Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Add `DioException` response data to error breadcrumb ([#3164](https://github.com/getsentry/sentry-dart/pull/3164))
- Bumped `dio` min verion to `5.2.0`
- Log a warning when dropping envelope items ([#3165](https://github.com/getsentry/sentry-dart/pull/3165))
- Call options.log for structured logs ([#3187](https://github.com/getsentry/sentry-dart/pull/3187))

### Dependencies

Expand Down
2 changes: 1 addition & 1 deletion metrics/metrics-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ apps:

startupTimeTest:
runs: 50
diffMin: 0
diffMin: -10 # For the flaky test case where the app with sentry is faster than the app without sentry.
diffMax: 150

binarySizeTest:
Expand Down
24 changes: 24 additions & 0 deletions packages/dart/lib/src/protocol/sentry_log_level.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'sentry_level.dart';

enum SentryLogLevel {
trace('trace'),
debug('debug'),
Expand Down Expand Up @@ -26,3 +28,25 @@ enum SentryLogLevel {
}
}
}

/// Extension to bridge SentryLogLevel to SentryLevel
extension SentryLogLevelExtension on SentryLogLevel {
/// Converts this SentryLogLevel to the corresponding SentryLevel
/// for use with the diagnostic logging system.
SentryLevel toSentryLevel() {
switch (this) {
case SentryLogLevel.trace:
return SentryLevel.debug;
case SentryLogLevel.debug:
return SentryLevel.debug;
case SentryLogLevel.info:
return SentryLevel.info;
case SentryLogLevel.warn:
return SentryLevel.warning;
case SentryLogLevel.error:
return SentryLevel.error;
case SentryLogLevel.fatal:
return SentryLevel.fatal;
}
}
}
60 changes: 60 additions & 0 deletions packages/dart/lib/src/sentry_logger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,66 @@ class SentryLogger {
body: body,
attributes: attributes ?? {},
);

_hub.options.log(
level.toSentryLevel(),
_formatLogMessage(level, body, attributes),
logger: 'sentry_logger',
);

return _hub.captureLog(log);
}

/// Format log message with level and attributes
String _formatLogMessage(
SentryLogLevel level,
String body,
Map<String, SentryLogAttribute>? attributes,
) {
if (attributes == null || attributes.isEmpty) {
return body;
}

final attrsStr = attributes.entries
.map((e) => '"${e.key}": ${_formatAttributeValue(e.value)}')
.join(', ');

return '$body {$attrsStr}';
}

/// Format attribute value based on its type
String _formatAttributeValue(SentryLogAttribute attribute) {
switch (attribute.type) {
case 'string':
if (attribute.value is String) {
return '"${attribute.value}"';
}
break;
case 'boolean':
if (attribute.value is bool) {
return attribute.value.toString();
}
break;
case 'integer':
if (attribute.value is int) {
return attribute.value.toString();
}
break;
case 'double':
if (attribute.value is double) {
final value = attribute.value as double;
// Handle special double values
if (value.isNaN || value.isInfinite) {
return value.toString();
}
// Ensure doubles always show decimal notation to distinguish from ints
// Use toStringAsFixed(1) for whole numbers, toString() for decimals
return value == value.toInt()
? value.toStringAsFixed(1)
: value.toString();
}
break;
}
return attribute.value.toString();
}
}
27 changes: 27 additions & 0 deletions packages/dart/test/protocol/sentry_log_level_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:test/test.dart';
import 'package:sentry/src/protocol/sentry_log_level.dart';
import 'package:sentry/src/protocol/sentry_level.dart';

void main() {
group('SentryLogLevel', () {
test('toSeverityNumber returns correct values', () {
expect(SentryLogLevel.trace.toSeverityNumber(), 1);
expect(SentryLogLevel.debug.toSeverityNumber(), 5);
expect(SentryLogLevel.info.toSeverityNumber(), 9);
expect(SentryLogLevel.warn.toSeverityNumber(), 13);
expect(SentryLogLevel.error.toSeverityNumber(), 17);
expect(SentryLogLevel.fatal.toSeverityNumber(), 21);
});
});

group('SentryLogLevelExtension', () {
test('toSentryLevel bridges levels correctly', () {
expect(SentryLogLevel.trace.toSentryLevel(), SentryLevel.debug);
expect(SentryLogLevel.debug.toSentryLevel(), SentryLevel.debug);
expect(SentryLogLevel.info.toSentryLevel(), SentryLevel.info);
expect(SentryLogLevel.warn.toSentryLevel(), SentryLevel.warning);
expect(SentryLogLevel.error.toSentryLevel(), SentryLevel.error);
expect(SentryLogLevel.fatal.toSentryLevel(), SentryLevel.fatal);
});
});
}
141 changes: 140 additions & 1 deletion packages/dart/test/sentry_logger_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,114 @@ void main() {

verifyCaptureLog(SentryLogLevel.fatal);
});

test('logs to hub options when provided', () {
final mockLogCallback = _MockSdkLogCallback();

// Set the mock log callback on the fixture hub
fixture.hub.options.log = mockLogCallback.call;
fixture.hub.options.debug = true;
fixture.hub.options.diagnosticLevel = SentryLevel.debug;

final logger = SentryLogger(
() => fixture.timestamp,
hub: fixture.hub,
);

logger.trace('test message', attributes: fixture.attributes);

// Verify that both hub.captureLog and our callback were called
expect(fixture.hub.captureLogCalls.length, 1);
expect(mockLogCallback.calls.length, 1);

// Verify the captured log has the right content
final capturedLog = fixture.hub.captureLogCalls[0].log;
expect(capturedLog.level, SentryLogLevel.trace);
expect(capturedLog.body, 'test message');
expect(capturedLog.attributes, fixture.attributes);

// Verify the log callback was called with the right parameters
final logCall = mockLogCallback.calls[0];
expect(logCall.level, SentryLevel.debug); // trace maps to debug
expect(logCall.message,
'test message {"string": "string", "int": 1, "double": 1.23456789, "bool": true, "double_int": 1.0, "nan": NaN, "positive_infinity": Infinity, "negative_infinity": -Infinity}');
expect(logCall.logger, 'sentry_logger');
});

test('bridges SentryLogLevel to SentryLevel correctly', () {
final mockLogCallback = _MockSdkLogCallback();

// Set the mock log callback on the fixture hub's options
fixture.hub.options.log = mockLogCallback.call;
fixture.hub.options.debug = true;
fixture.hub.options.diagnosticLevel = SentryLevel.debug;

final logger = SentryLogger(
() => fixture.timestamp,
hub: fixture.hub,
);

// Test all log levels to ensure proper bridging
logger.trace('trace message');
logger.debug('debug message');
logger.info('info message');
logger.warn('warn message');
logger.error('error message');
logger.fatal('fatal message');

// Verify that all calls were made to both the hub and the log callback
expect(fixture.hub.captureLogCalls.length, 6);
expect(mockLogCallback.calls.length, 6);

// Verify the bridging is correct
expect(mockLogCallback.calls[0].level, SentryLevel.debug); // trace -> debug
expect(mockLogCallback.calls[1].level, SentryLevel.debug); // debug -> debug
expect(mockLogCallback.calls[2].level, SentryLevel.info); // info -> info
expect(
mockLogCallback.calls[3].level, SentryLevel.warning); // warn -> warning
expect(mockLogCallback.calls[4].level, SentryLevel.error); // error -> error
expect(mockLogCallback.calls[5].level, SentryLevel.fatal); // fatal -> fatal
});

test('handles NaN and infinite values correctly', () {
final mockLogCallback = _MockSdkLogCallback();

// Set the mock log callback on the fixture hub's options
fixture.hub.options.log = mockLogCallback.call;
fixture.hub.options.debug = true;
fixture.hub.options.diagnosticLevel = SentryLevel.debug;

final logger = SentryLogger(
() => fixture.timestamp,
hub: fixture.hub,
);

// Test with special double values
final specialAttributes = <String, SentryLogAttribute>{
'nan': SentryLogAttribute.double(double.nan),
'positive_infinity': SentryLogAttribute.double(double.infinity),
'negative_infinity': SentryLogAttribute.double(double.negativeInfinity),
};

logger.info('special values', attributes: specialAttributes);

// Verify that both hub.captureLog and our callback were called
expect(fixture.hub.captureLogCalls.length, 1);
expect(mockLogCallback.calls.length, 1);

// Verify the captured log has the right content
final capturedLog = fixture.hub.captureLogCalls[0].log;
expect(capturedLog.level, SentryLogLevel.info);
expect(capturedLog.body, 'special values');
expect(capturedLog.attributes, specialAttributes);

// Verify the log callback was called with the right parameters
final logCall = mockLogCallback.calls[0];
expect(logCall.level, SentryLevel.info);
expect(logCall.message,
'special values {"nan": NaN, "positive_infinity": Infinity, "negative_infinity": -Infinity}');
expect(logCall.logger, 'sentry_logger');
});
}

class Fixture {
Expand All @@ -85,11 +193,42 @@ class Fixture {
final attributes = <String, SentryLogAttribute>{
'string': SentryLogAttribute.string('string'),
'int': SentryLogAttribute.int(1),
'double': SentryLogAttribute.double(1.0),
'double': SentryLogAttribute.double(1.23456789),
'bool': SentryLogAttribute.bool(true),
'double_int': SentryLogAttribute.double(1.0),
'nan': SentryLogAttribute.double(double.nan),
'positive_infinity': SentryLogAttribute.double(double.infinity),
'negative_infinity': SentryLogAttribute.double(double.negativeInfinity),
};

SentryLogger getSut() {
return SentryLogger(() => timestamp, hub: hub);
}
}

/// Simple mock for SdkLogCallback to track calls
class _MockSdkLogCallback {
final List<_LogCall> calls = [];

void call(
SentryLevel level,
String message, {
String? logger,
Object? exception,
StackTrace? stackTrace,
}) {
calls.add(_LogCall(level, message, logger, exception, stackTrace));
}
}

/// Data class to store log call information
class _LogCall {
final SentryLevel level;
final String message;
final String? logger;
final Object? exception;
final StackTrace? stackTrace;

_LogCall(
this.level, this.message, this.logger, this.exception, this.stackTrace);
}
Loading