diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6ab1800a..4383846760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/metrics/metrics-ios.yml b/metrics/metrics-ios.yml index 8a44389491..cd3ad068a6 100644 --- a/metrics/metrics-ios.yml +++ b/metrics/metrics-ios.yml @@ -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: diff --git a/packages/dart/lib/src/protocol/sentry_log_level.dart b/packages/dart/lib/src/protocol/sentry_log_level.dart index aac0b386bc..5fd441e8c9 100644 --- a/packages/dart/lib/src/protocol/sentry_log_level.dart +++ b/packages/dart/lib/src/protocol/sentry_log_level.dart @@ -1,3 +1,5 @@ +import 'sentry_level.dart'; + enum SentryLogLevel { trace('trace'), debug('debug'), @@ -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; + } + } +} diff --git a/packages/dart/lib/src/sentry_logger.dart b/packages/dart/lib/src/sentry_logger.dart index 930b78166e..2ae95dad9f 100644 --- a/packages/dart/lib/src/sentry_logger.dart +++ b/packages/dart/lib/src/sentry_logger.dart @@ -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? 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(); + } } diff --git a/packages/dart/test/protocol/sentry_log_level_test.dart b/packages/dart/test/protocol/sentry_log_level_test.dart new file mode 100644 index 0000000000..8139859c0e --- /dev/null +++ b/packages/dart/test/protocol/sentry_log_level_test.dart @@ -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); + }); + }); +} diff --git a/packages/dart/test/sentry_logger_test.dart b/packages/dart/test/sentry_logger_test.dart index 96e4aa7743..7a3a303256 100644 --- a/packages/dart/test/sentry_logger_test.dart +++ b/packages/dart/test/sentry_logger_test.dart @@ -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 = { + '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 { @@ -85,11 +193,42 @@ class Fixture { final attributes = { '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); +}