From 1a4b78b03b537e646ded1b661ebf014da075fc93 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 17:43:43 +0100 Subject: [PATCH 01/42] Add TelemetryProcessor for span and log buffering Co-Authored-By: Claude Sonnet 4.5 --- packages/dart/lib/sentry.dart | 1 + packages/dart/lib/src/sentry_client.dart | 28 +- packages/dart/lib/src/sentry_options.dart | 20 +- .../lib/src/telemetry/processing/buffer.dart | 13 + .../telemetry/processing/buffer_config.dart | 15 + .../processing/in_memory_buffer.dart | 180 ++++++ .../src/telemetry/processing/processor.dart | 100 ++++ .../processing/processor_integration.dart | 61 +++ .../test/mocks/mock_telemetry_buffer.dart | 23 + .../test/mocks/mock_telemetry_processor.dart | 25 + .../test/sentry_client_lifecycle_test.dart | 13 +- .../sentry_client_sdk_lifecycle_test.dart | 13 +- packages/dart/test/sentry_client_test.dart | 171 +++--- .../telemetry/processing/buffer_test.dart | 310 +++++++++++ .../processor_integration_test.dart | 161 ++++++ .../telemetry/processing/processor_test.dart | 173 ++++++ .../dart/test/telemetry/span/span_test.dart | 512 ++++++++++++++++++ .../lib/src/widgets_binding_observer.dart | 2 +- packages/flutter/test/mocks.dart | 24 + .../test/widgets_binding_observer_test.dart | 23 +- 20 files changed, 1757 insertions(+), 111 deletions(-) create mode 100644 packages/dart/lib/src/telemetry/processing/buffer.dart create mode 100644 packages/dart/lib/src/telemetry/processing/buffer_config.dart create mode 100644 packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart create mode 100644 packages/dart/lib/src/telemetry/processing/processor.dart create mode 100644 packages/dart/lib/src/telemetry/processing/processor_integration.dart create mode 100644 packages/dart/test/mocks/mock_telemetry_buffer.dart create mode 100644 packages/dart/test/mocks/mock_telemetry_processor.dart create mode 100644 packages/dart/test/telemetry/processing/buffer_test.dart create mode 100644 packages/dart/test/telemetry/processing/processor_integration_test.dart create mode 100644 packages/dart/test/telemetry/processing/processor_test.dart create mode 100644 packages/dart/test/telemetry/span/span_test.dart diff --git a/packages/dart/lib/sentry.dart b/packages/dart/lib/sentry.dart index 8d01a3181c..ccfd5b5e8f 100644 --- a/packages/dart/lib/sentry.dart +++ b/packages/dart/lib/sentry.dart @@ -41,6 +41,7 @@ export 'src/sdk_lifecycle_hooks.dart'; export 'src/sentry_envelope.dart'; export 'src/sentry_envelope_item.dart'; export 'src/sentry_options.dart'; +export 'src/telemetry/sentry_trace_lifecycle.dart'; // ignore: invalid_export_of_internal_element export 'src/sentry_trace_origins.dart'; export 'src/span_data_convention.dart'; diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index b53eb998ac..17b97fc0e1 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -18,6 +18,7 @@ import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'sentry_trace_context_header.dart'; +import 'telemetry/span/sentry_span_v2.dart'; import 'transport/client_report_transport.dart'; import 'transport/data_category.dart'; import 'transport/http_transport.dart'; @@ -28,7 +29,6 @@ import 'type_check_hint.dart'; import 'utils/isolate_utils.dart'; import 'utils/regex_utils.dart'; import 'utils/stacktrace_utils.dart'; -import 'sentry_log_batcher.dart'; import 'version.dart'; /// Default value for [SentryUser.ipAddress]. It gets set when an event does not have @@ -75,9 +75,6 @@ class SentryClient { if (enableFlutterSpotlight) { options.transport = SpotlightHttpTransport(options, options.transport); } - if (options.enableLogs) { - options.logBatcher = SentryLogBatcher(options); - } return SentryClient._(options); } @@ -493,6 +490,25 @@ class SentryClient { ); } + void captureSpan( + SentrySpanV2 span, { + Scope? scope, + }) { + switch (span) { + case UnsetSentrySpanV2(): + _options.log( + SentryLevel.warning, + "captureSpan: span is in an invalid state $UnsetSentrySpanV2.", + ); + case NoOpSentrySpanV2(): + return; + case RecordingSentrySpanV2 span: + // TODO(next-pr): add common attributes, merge scope attributes + + _options.telemetryProcessor.addSpan(span); + } + } + @internal FutureOr captureLog( SentryLog log, { @@ -578,7 +594,7 @@ class SentryClient { if (processedLog != null) { await _options.lifecycleRegistry .dispatchCallback(OnBeforeCaptureLog(processedLog)); - _options.logBatcher.addLog(processedLog); + _options.telemetryProcessor.addLog(processedLog); } else { _options.recorder.recordLostEvent( DiscardReason.beforeSend, @@ -588,7 +604,7 @@ class SentryClient { } FutureOr close() { - final flush = _options.logBatcher.flush(); + final flush = _options.telemetryProcessor.flush(); if (flush is Future) { return flush.then((_) => _options.httpClient.close()); } diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 9cb8b881fa..ab188f8e3e 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -12,10 +12,9 @@ import 'noop_client.dart'; import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; +import 'telemetry/processing/processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; -import 'sentry_log_batcher.dart'; -import 'noop_log_batcher.dart'; import 'dart:developer' as developer; // TODO: shutdownTimeout, flushTimeoutMillis @@ -233,6 +232,21 @@ class SentryOptions { /// sent. Events are picked randomly. Default is null (disabled) double? sampleRate; + /// Chooses between two tracing systems. You can only use one at a time. + /// + /// [SentryTraceLifecycle.streaming] sends each span to Sentry as it finishes. + /// Use [Sentry.startSpan] to create spans. The older transaction APIs + /// ([Sentry.startTransaction], [ISentrySpan.startChild]) will do nothing. + /// + /// [SentryTraceLifecycle.static] collects all spans and sends them together + /// when the transaction ends. Use [Sentry.startTransaction] to create traces. + /// The newer span APIs ([Sentry.startSpan]) will do nothing. + /// + /// Integrations automatically switch to the correct API based on this setting. + /// + /// Defaults to [SentryTraceLifecycle.static]. + SentryTraceLifecycle traceLifecycle = SentryTraceLifecycle.static; + /// The ignoreErrors tells the SDK which errors should be not sent to the sentry server. /// If an null or an empty list is used, the SDK will send all transactions. /// To use regex add the `^` and the `$` to the string. @@ -554,7 +568,7 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); @internal - SentryLogBatcher logBatcher = NoopLogBatcher(); + TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) { this.dsn = dsn; diff --git a/packages/dart/lib/src/telemetry/processing/buffer.dart b/packages/dart/lib/src/telemetry/processing/buffer.dart new file mode 100644 index 0000000000..518c9808ad --- /dev/null +++ b/packages/dart/lib/src/telemetry/processing/buffer.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +/// A buffer that batches telemetry items for efficient transmission to Sentry. +/// +/// Collects items of type [T] and sends them in batches rather than +/// individually, reducing network overhead. +abstract class TelemetryBuffer { + /// Adds an item to the buffer. + void add(T item); + + /// When executed immediately sends all buffered items to Sentry and clears the buffer. + FutureOr flush(); +} diff --git a/packages/dart/lib/src/telemetry/processing/buffer_config.dart b/packages/dart/lib/src/telemetry/processing/buffer_config.dart new file mode 100644 index 0000000000..7ef8214aa1 --- /dev/null +++ b/packages/dart/lib/src/telemetry/processing/buffer_config.dart @@ -0,0 +1,15 @@ +final class TelemetryBufferConfig { + final Duration flushTimeout; + final int maxBufferSizeBytes; + final int maxItemCount; + + const TelemetryBufferConfig({ + this.flushTimeout = defaultFlushTimeout, + this.maxBufferSizeBytes = defaultMaxBufferSizeBytes, + this.maxItemCount = defaultMaxItemCount, + }); + + static const Duration defaultFlushTimeout = Duration(seconds: 5); + static const int defaultMaxBufferSizeBytes = 1024 * 1024; + static const int defaultMaxItemCount = 100; +} diff --git a/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart b/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart new file mode 100644 index 0000000000..9b62344e52 --- /dev/null +++ b/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../utils/internal_logger.dart'; +import 'buffer.dart'; +import 'buffer_config.dart'; + +/// Callback invoked when the buffer is flushed with the accumulated data. +typedef OnFlushCallback = FutureOr Function(T data); + +/// Encodes an item of type [T] into bytes. +typedef ItemEncoder = List Function(T item); + +/// Extracts a grouping key from items of type [T]. +typedef GroupKeyExtractor = String Function(T item); + +/// Base class for in-memory telemetry buffers. +/// +/// Buffers telemetry items in memory and flushes them when either the +/// configured size limit, item count limit, or flush timeout is reached. +abstract base class _BaseInMemoryTelemetryBuffer + implements TelemetryBuffer { + final TelemetryBufferConfig _config; + final ItemEncoder _encoder; + final OnFlushCallback _onFlush; + + S _storage; + int _bufferSize = 0; + int _itemCount = 0; + Timer? _flushTimer; + + _BaseInMemoryTelemetryBuffer({ + required ItemEncoder encoder, + required OnFlushCallback onFlush, + required S initialStorage, + TelemetryBufferConfig config = const TelemetryBufferConfig(), + }) : _encoder = encoder, + _onFlush = onFlush, + _storage = initialStorage, + _config = config; + + S _createEmptyStorage(); + void _store(List encoded, T item); + bool get _isEmpty; + + bool get _isBufferFull => + _bufferSize >= _config.maxBufferSizeBytes || + _itemCount >= _config.maxItemCount; + + @override + void add(T item) { + final List encoded; + try { + encoded = _encoder(item); + } catch (exception, stackTrace) { + internalLogger.error( + '$runtimeType: Failed to encode item, dropping', + error: exception, + stackTrace: stackTrace, + ); + return; + } + + if (encoded.length > _config.maxBufferSizeBytes) { + internalLogger.warning( + '$runtimeType: Item size ${encoded.length} exceeds buffer limit ${_config.maxBufferSizeBytes}, dropping', + ); + return; + } + + _store(encoded, item); + _bufferSize += encoded.length; + _itemCount++; + + if (_isBufferFull) { + internalLogger.debug( + '$runtimeType: Buffer full, flushing $_itemCount items', + ); + flush(); + } else { + _flushTimer ??= Timer(_config.flushTimeout, flush); + } + } + + @override + FutureOr flush() { + _flushTimer?.cancel(); + _flushTimer = null; + + if (_isEmpty) return null; + + final toFlush = _storage; + final flushedCount = _itemCount; + final flushedSize = _bufferSize; + _storage = _createEmptyStorage(); + _bufferSize = 0; + _itemCount = 0; + + final successMessage = + '$runtimeType: Flushed $flushedCount items ($flushedSize bytes)'; + final errorMessage = + '$runtimeType: Flush failed for $flushedCount items ($flushedSize bytes)'; + + try { + final result = _onFlush(toFlush); + if (result is Future) { + return result.then( + (_) => internalLogger.debug(successMessage), + onError: (exception, stackTrace) => internalLogger.warning( + errorMessage, + error: exception, + stackTrace: stackTrace, + ), + ); + } + internalLogger.debug(successMessage); + } catch (exception, stackTrace) { + internalLogger.warning( + errorMessage, + error: exception, + stackTrace: stackTrace, + ); + } + } +} + +/// In-memory buffer that collects telemetry items as a flat list. +/// +/// Items are encoded and stored in insertion order. On flush, the entire +/// list of encoded items is passed to the [OnFlushCallback]. +final class InMemoryTelemetryBuffer + extends _BaseInMemoryTelemetryBuffer>> { + InMemoryTelemetryBuffer({ + required super.encoder, + required super.onFlush, + super.config, + }) : super(initialStorage: []); + + @override + List> _createEmptyStorage() => []; + + @override + void _store(List encoded, T item) => _storage.add(encoded); + + @override + bool get _isEmpty => _storage.isEmpty; +} + +/// In-memory buffer that groups telemetry items by a key. +/// +/// Same idea as [InMemoryTelemetryBuffer], but grouped. +final class GroupedInMemoryTelemetryBuffer + extends _BaseInMemoryTelemetryBuffer>, T)>> { + final GroupKeyExtractor _groupKey; + + @visibleForTesting + GroupKeyExtractor get groupKey => _groupKey; + + GroupedInMemoryTelemetryBuffer({ + required super.encoder, + required super.onFlush, + required GroupKeyExtractor groupKeyExtractor, + super.config, + }) : _groupKey = groupKeyExtractor, + super(initialStorage: {}); + + @override + Map>, T)> _createEmptyStorage() => {}; + + @override + void _store(List encoded, T item) { + final key = _groupKey(item); + final bucket = _storage.putIfAbsent(key, () => ([], item)); + bucket.$1.add(encoded); + } + + @override + bool get _isEmpty => _storage.isEmpty; +} diff --git a/packages/dart/lib/src/telemetry/processing/processor.dart b/packages/dart/lib/src/telemetry/processing/processor.dart new file mode 100644 index 0000000000..c2c3b47b27 --- /dev/null +++ b/packages/dart/lib/src/telemetry/processing/processor.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../../sentry.dart'; +import '../span/sentry_span_v2.dart'; +import 'buffer.dart'; + +/// Interface for processing and buffering telemetry data before sending. +/// +/// Implementations collect spans and logs, buffering them until flushed. +/// This enables batching of telemetry data for efficient transport. +abstract class TelemetryProcessor { + /// Adds a span to be processed and buffered. + void addSpan(RecordingSentrySpanV2 span); + + /// Adds a log to be processed and buffered. + void addLog(SentryLog log); + + /// Flushes all buffered telemetry data. + /// + /// Returns a [Future] if any buffer performs async flushing, otherwise + /// returns synchronously. + FutureOr flush(); +} + +/// Default telemetry processor that routes items to type-specific buffers. +/// +/// Spans and logs are dispatched to their respective [TelemetryBuffer] +/// instances. If no buffer is registered for a telemetry type, items are +/// dropped with a warning. +class DefaultTelemetryProcessor implements TelemetryProcessor { + final SdkLogCallback _logger; + + /// The buffer for span data, or `null` if span buffering is disabled. + @visibleForTesting + TelemetryBuffer? spanBuffer; + + /// The buffer for log data, or `null` if log buffering is disabled. + @visibleForTesting + TelemetryBuffer? logBuffer; + + DefaultTelemetryProcessor( + this._logger, { + this.spanBuffer, + this.logBuffer, + }); + + @override + void addSpan(RecordingSentrySpanV2 span) => _add(span); + + @override + void addLog(SentryLog log) => _add(log); + + void _add(dynamic item) { + final buffer = switch (item) { + RecordingSentrySpanV2 _ => spanBuffer, + SentryLog _ => logBuffer, + _ => null, + }; + + if (buffer == null) { + _logger( + SentryLevel.warning, + '$runtimeType: No buffer registered for ${item.runtimeType} - item was dropped', + ); + return; + } + + buffer.add(item); + } + + @override + FutureOr flush() { + _logger(SentryLevel.debug, '$runtimeType: Clearing buffers'); + + final results = >[ + spanBuffer?.flush(), + logBuffer?.flush(), + ]; + + final futures = results.whereType().toList(); + if (futures.isEmpty) { + return null; + } + + return Future.wait(futures).then((_) {}); + } +} + +class NoOpTelemetryProcessor implements TelemetryProcessor { + @override + void addSpan(RecordingSentrySpanV2 span) {} + + @override + void addLog(SentryLog log) {} + + @override + FutureOr flush() {} +} diff --git a/packages/dart/lib/src/telemetry/processing/processor_integration.dart b/packages/dart/lib/src/telemetry/processing/processor_integration.dart new file mode 100644 index 0000000000..5ac4c0f802 --- /dev/null +++ b/packages/dart/lib/src/telemetry/processing/processor_integration.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../../sentry.dart'; +import '../span/sentry_span_v2.dart'; +import 'in_memory_buffer.dart'; +import 'processor.dart'; + +class DefaultTelemetryProcessorIntegration extends Integration { + static const integrationName = 'DefaultTelemetryProcessor'; + + @visibleForTesting + final GroupKeyExtractor spanGroupKeyExtractor = + (RecordingSentrySpanV2 item) => + '${item.traceId}-${item.segmentSpan.spanId}'; + + @override + void call(Hub hub, SentryOptions options) { + if (options.telemetryProcessor is! NoOpTelemetryProcessor) { + options.log( + SentryLevel.debug, + '$integrationName: ${options.telemetryProcessor.runtimeType} already set, skipping', + ); + return; + } + + options.telemetryProcessor = DefaultTelemetryProcessor(options.log, + logBuffer: _createLogBuffer(options), + spanBuffer: _createSpanBuffer(options)); + + options.sdk.addIntegration(integrationName); + } + + InMemoryTelemetryBuffer _createLogBuffer(SentryOptions options) => + InMemoryTelemetryBuffer( + encoder: (SentryLog item) => utf8JsonEncoder.convert(item.toJson()), + onFlush: (items) { + final envelope = SentryEnvelope.fromLogsData( + items.map((item) => item).toList(), options.sdk); + return options.transport.send(envelope).then((_) {}); + }); + + GroupedInMemoryTelemetryBuffer _createSpanBuffer( + SentryOptions options) => + GroupedInMemoryTelemetryBuffer( + encoder: (RecordingSentrySpanV2 item) => + utf8JsonEncoder.convert(item.toJson()), + onFlush: (items) { + final futures = items.values.map((itemData) { + final dsc = itemData.$2.resolveDsc(); + final envelope = SentryEnvelope.fromSpansData( + itemData.$1, options.sdk, + traceContext: dsc); + return options.transport.send(envelope); + }).toList(); + if (futures.isEmpty) return null; + return Future.wait(futures).then((_) {}); + }, + groupKeyExtractor: spanGroupKeyExtractor); +} diff --git a/packages/dart/test/mocks/mock_telemetry_buffer.dart b/packages/dart/test/mocks/mock_telemetry_buffer.dart new file mode 100644 index 0000000000..da257d2751 --- /dev/null +++ b/packages/dart/test/mocks/mock_telemetry_buffer.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:sentry/src/telemetry/processing/buffer.dart'; + +class MockTelemetryBuffer extends TelemetryBuffer { + final List addedItems = []; + int flushCallCount = 0; + final bool asyncFlush; + + MockTelemetryBuffer({this.asyncFlush = false}); + + @override + void add(T item) => addedItems.add(item); + + @override + FutureOr flush() { + flushCallCount++; + if (asyncFlush) { + return Future.value(); + } + return null; + } +} diff --git a/packages/dart/test/mocks/mock_telemetry_processor.dart b/packages/dart/test/mocks/mock_telemetry_processor.dart new file mode 100644 index 0000000000..db1b80470d --- /dev/null +++ b/packages/dart/test/mocks/mock_telemetry_processor.dart @@ -0,0 +1,25 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; + +class MockTelemetryProcessor implements TelemetryProcessor { + final List addedSpans = []; + final List addedLogs = []; + int flushCalls = 0; + int closeCalls = 0; + + @override + void addSpan(RecordingSentrySpanV2 span) { + addedSpans.add(span); + } + + @override + void addLog(SentryLog log) { + addedLogs.add(log); + } + + @override + void flush() { + flushCalls++; + } +} diff --git a/packages/dart/test/sentry_client_lifecycle_test.dart b/packages/dart/test/sentry_client_lifecycle_test.dart index 18c397a46c..060cb6e261 100644 --- a/packages/dart/test/sentry_client_lifecycle_test.dart +++ b/packages/dart/test/sentry_client_lifecycle_test.dart @@ -4,7 +4,7 @@ import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; import 'mocks/mock_client_report_recorder.dart'; -import 'mocks/mock_log_batcher.dart'; +import 'mocks/mock_telemetry_processor.dart'; import 'mocks/mock_transport.dart'; import 'sentry_client_test.dart'; import 'test_utils.dart'; @@ -41,7 +41,8 @@ void main() { scope.span = span; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; fixture.options.lifecycleRegistry .registerCallback((event) { @@ -50,9 +51,8 @@ void main() { await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.attributes['test']?.value, "test-value"); expect(capturedLog.attributes['test']?.type, 'string'); @@ -72,7 +72,8 @@ void main() { scope.span = span; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; fixture.options.lifecycleRegistry .registerCallback((event) { diff --git a/packages/dart/test/sentry_client_sdk_lifecycle_test.dart b/packages/dart/test/sentry_client_sdk_lifecycle_test.dart index 18c397a46c..060cb6e261 100644 --- a/packages/dart/test/sentry_client_sdk_lifecycle_test.dart +++ b/packages/dart/test/sentry_client_sdk_lifecycle_test.dart @@ -4,7 +4,7 @@ import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; import 'mocks/mock_client_report_recorder.dart'; -import 'mocks/mock_log_batcher.dart'; +import 'mocks/mock_telemetry_processor.dart'; import 'mocks/mock_transport.dart'; import 'sentry_client_test.dart'; import 'test_utils.dart'; @@ -41,7 +41,8 @@ void main() { scope.span = span; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; fixture.options.lifecycleRegistry .registerCallback((event) { @@ -50,9 +51,8 @@ void main() { await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.attributes['test']?.value, "test-value"); expect(capturedLog.attributes['test']?.type, 'string'); @@ -72,7 +72,8 @@ void main() { scope.span = span; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; fixture.options.lifecycleRegistry .registerCallback((event) { diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index 534f665774..32c51484ef 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -16,19 +16,18 @@ import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/transport/noop_transport.dart'; import 'package:sentry/src/transport/spotlight_http_transport.dart'; import 'package:sentry/src/utils/iterable_utils.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'package:test/test.dart'; -import 'package:sentry/src/noop_log_batcher.dart'; -import 'package:sentry/src/sentry_log_batcher.dart'; import 'package:mockito/mockito.dart'; import 'package:http/http.dart' as http; import 'mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_hub.dart'; +import 'mocks/mock_telemetry_processor.dart'; import 'mocks/mock_transport.dart'; import 'test_utils.dart'; import 'utils/url_details_test.dart'; -import 'mocks/mock_log_batcher.dart'; void main() { group('SentryClient captures message', () { @@ -1734,41 +1733,32 @@ void main() { ); } - test('sets log batcher on options when logs are enabled', () async { - expect(fixture.options.logBatcher is NoopLogBatcher, true); - - fixture.options.enableLogs = true; - fixture.getSut(); - - expect(fixture.options.logBatcher is NoopLogBatcher, false); - }); - test('disabled by default', () async { final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); await client.captureLog(log); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls, isEmpty); + expect(mockProcessor.addedLogs, isEmpty); }); test('should capture logs as envelope', () async { fixture.options.enableLogs = true; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); await client.captureLog(log); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); + expect(mockProcessor.addedLogs.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.traceId, log.traceId); expect(capturedLog.level, log.level); @@ -1789,13 +1779,13 @@ void main() { scope.span = span; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect( capturedLog.attributes['sentry.sdk.name']?.value, @@ -1843,7 +1833,8 @@ void main() { fixture.options.enableLogs = true; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); final scope = Scope(fixture.options); @@ -1851,9 +1842,8 @@ void main() { await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.attributes['from_scope']?.value, 12); }); @@ -1861,7 +1851,8 @@ void main() { fixture.options.enableLogs = true; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); final scope = Scope(fixture.options); @@ -1875,9 +1866,8 @@ void main() { await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final captured = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final captured = mockProcessor.addedLogs.first; expect(captured.attributes['overridden']?.value, 'fromLog'); expect(captured.attributes['kept']?.value, true); @@ -1897,13 +1887,13 @@ void main() { await scope.setUser(user); final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect( capturedLog.attributes['user.id']?.value, @@ -1937,16 +1927,16 @@ void main() { fixture.options.enableLogs = true; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); final scope = Scope(fixture.options); await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.traceId, scope.propagationContext.traceId); }); @@ -1958,14 +1948,14 @@ void main() { fixture.options.beforeSendLog = (log) => null; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); await client.captureLog(log); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 0); + expect(mockProcessor.addedLogs.length, 0); expect( fixture.recorder.discardedEvents.first.reason, @@ -1985,15 +1975,15 @@ void main() { }; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); await client.captureLog(log); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.body, 'modified'); }); @@ -2007,15 +1997,15 @@ void main() { }; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); await client.captureLog(log); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.body, 'modified'); }); @@ -2029,14 +2019,14 @@ void main() { }; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; final log = givenLog(); await client.captureLog(log); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.body, 'test'); }); @@ -2053,7 +2043,8 @@ void main() { scope.span = span; final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = mockProcessor; fixture.options.lifecycleRegistry .registerCallback((event) { @@ -2062,15 +2053,61 @@ void main() { await client.captureLog(log, scope: scope); - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; + expect(mockProcessor.addedLogs.length, 1); + final capturedLog = mockProcessor.addedLogs.first; expect(capturedLog.attributes['test']?.value, "test-value"); expect(capturedLog.attributes['test']?.type, 'string'); }); }); + group('SentryClient', () { + group('when capturing span', () { + late Fixture fixture; + late MockTelemetryProcessor processor; + + setUp(() { + fixture = Fixture(); + processor = MockTelemetryProcessor(); + fixture.options.telemetryProcessor = processor; + }); + + test('adds recording span to telemetry processor', () { + final client = fixture.getSut(); + + final span = RecordingSentrySpanV2.root( + name: 'test-span', + traceId: SentryId.newId(), + onSpanEnd: (_) {}, + clock: fixture.options.clock, + dscCreator: (s) => SentryTraceContextHeader(SentryId.newId(), 'key'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + + client.captureSpan(span); + + expect(processor.addedSpans, hasLength(1)); + expect(processor.addedSpans.first, equals(span)); + }); + + test('does nothing for NoOpSentrySpanV2', () { + final client = fixture.getSut(); + + client.captureSpan(const NoOpSentrySpanV2()); + + expect(processor.addedSpans, isEmpty); + }); + + test('does nothing for UnsetSentrySpanV2', () { + final client = fixture.getSut(); + + client.captureSpan(const UnsetSentrySpanV2()); + + expect(processor.addedSpans, isEmpty); + }); + }); + }); + group('SentryClient captures envelope', () { late Fixture fixture; final fakeEnvelope = getFakeEnvelope(); @@ -2678,15 +2715,15 @@ void main() { final flushCompleter = Completer(); bool flushStarted = false; - // Create a mock log batcher with async flush - final mockLogBatcher = MockLogBatcherWithAsyncFlush( + // Create a mock telemetry processor with async flush + final mockProcessor = MockTelemetryProcessorWithAsyncFlush( onFlush: () async { flushStarted = true; // Wait for the completer to complete await flushCompleter.future; }, ); - fixture.options.logBatcher = mockLogBatcher; + fixture.options.telemetryProcessor = mockProcessor; // Start close() in the background final closeFuture = client.close(); @@ -2904,20 +2941,14 @@ class Fixture { class MockHttpClient extends Mock implements http.Client {} -class MockLogBatcherWithAsyncFlush implements SentryLogBatcher { +class MockTelemetryProcessorWithAsyncFlush extends MockTelemetryProcessor { final Future Function() onFlush; - final addLogCalls = []; - MockLogBatcherWithAsyncFlush({required this.onFlush}); - - @override - void addLog(SentryLog log) { - addLogCalls.add(log); - } + MockTelemetryProcessorWithAsyncFlush({required this.onFlush}); @override FutureOr flush() async { - await onFlush(); + return onFlush(); } } diff --git a/packages/dart/test/telemetry/processing/buffer_test.dart b/packages/dart/test/telemetry/processing/buffer_test.dart new file mode 100644 index 0000000000..e74cb81d01 --- /dev/null +++ b/packages/dart/test/telemetry/processing/buffer_test.dart @@ -0,0 +1,310 @@ +import 'dart:convert'; + +import 'package:sentry/src/telemetry/processing/in_memory_buffer.dart'; +import 'package:sentry/src/telemetry/processing/buffer_config.dart'; +import 'package:test/test.dart'; + +void main() { + group('InMemoryTelemetryBuffer', () { + late _SimpleFixture fixture; + + setUp(() { + fixture = _SimpleFixture(); + }); + + test('items are flushed after timeout', () async { + final flushTimeout = Duration(milliseconds: 1); + final buffer = fixture.getSut( + config: TelemetryBufferConfig(flushTimeout: flushTimeout), + ); + + buffer.add(_TestItem('item1')); + buffer.add(_TestItem('item2')); + + expect(fixture.flushedItems, isEmpty); + + await Future.delayed(flushTimeout + Duration(milliseconds: 10)); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedItems, hasLength(2)); + }); + + test('items exceeding max size are flushed immediately', () async { + // Each item encodes to ~14 bytes ({"id":"item1"}), so 20 bytes triggers flush on 2nd item + final buffer = fixture.getSut( + config: TelemetryBufferConfig(maxBufferSizeBytes: 20), + ); + + buffer.add(_TestItem('item1')); + expect(fixture.flushCallCount, 0); + + buffer.add(_TestItem('item2')); + + // Wait briefly for async flush + await Future.delayed(Duration(milliseconds: 1)); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedItems, hasLength(2)); + }); + + test('single item exceeding max buffer size is rejected', () async { + // Set max buffer size to 10 bytes, but item encodes to ~14 bytes + final buffer = fixture.getSut( + config: TelemetryBufferConfig(maxBufferSizeBytes: 10), + ); + + buffer.add(_TestItem('item1')); + + // Item should be rejected, not added to buffer + await buffer.flush(); + + expect(fixture.flushedItems, isEmpty); + }); + + test('items exceeding max item count are flushed immediately', () async { + final buffer = fixture.getSut( + config: TelemetryBufferConfig(maxItemCount: 2), + ); + + buffer.add(_TestItem('item1')); + expect(fixture.flushCallCount, 0); + + buffer.add(_TestItem('item2')); + + // Wait briefly for async flush + await Future.delayed(Duration(milliseconds: 1)); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedItems, hasLength(2)); + }); + + test('calling flush directly sends items', () async { + final buffer = fixture.getSut(); + + buffer.add(_TestItem('item1')); + buffer.add(_TestItem('item2')); + + await buffer.flush(); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedItems, hasLength(2)); + }); + + test('timer is only started once and not restarted on subsequent additions', + () async { + final flushTimeout = Duration(milliseconds: 100); + final buffer = fixture.getSut( + config: TelemetryBufferConfig(flushTimeout: flushTimeout), + ); + + buffer.add(_TestItem('item1')); + expect(fixture.flushCallCount, 0); + + buffer.add(_TestItem('item2')); + expect(fixture.flushCallCount, 0); + + await Future.delayed(flushTimeout + Duration(milliseconds: 10)); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedItems, hasLength(2)); + }); + + test('flush with empty buffer returns null', () async { + final buffer = fixture.getSut(); + + final result = buffer.flush(); + + expect(result, isNull); + expect(fixture.flushedItems, isEmpty); + }); + + test('buffer is cleared after flush', () async { + final buffer = fixture.getSut(); + + buffer.add(_TestItem('item1')); + await buffer.flush(); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedItems, hasLength(1)); + + // Second flush should not send anything + fixture.reset(); + final result = buffer.flush(); + + expect(result, isNull); + expect(fixture.flushCallCount, 0); + expect(fixture.flushedItems, isEmpty); + }); + + test('encoding failure does not crash and item is skipped', () async { + final buffer = fixture.getSut(); + + buffer.add(_ThrowingTestItem()); + buffer.add(_TestItem('valid')); + await buffer.flush(); + + // Only the valid item should be in the buffer + expect(fixture.flushedItems, hasLength(1)); + expect(fixture.flushCallCount, 1); + }); + + test('onFlush receives List> directly', () async { + final buffer = fixture.getSut(); + + buffer.add(_TestItem('item1')); + buffer.add(_TestItem('item2')); + await buffer.flush(); + + // Verify callback received a simple list, not a map + expect(fixture.flushedItems, hasLength(2)); + expect(fixture.flushCallCount, 1); + }); + }); + + group('GroupedInMemoryTelemetryBuffer', () { + late _GroupedFixture fixture; + + setUp(() { + fixture = _GroupedFixture(); + }); + + test('items are grouped by key', () async { + final buffer = fixture.getSut( + groupKeyExtractor: (item) => item.group, + ); + + buffer.add(_TestItem('item1', group: 'group1')); + buffer.add(_TestItem('item2', group: 'group2')); + buffer.add(_TestItem('item3', group: 'group1')); + + await buffer.flush(); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedGroups.keys, containsAll(['group1', 'group2'])); + expect( + fixture.flushedGroups['group1']?.$1, hasLength(2)); // item1 and item3 + expect(fixture.flushedGroups['group2']?.$1, hasLength(1)); // item2 + }); + + test('items are flushed after timeout', () async { + final flushTimeout = Duration(milliseconds: 1); + final buffer = fixture.getSut( + config: TelemetryBufferConfig(flushTimeout: flushTimeout), + groupKeyExtractor: (item) => item.group, + ); + + buffer.add(_TestItem('item1', group: 'a')); + buffer.add(_TestItem('item2', group: 'b')); + + expect(fixture.flushedGroups, isEmpty); + + await Future.delayed(flushTimeout + Duration(milliseconds: 10)); + + expect(fixture.flushCallCount, 1); + expect(fixture.flushedGroups.keys, hasLength(2)); + }); + + test('flush with empty buffer returns null', () async { + final buffer = fixture.getSut( + groupKeyExtractor: (item) => item.group, + ); + + final result = buffer.flush(); + + expect(result, isNull); + expect(fixture.flushedGroups, isEmpty); + }); + + test('buffer is cleared after flush', () async { + final buffer = fixture.getSut( + groupKeyExtractor: (item) => item.group, + ); + + buffer.add(_TestItem('item1', group: 'a')); + await buffer.flush(); + + expect(fixture.flushCallCount, 1); + + fixture.reset(); + final result = buffer.flush(); + + expect(result, isNull); + expect(fixture.flushCallCount, 0); + }); + + test('onFlush receives Map>>', () async { + final buffer = fixture.getSut( + groupKeyExtractor: (item) => item.group, + ); + + buffer.add(_TestItem('item1', group: 'myGroup')); + await buffer.flush(); + + expect(fixture.flushedGroups.containsKey('myGroup'), isTrue); + }); + }); +} + +class _TestItem { + final String id; + final String group; + + _TestItem(this.id, {this.group = 'default'}); + + Map toJson() => {'id': id}; +} + +class _ThrowingTestItem extends _TestItem { + _ThrowingTestItem() : super('throwing'); + + @override + Map toJson() => throw Exception('Encoding failed'); +} + +class _SimpleFixture { + List> flushedItems = []; + int flushCallCount = 0; + + InMemoryTelemetryBuffer<_TestItem> getSut({ + TelemetryBufferConfig config = const TelemetryBufferConfig(), + }) { + return InMemoryTelemetryBuffer<_TestItem>( + encoder: (item) => utf8.encode(jsonEncode(item.toJson())), + onFlush: (items) { + flushCallCount++; + flushedItems = items; + }, + config: config, + ); + } + + void reset() { + flushedItems = []; + flushCallCount = 0; + } +} + +class _GroupedFixture { + Map>, _TestItem)> flushedGroups = {}; + int flushCallCount = 0; + + GroupedInMemoryTelemetryBuffer<_TestItem> getSut({ + required GroupKeyExtractor<_TestItem> groupKeyExtractor, + TelemetryBufferConfig config = const TelemetryBufferConfig(), + }) { + return GroupedInMemoryTelemetryBuffer<_TestItem>( + encoder: (item) => utf8.encode(jsonEncode(item.toJson())), + onFlush: (groups) { + flushCallCount++; + flushedGroups = groups; + }, + groupKeyExtractor: groupKeyExtractor, + config: config, + ); + } + + void reset() { + flushedGroups = {}; + flushCallCount = 0; + } +} diff --git a/packages/dart/test/telemetry/processing/processor_integration_test.dart b/packages/dart/test/telemetry/processing/processor_integration_test.dart new file mode 100644 index 0000000000..2fedd77d1e --- /dev/null +++ b/packages/dart/test/telemetry/processing/processor_integration_test.dart @@ -0,0 +1,161 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/processing/in_memory_buffer.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:sentry/src/telemetry/processing/processor_integration.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_hub.dart'; +import '../../mocks/mock_transport.dart'; +import '../../test_utils.dart'; + +void main() { + group('DefaultTelemetryProcessorIntegration', () { + late _Fixture fixture; + + setUp(() { + fixture = _Fixture(); + }); + + test( + 'sets up DefaultTelemetryProcessor when NoOpTelemetryProcessor is active', + () { + final options = fixture.options; + expect(options.telemetryProcessor, isA()); + + fixture.getSut().call(fixture.hub, options); + + expect(options.telemetryProcessor, isA()); + }); + + test('does not override existing telemetry processor', () { + final options = fixture.options; + final existingProcessor = DefaultTelemetryProcessor(options.log); + options.telemetryProcessor = existingProcessor; + + fixture.getSut().call(fixture.hub, options); + + expect(identical(options.telemetryProcessor, existingProcessor), isTrue); + }); + + test('adds integration name to SDK', () { + final options = fixture.options; + + fixture.getSut().call(fixture.hub, options); + + expect( + options.sdk.integrations, + contains(DefaultTelemetryProcessorIntegration.integrationName), + ); + }); + + test('configures log buffer as InMemoryTelemetryBuffer', () { + final options = fixture.options; + + fixture.getSut().call(fixture.hub, options); + + final processor = options.telemetryProcessor as DefaultTelemetryProcessor; + expect(processor.logBuffer, isA>()); + }); + + test('configures span buffer as GroupedInMemoryTelemetryBuffer', () { + final options = fixture.options; + + fixture.getSut().call(fixture.hub, options); + + final processor = options.telemetryProcessor as DefaultTelemetryProcessor; + expect(processor.spanBuffer, + isA>()); + }); + + test('configures span buffer with group key extractor', () { + final options = fixture.options; + + final integration = fixture.getSut(); + integration.call(fixture.hub, options); + + final processor = options.telemetryProcessor as DefaultTelemetryProcessor; + + expect( + (processor.spanBuffer + as GroupedInMemoryTelemetryBuffer) + .groupKey, + integration.spanGroupKeyExtractor); + }); + + test('spanGroupKeyExtractor uses traceId-spanId format', () { + final options = fixture.options; + + final integration = fixture.getSut(); + integration.call(fixture.hub, options); + + final span = fixture.createSpan(); + final key = integration.spanGroupKeyExtractor(span); + + expect(key, '${span.traceId}-${span.spanId}'); + }); + + group('flush', () { + test('log reaches transport as envelope', () async { + final options = fixture.options; + fixture.getSut().call(fixture.hub, options); + + final processor = + options.telemetryProcessor as DefaultTelemetryProcessor; + processor.addLog(fixture.createLog()); + await processor.flush(); + + expect(fixture.transport.envelopes, hasLength(1)); + }); + + test('span reaches transport as envelope', () async { + final options = fixture.options; + fixture.getSut().call(fixture.hub, options); + + final processor = + options.telemetryProcessor as DefaultTelemetryProcessor; + final span = fixture.createSpan(); + span.end(); + processor.addSpan(span); + await processor.flush(); + + expect(fixture.transport.envelopes, hasLength(1)); + }); + }); + }); +} + +class _Fixture { + final hub = MockHub(); + final transport = MockTransport(); + late SentryOptions options; + + _Fixture() { + options = defaultTestOptions()..transport = transport; + } + + DefaultTelemetryProcessorIntegration getSut() { + return DefaultTelemetryProcessorIntegration(); + } + + SentryLog createLog() { + return SentryLog( + timestamp: DateTime.now().toUtc(), + level: SentryLogLevel.info, + body: 'test log', + attributes: {}, + ); + } + + RecordingSentrySpanV2 createSpan() { + return RecordingSentrySpanV2.root( + name: 'test-span', + traceId: SentryId.newId(), + onSpanEnd: (_) {}, + clock: options.clock, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + } +} diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart new file mode 100644 index 0000000000..6e20939b09 --- /dev/null +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_telemetry_buffer.dart'; +import '../../test_utils.dart'; + +void main() { + group('DefaultTelemetryProcessor', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('addSpan', () { + test('routes span to span buffer', () { + final mockSpanBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + + final span = fixture.createSpan(); + span.end(); + processor.addSpan(span); + + expect(mockSpanBuffer.addedItems.length, 1); + expect(mockSpanBuffer.addedItems.first, span); + }); + + test('does not throw when no span buffer registered', () { + final processor = fixture.getSut(); + processor.spanBuffer = null; + + final span = fixture.createSpan(); + span.end(); + processor.addSpan(span); + + // Nothing to assert - just verifying no exception thrown + }); + }); + + group('addLog', () { + test('routes log to log buffer', () { + final mockLogBuffer = MockTelemetryBuffer(); + final processor = + fixture.getSut(enableLogs: true, logBuffer: mockLogBuffer); + + final log = fixture.createLog(); + processor.addLog(log); + + expect(mockLogBuffer.addedItems.length, 1); + expect(mockLogBuffer.addedItems.first, log); + }); + + test('does not throw when no log buffer registered', () { + final processor = fixture.getSut(); + processor.logBuffer = null; + + final log = fixture.createLog(); + processor.addLog(log); + }); + }); + + group('flush', () { + test('flushes all registered buffers', () async { + final mockSpanBuffer = MockTelemetryBuffer(); + final mockLogBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut( + enableLogs: true, + spanBuffer: mockSpanBuffer, + logBuffer: mockLogBuffer, + ); + + await processor.flush(); + + expect(mockSpanBuffer.flushCallCount, 1); + expect(mockLogBuffer.flushCallCount, 1); + }); + + test('flushes only span buffer when log buffer is null', () async { + final mockSpanBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + processor.logBuffer = null; + + await processor.flush(); + + expect(mockSpanBuffer.flushCallCount, 1); + }); + + test('returns sync (null) when all buffers flush synchronously', () { + final mockSpanBuffer = + MockTelemetryBuffer(asyncFlush: false); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + processor.logBuffer = null; + + final result = processor.flush(); + + expect(result, isNull); + }); + + test('returns Future when at least one buffer flushes asynchronously', + () async { + final mockSpanBuffer = + MockTelemetryBuffer(asyncFlush: true); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + processor.logBuffer = null; + + final result = processor.flush(); + + expect(result, isA()); + await result; + }); + }); + }); +} + +class Fixture { + late SentryOptions options; + + Fixture() { + options = defaultTestOptions(); + } + + DefaultTelemetryProcessor getSut({ + bool enableLogs = false, + MockTelemetryBuffer? spanBuffer, + MockTelemetryBuffer? logBuffer, + }) { + options.enableLogs = enableLogs; + return DefaultTelemetryProcessor( + options.log, + spanBuffer: spanBuffer, + logBuffer: logBuffer, + ); + } + + RecordingSentrySpanV2 createSpan({String name = 'test-span'}) { + return RecordingSentrySpanV2.root( + name: name, + traceId: SentryId.newId(), + onSpanEnd: (_) {}, + clock: options.clock, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), + samplingDecision: SentryTracesSamplingDecision(true), + ); + } + + RecordingSentrySpanV2 createChildSpan({ + required RecordingSentrySpanV2 parent, + String name = 'child-span', + }) { + return RecordingSentrySpanV2.child( + parent: parent, + name: name, + onSpanEnd: (_) {}, + clock: options.clock, + dscCreator: (_) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'), + ); + } + + SentryLog createLog({String body = 'test log'}) { + return SentryLog( + timestamp: DateTime.now().toUtc(), + level: SentryLogLevel.info, + body: body, + attributes: {}, + ); + } +} diff --git a/packages/dart/test/telemetry/span/span_test.dart b/packages/dart/test/telemetry/span/span_test.dart new file mode 100644 index 0000000000..3bd8a372c3 --- /dev/null +++ b/packages/dart/test/telemetry/span/span_test.dart @@ -0,0 +1,512 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_status_v2.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +void main() { + group('RecordingSentrySpanV2', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('end finishes the span', () { + final span = fixture.createSpan(name: 'test-span'); + + span.end(); + + expect(span.endTimestamp, isNotNull); + expect(span.isEnded, isTrue); + }); + + test('end sets current time by default', () { + final span = fixture.createSpan(name: 'test-span'); + + final before = DateTime.now().toUtc(); + span.end(); + final after = DateTime.now().toUtc(); + + expect(span.endTimestamp, isNotNull); + expect(span.endTimestamp!.isAfter(before) || span.endTimestamp == before, + isTrue, + reason: 'endTimestamp should be >= time before end() was called'); + expect(span.endTimestamp!.isBefore(after) || span.endTimestamp == after, + isTrue, + reason: 'endTimestamp should be <= time after end() was called'); + }); + + test('end with custom timestamp sets end time', () { + final span = fixture.createSpan(name: 'test-span'); + final endTime = DateTime.now().add(Duration(seconds: 5)).toUtc(); + + span.end(endTimestamp: endTime); + + expect(span.endTimestamp, equals(endTime)); + }); + + test('end sets endTimestamp as UTC', () { + final span1 = fixture.createSpan(name: 'test-span'); + span1.end(); + expect(span1.endTimestamp!.isUtc, isTrue); + + final span2 = fixture.createSpan(name: 'test-span'); + // Should transform non-utc time to utc + span2.end(endTimestamp: DateTime.now()); + expect(span2.endTimestamp!.isUtc, isTrue); + }); + + test('end calls onSpanEnded callback', () { + RecordingSentrySpanV2? capturedSpan; + final span = fixture.createSpan( + name: 'test-span', + onSpanEnded: (s) => capturedSpan = s, + ); + + span.end(); + + expect(capturedSpan, same(span)); + }); + + test('end is idempotent once finished', () { + var callCount = 0; + final span = fixture.createSpan( + name: 'test-span', + onSpanEnded: (_) => callCount++, + ); + final firstEndTimestamp = DateTime.utc(2024, 1, 1); + final secondEndTimestamp = DateTime.utc(2024, 1, 2); + + span.end(endTimestamp: firstEndTimestamp); + span.end(endTimestamp: secondEndTimestamp); + + expect(span.endTimestamp, equals(firstEndTimestamp)); + expect(span.isEnded, isTrue); + expect(callCount, 1); + }); + + test('setAttribute sets single attribute', () { + final span = fixture.createSpan(name: 'test-span'); + + final attributeValue = SentryAttribute.string('value'); + span.setAttribute('key', attributeValue); + + expect(span.attributes, equals({'key': attributeValue})); + }); + + test('setAttributes sets multiple attributes', () { + final span = fixture.createSpan(name: 'test-span'); + + final attributes = { + 'key1': SentryAttribute.string('value1'), + 'key2': SentryAttribute.int(42), + }; + span.setAttributes(attributes); + + expect(span.attributes, equals(attributes)); + }); + + test('setName sets span name', () { + final span = fixture.createSpan(name: 'initial-name'); + + span.name = 'updated-name'; + expect(span.name, equals('updated-name')); + }); + + test('setStatus sets span status', () { + final span = fixture.createSpan(name: 'test-span'); + + span.status = SentrySpanStatusV2.ok; + expect(span.status, equals(SentrySpanStatusV2.ok)); + + span.status = SentrySpanStatusV2.error; + expect(span.status, equals(SentrySpanStatusV2.error)); + }); + + test('parentSpan returns the parent span', () { + final parent = fixture.createSpan(name: 'parent'); + final child = fixture.createSpan(name: 'child', parentSpan: parent); + + expect(child.parentSpan, equals(parent)); + }); + + test('parentSpan returns null for root span', () { + final span = fixture.createSpan(name: 'root'); + + expect(span.parentSpan, isNull); + }); + + test('name returns the span name', () { + final span = fixture.createSpan(name: 'my-span-name'); + + expect(span.name, equals('my-span-name')); + }); + + test('spanId is created when span is created', () { + final span = fixture.createSpan(name: 'test-span'); + + expect(span.spanId.toString(), isNot(SpanId.empty().toString())); + }); + + group('segmentSpan', () { + test('returns null when parentSpan is null', () { + final span = fixture.createSpan(name: 'root-span'); + + expect(span.segmentSpan, same(span)); + }); + + test('returns parent segmentSpan when parentSpan is set', () { + final root = fixture.createSpan(name: 'root'); + final child = fixture.createSpan(name: 'child', parentSpan: root); + + expect(child.segmentSpan, same(root)); + }); + + test('returns root segmentSpan for deeply nested spans', () { + final root = fixture.createSpan(name: 'root'); + final child = fixture.createSpan(name: 'child', parentSpan: root); + final grandchild = + fixture.createSpan(name: 'grandchild', parentSpan: child); + final greatGrandchild = fixture.createSpan( + name: 'great-grandchild', parentSpan: grandchild); + + expect(grandchild.segmentSpan, same(root)); + expect(greatGrandchild.segmentSpan, same(root)); + }); + }); + + group('traceId', () { + test('uses defaultTraceId when no parent', () { + final traceId = SentryId.newId(); + final span = fixture.createSpan(name: 'test-span', traceId: traceId); + + expect(span.traceId, equals(traceId)); + }); + + test('child span inherits traceId from parent', () { + final parent = fixture.createSpan(name: 'parent'); + final child = fixture.createSpan(name: 'child', parentSpan: parent); + + expect(child.traceId, equals(parent.traceId)); + }); + + test('child span ignores defaultTraceId when parent exists', () { + final parentTraceId = SentryId.newId(); + final differentTraceId = SentryId.newId(); + + final parent = + fixture.createSpan(name: 'parent', traceId: parentTraceId); + final child = fixture.createSpan( + name: 'child', + parentSpan: parent, + traceId: differentTraceId, + ); + + expect(child.traceId, equals(parentTraceId)); + expect(child.traceId, isNot(equals(differentTraceId))); + }); + }); + + group('when resolving DSC', () { + test('creates DSC on first access for root span', () { + final span = fixture.createSpan(name: 'root-span'); + + final dsc = span.resolveDsc(); + + expect(dsc, isNotNull); + expect(dsc.publicKey, equals('publicKey')); + }); + + test('returns same DSC on subsequent access', () { + final span = fixture.createSpan(name: 'root-span'); + + final dsc1 = span.resolveDsc(); + final dsc2 = span.resolveDsc(); + + expect(identical(dsc1, dsc2), isTrue); + }); + + test('returns DSC from segment span for child span', () { + final root = fixture.createSpan(name: 'root'); + final child = fixture.createSpan(name: 'child', parentSpan: root); + + final rootDsc = root.resolveDsc(); + final childDsc = child.resolveDsc(); + + expect(identical(rootDsc, childDsc), isTrue); + }); + + test('returns same DSC for deeply nested spans', () { + final root = fixture.createSpan(name: 'root'); + final child = fixture.createSpan(name: 'child', parentSpan: root); + final grandchild = + fixture.createSpan(name: 'grandchild', parentSpan: child); + + final rootDsc = root.resolveDsc(); + final childDsc = child.resolveDsc(); + final grandchildDsc = grandchild.resolveDsc(); + + expect(identical(rootDsc, childDsc), isTrue); + expect(identical(rootDsc, grandchildDsc), isTrue); + }); + + test('freezes DSC after first access', () { + var callCount = 0; + final span = fixture.createSpan( + name: 'root-span', + dscCreator: (s) { + callCount++; + return SentryTraceContextHeader(SentryId.newId(), 'publicKey'); + }, + ); + + span.resolveDsc(); + span.resolveDsc(); + span.resolveDsc(); + + expect(callCount, equals(1), + reason: 'DSC creator should only be called once'); + }); + }); + + group('when accessing samplingDecision', () { + test('returns stored decision for root span', () { + final decision = SentryTracesSamplingDecision( + true, + sampleRate: 0.5, + sampleRand: 0.25, + ); + final span = fixture.createSpan( + name: 'root-span', + samplingDecision: decision, + ); + + expect(span.samplingDecision.sampled, isTrue); + expect(span.samplingDecision.sampleRate, equals(0.5)); + expect(span.samplingDecision.sampleRand, equals(0.25)); + }); + + test('returns inherited decision for child span', () { + final decision = SentryTracesSamplingDecision( + true, + sampleRate: 0.75, + sampleRand: 0.1, + ); + final root = fixture.createSpan( + name: 'root', + samplingDecision: decision, + ); + final child = fixture.createSpan(name: 'child', parentSpan: root); + + expect(child.samplingDecision.sampled, + equals(root.samplingDecision.sampled)); + expect(child.samplingDecision.sampleRate, + equals(root.samplingDecision.sampleRate)); + expect(child.samplingDecision.sampleRand, + equals(root.samplingDecision.sampleRand)); + }); + + test('returns root decision for deeply nested span', () { + final decision = SentryTracesSamplingDecision( + true, + sampleRate: 0.3, + sampleRand: 0.99, + ); + final root = fixture.createSpan( + name: 'root', + samplingDecision: decision, + ); + final child = fixture.createSpan(name: 'child', parentSpan: root); + final grandchild = + fixture.createSpan(name: 'grandchild', parentSpan: child); + + expect(grandchild.samplingDecision.sampled, equals(decision.sampled)); + expect(grandchild.samplingDecision.sampleRate, + equals(decision.sampleRate)); + expect(grandchild.samplingDecision.sampleRand, + equals(decision.sampleRand)); + }); + }); + + group('toJson', () { + test('serializes basic span without parent', () { + final span = fixture.createSpan(name: 'test-span'); + span.end(); + + final json = span.toJson(); + + expect(json['trace_id'], equals(span.traceId.toString())); + expect(json['span_id'], equals(span.spanId.toString())); + expect(json['name'], equals('test-span')); + expect(json['is_segment'], isTrue); + expect(json['status'], equals('ok')); + expect(json['start_timestamp'], isA()); + expect(json['end_timestamp'], isA()); + expect(json.containsKey('parent_span_id'), isFalse); + }); + + test('serializes span with parent', () { + final parent = fixture.createSpan(name: 'parent'); + final child = fixture.createSpan(name: 'child', parentSpan: parent); + child.end(); + + final json = child.toJson(); + + expect(json['parent_span_id'], equals(parent.spanId.toString())); + expect(json['is_segment'], isFalse); + }); + + test('serializes span with error status', () { + final span = fixture.createSpan(name: 'test-span'); + span.status = SentrySpanStatusV2.error; + span.end(); + + final json = span.toJson(); + + expect(json['status'], equals('error')); + }); + + test('serializes span with attributes', () { + final span = fixture.createSpan(name: 'test-span'); + span.setAttribute('string_attr', SentryAttribute.string('value')); + span.setAttribute('int_attr', SentryAttribute.int(42)); + span.setAttribute('bool_attr', SentryAttribute.bool(true)); + span.setAttribute('double_attr', SentryAttribute.double(3.14)); + span.end(); + + final json = span.toJson(); + + expect(json.containsKey('attributes'), isTrue); + final attributes = Map.from(json['attributes']); + + expect(attributes['string_attr'], {'value': 'value', 'type': 'string'}); + expect(attributes['int_attr'], {'value': 42, 'type': 'integer'}); + expect(attributes['bool_attr'], {'value': true, 'type': 'boolean'}); + expect(attributes['double_attr'], {'value': 3.14, 'type': 'double'}); + }); + + test('end_timestamp is null when span is not finished', () { + final span = fixture.createSpan(name: 'test-span'); + + final json = span.toJson(); + + expect(json['end_timestamp'], isNull); + }); + + test( + 'timestamps are serialized as unix seconds with microsecond precision', + () { + final span = fixture.createSpan(name: 'test-span'); + final customEndTime = DateTime.utc(2024, 6, 15, 12, 30, 45, 123, 456); + span.end(endTimestamp: customEndTime); + + final json = span.toJson(); + + final endTimestamp = json['end_timestamp'] as double; + final expectedMicros = customEndTime.microsecondsSinceEpoch; + final expectedSeconds = expectedMicros / 1000000; + + expect(endTimestamp, closeTo(expectedSeconds, 0.000001)); + }); + + test('serializes updated name', () { + final span = fixture.createSpan(name: 'original-name'); + span.name = 'updated-name'; + span.end(); + + final json = span.toJson(); + + expect(json['name'], equals('updated-name')); + }); + }); + }); + + group('NoOpSentrySpanV2', () { + test('operations do not throw', () { + const span = NoOpSentrySpanV2(); + + // All operations should be no-ops and not throw + span.end(); + span.end(endTimestamp: DateTime.now()); + span.setAttribute('key', SentryAttribute.string('value')); + span.setAttributes({'key': SentryAttribute.string('value')}); + span.removeAttribute('key'); + span.name = 'name'; + span.status = SentrySpanStatusV2.ok; + span.status = SentrySpanStatusV2.error; + }); + + test('returns default values', () { + const span = NoOpSentrySpanV2(); + + expect(span.spanId.toString(), SpanId.empty().toString()); + expect(span.traceId.toString(), SentryId.empty().toString()); + expect(span.name, 'NoOpSpan'); + expect(span.status, SentrySpanStatusV2.ok); + expect(span.parentSpan, isNull); + expect(span.endTimestamp, isNull); + expect(span.attributes, isEmpty); + }); + }); + + group('UnsetSentrySpanV2', () { + test('all APIs throw to prevent accidental use', () { + const span = UnsetSentrySpanV2(); + + expect(() => span.spanId, throwsA(isA())); + expect(() => span.traceId, throwsA(isA())); + expect(() => span.name, throwsA(isA())); + expect(() => span.status, throwsA(isA())); + expect(() => span.parentSpan, throwsA(isA())); + expect(() => span.endTimestamp, throwsA(isA())); + expect(() => span.attributes, throwsA(isA())); + + expect(() => span.name = 'foo', throwsA(isA())); + expect(() => span.status = SentrySpanStatusV2.ok, + throwsA(isA())); + expect(() => span.setAttribute('k', SentryAttribute.string('v')), + throwsA(isA())); + expect(() => span.setAttributes({'k': SentryAttribute.string('v')}), + throwsA(isA())); + expect( + () => span.removeAttribute('k'), throwsA(isA())); + expect(() => span.end(), throwsA(isA())); + }); + }); +} + +class Fixture { + final options = defaultTestOptions(); + + RecordingSentrySpanV2 createSpan({ + required String name, + RecordingSentrySpanV2? parentSpan, + SentryId? traceId, + OnSpanEndCallback? onSpanEnded, + DscCreatorCallback? dscCreator, + SentryTracesSamplingDecision? samplingDecision, + }) { + final defaultDscCreator = (RecordingSentrySpanV2 span) => + SentryTraceContextHeader(SentryId.newId(), 'publicKey'); + + if (parentSpan != null) { + return RecordingSentrySpanV2.child( + parent: parentSpan, + name: name, + onSpanEnd: onSpanEnded ?? (_) {}, + clock: options.clock, + dscCreator: dscCreator ?? defaultDscCreator, + ); + } + return RecordingSentrySpanV2.root( + name: name, + traceId: traceId ?? SentryId.newId(), + onSpanEnd: onSpanEnded ?? (_) {}, + clock: options.clock, + dscCreator: dscCreator ?? defaultDscCreator, + samplingDecision: samplingDecision ?? SentryTracesSamplingDecision(true), + ); + } +} diff --git a/packages/flutter/lib/src/widgets_binding_observer.dart b/packages/flutter/lib/src/widgets_binding_observer.dart index f26a080bcd..12811068e1 100644 --- a/packages/flutter/lib/src/widgets_binding_observer.dart +++ b/packages/flutter/lib/src/widgets_binding_observer.dart @@ -94,7 +94,7 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver { if (!_isNavigatorObserverCreated() && !_options.platform.isWeb) { if (state == AppLifecycleState.inactive) { _appInBackgroundStopwatch.start(); - _options.logBatcher.flush(); + _options.telemetryProcessor.flush(); } else if (_appInBackgroundStopwatch.isRunning && state == AppLifecycleState.resumed) { _appInBackgroundStopwatch.stop(); diff --git a/packages/flutter/test/mocks.dart b/packages/flutter/test/mocks.dart index dec5362a20..34ddbf8a9d 100644 --- a/packages/flutter/test/mocks.dart +++ b/packages/flutter/test/mocks.dart @@ -8,6 +8,8 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/frames_tracking/sentry_delayed_frames_tracker.dart'; import 'package:sentry_flutter/src/navigation/time_to_display_tracker.dart'; @@ -236,3 +238,25 @@ class MockLogItem { const MockLogItem(this.level, this.message, {this.logger, this.exception, this.stackTrace}); } + +class MockTelemetryProcessor implements TelemetryProcessor { + final List addedSpans = []; + final List addedLogs = []; + int flushCalls = 0; + int closeCalls = 0; + + @override + void addSpan(RecordingSentrySpanV2 span) { + addedSpans.add(span); + } + + @override + void addLog(SentryLog log) { + addedLogs.add(log); + } + + @override + void flush() { + flushCalls++; + } +} diff --git a/packages/flutter/test/widgets_binding_observer_test.dart b/packages/flutter/test/widgets_binding_observer_test.dart index d4ed7e8a52..8470de76d7 100644 --- a/packages/flutter/test/widgets_binding_observer_test.dart +++ b/packages/flutter/test/widgets_binding_observer_test.dart @@ -1,7 +1,6 @@ // ignore_for_file: invalid_use_of_internal_member import 'dart:ui'; -import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -11,8 +10,6 @@ import 'package:sentry/src/platform/mock_platform.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/widgets_binding_observer.dart'; -import 'package:sentry/src/sentry_log_batcher.dart'; - import 'mocks.dart'; import 'mocks.mocks.dart'; @@ -567,17 +564,17 @@ void main() { }); testWidgets( - 'calls flush on logs batcher when transitioning to inactive state', + 'calls flush on telemetry processor when transitioning to inactive state', (WidgetTester tester) async { final hub = MockHub(); - final mockLogBatcher = MockLogBatcher(); + final mockProcessor = MockTelemetryProcessor(); final options = defaultTestOptions(); options.platform = MockPlatform(isWeb: false); options.bindingUtils = TestBindingWrapper(); - options.logBatcher = mockLogBatcher; + options.telemetryProcessor = mockProcessor; options.enableLogs = true; final observer = SentryWidgetsBindingObserver( @@ -590,21 +587,9 @@ void main() { await sendLifecycle('inactive'); - expect(mockLogBatcher.flushCalled, true); + expect(mockProcessor.flushCalls, 1); instance.removeObserver(observer); }); }); } - -class MockLogBatcher implements SentryLogBatcher { - var flushCalled = false; - - @override - void addLog(SentryLog log) {} - - @override - FutureOr flush() async { - flushCalled = true; - } -} From 3897122a12d02268f9e870151e337c1c1195ed93 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 17:52:18 +0100 Subject: [PATCH 02/42] Remove SpanV2 and TraceLifecycle dependencies - Remove addSpan method from TelemetryProcessor interface - Remove span buffer from DefaultTelemetryProcessor - Remove captureSpan method from SentryClient - Remove traceLifecycle property from SentryOptions - Remove span imports and exports - Update mocks to remove span-related code Co-Authored-By: Claude Sonnet 4.5 --- packages/dart/lib/sentry.dart | 1 - packages/dart/lib/src/sentry_client.dart | 20 - packages/dart/lib/src/sentry_options.dart | 15 - .../src/telemetry/processing/processor.dart | 47 +- .../processing/processor_integration.dart | 29 +- .../test/mocks/mock_telemetry_processor.dart | 7 - .../dart/test/telemetry/span/span_test.dart | 512 ------------------ packages/flutter/test/mocks.dart | 6 - 8 files changed, 11 insertions(+), 626 deletions(-) delete mode 100644 packages/dart/test/telemetry/span/span_test.dart diff --git a/packages/dart/lib/sentry.dart b/packages/dart/lib/sentry.dart index ccfd5b5e8f..8d01a3181c 100644 --- a/packages/dart/lib/sentry.dart +++ b/packages/dart/lib/sentry.dart @@ -41,7 +41,6 @@ export 'src/sdk_lifecycle_hooks.dart'; export 'src/sentry_envelope.dart'; export 'src/sentry_envelope_item.dart'; export 'src/sentry_options.dart'; -export 'src/telemetry/sentry_trace_lifecycle.dart'; // ignore: invalid_export_of_internal_element export 'src/sentry_trace_origins.dart'; export 'src/span_data_convention.dart'; diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index 17b97fc0e1..02e6841e1d 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -18,7 +18,6 @@ import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'sentry_trace_context_header.dart'; -import 'telemetry/span/sentry_span_v2.dart'; import 'transport/client_report_transport.dart'; import 'transport/data_category.dart'; import 'transport/http_transport.dart'; @@ -490,25 +489,6 @@ class SentryClient { ); } - void captureSpan( - SentrySpanV2 span, { - Scope? scope, - }) { - switch (span) { - case UnsetSentrySpanV2(): - _options.log( - SentryLevel.warning, - "captureSpan: span is in an invalid state $UnsetSentrySpanV2.", - ); - case NoOpSentrySpanV2(): - return; - case RecordingSentrySpanV2 span: - // TODO(next-pr): add common attributes, merge scope attributes - - _options.telemetryProcessor.addSpan(span); - } - } - @internal FutureOr captureLog( SentryLog log, { diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index ab188f8e3e..f0b7472f3e 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -232,21 +232,6 @@ class SentryOptions { /// sent. Events are picked randomly. Default is null (disabled) double? sampleRate; - /// Chooses between two tracing systems. You can only use one at a time. - /// - /// [SentryTraceLifecycle.streaming] sends each span to Sentry as it finishes. - /// Use [Sentry.startSpan] to create spans. The older transaction APIs - /// ([Sentry.startTransaction], [ISentrySpan.startChild]) will do nothing. - /// - /// [SentryTraceLifecycle.static] collects all spans and sends them together - /// when the transaction ends. Use [Sentry.startTransaction] to create traces. - /// The newer span APIs ([Sentry.startSpan]) will do nothing. - /// - /// Integrations automatically switch to the correct API based on this setting. - /// - /// Defaults to [SentryTraceLifecycle.static]. - SentryTraceLifecycle traceLifecycle = SentryTraceLifecycle.static; - /// The ignoreErrors tells the SDK which errors should be not sent to the sentry server. /// If an null or an empty list is used, the SDK will send all transactions. /// To use regex add the `^` and the `$` to the string. diff --git a/packages/dart/lib/src/telemetry/processing/processor.dart b/packages/dart/lib/src/telemetry/processing/processor.dart index c2c3b47b27..2e4268bdb2 100644 --- a/packages/dart/lib/src/telemetry/processing/processor.dart +++ b/packages/dart/lib/src/telemetry/processing/processor.dart @@ -3,17 +3,13 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../../../sentry.dart'; -import '../span/sentry_span_v2.dart'; import 'buffer.dart'; /// Interface for processing and buffering telemetry data before sending. /// -/// Implementations collect spans and logs, buffering them until flushed. +/// Implementations collect logs, buffering them until flushed. /// This enables batching of telemetry data for efficient transport. abstract class TelemetryProcessor { - /// Adds a span to be processed and buffered. - void addSpan(RecordingSentrySpanV2 span); - /// Adds a log to be processed and buffered. void addLog(SentryLog log); @@ -26,72 +22,49 @@ abstract class TelemetryProcessor { /// Default telemetry processor that routes items to type-specific buffers. /// -/// Spans and logs are dispatched to their respective [TelemetryBuffer] +/// Logs are dispatched to their respective [TelemetryBuffer] /// instances. If no buffer is registered for a telemetry type, items are /// dropped with a warning. class DefaultTelemetryProcessor implements TelemetryProcessor { final SdkLogCallback _logger; - /// The buffer for span data, or `null` if span buffering is disabled. - @visibleForTesting - TelemetryBuffer? spanBuffer; - /// The buffer for log data, or `null` if log buffering is disabled. @visibleForTesting TelemetryBuffer? logBuffer; DefaultTelemetryProcessor( this._logger, { - this.spanBuffer, this.logBuffer, }); @override - void addSpan(RecordingSentrySpanV2 span) => _add(span); - - @override - void addLog(SentryLog log) => _add(log); - - void _add(dynamic item) { - final buffer = switch (item) { - RecordingSentrySpanV2 _ => spanBuffer, - SentryLog _ => logBuffer, - _ => null, - }; - - if (buffer == null) { + void addLog(SentryLog log) { + if (logBuffer == null) { _logger( SentryLevel.warning, - '$runtimeType: No buffer registered for ${item.runtimeType} - item was dropped', + '$runtimeType: No buffer registered for ${log.runtimeType} - item was dropped', ); return; } - buffer.add(item); + logBuffer!.add(log); } @override FutureOr flush() { _logger(SentryLevel.debug, '$runtimeType: Clearing buffers'); - final results = >[ - spanBuffer?.flush(), - logBuffer?.flush(), - ]; + final result = logBuffer?.flush(); - final futures = results.whereType().toList(); - if (futures.isEmpty) { - return null; + if (result is Future) { + return result; } - return Future.wait(futures).then((_) {}); + return null; } } class NoOpTelemetryProcessor implements TelemetryProcessor { - @override - void addSpan(RecordingSentrySpanV2 span) {} - @override void addLog(SentryLog log) {} diff --git a/packages/dart/lib/src/telemetry/processing/processor_integration.dart b/packages/dart/lib/src/telemetry/processing/processor_integration.dart index 5ac4c0f802..a2796882e5 100644 --- a/packages/dart/lib/src/telemetry/processing/processor_integration.dart +++ b/packages/dart/lib/src/telemetry/processing/processor_integration.dart @@ -1,20 +1,12 @@ import 'dart:async'; -import 'package:meta/meta.dart'; - import '../../../sentry.dart'; -import '../span/sentry_span_v2.dart'; import 'in_memory_buffer.dart'; import 'processor.dart'; class DefaultTelemetryProcessorIntegration extends Integration { static const integrationName = 'DefaultTelemetryProcessor'; - @visibleForTesting - final GroupKeyExtractor spanGroupKeyExtractor = - (RecordingSentrySpanV2 item) => - '${item.traceId}-${item.segmentSpan.spanId}'; - @override void call(Hub hub, SentryOptions options) { if (options.telemetryProcessor is! NoOpTelemetryProcessor) { @@ -26,8 +18,7 @@ class DefaultTelemetryProcessorIntegration extends Integration { } options.telemetryProcessor = DefaultTelemetryProcessor(options.log, - logBuffer: _createLogBuffer(options), - spanBuffer: _createSpanBuffer(options)); + logBuffer: _createLogBuffer(options)); options.sdk.addIntegration(integrationName); } @@ -40,22 +31,4 @@ class DefaultTelemetryProcessorIntegration extends Integration { items.map((item) => item).toList(), options.sdk); return options.transport.send(envelope).then((_) {}); }); - - GroupedInMemoryTelemetryBuffer _createSpanBuffer( - SentryOptions options) => - GroupedInMemoryTelemetryBuffer( - encoder: (RecordingSentrySpanV2 item) => - utf8JsonEncoder.convert(item.toJson()), - onFlush: (items) { - final futures = items.values.map((itemData) { - final dsc = itemData.$2.resolveDsc(); - final envelope = SentryEnvelope.fromSpansData( - itemData.$1, options.sdk, - traceContext: dsc); - return options.transport.send(envelope); - }).toList(); - if (futures.isEmpty) return null; - return Future.wait(futures).then((_) {}); - }, - groupKeyExtractor: spanGroupKeyExtractor); } diff --git a/packages/dart/test/mocks/mock_telemetry_processor.dart b/packages/dart/test/mocks/mock_telemetry_processor.dart index db1b80470d..a52fd97a2f 100644 --- a/packages/dart/test/mocks/mock_telemetry_processor.dart +++ b/packages/dart/test/mocks/mock_telemetry_processor.dart @@ -1,18 +1,11 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; class MockTelemetryProcessor implements TelemetryProcessor { - final List addedSpans = []; final List addedLogs = []; int flushCalls = 0; int closeCalls = 0; - @override - void addSpan(RecordingSentrySpanV2 span) { - addedSpans.add(span); - } - @override void addLog(SentryLog log) { addedLogs.add(log); diff --git a/packages/dart/test/telemetry/span/span_test.dart b/packages/dart/test/telemetry/span/span_test.dart deleted file mode 100644 index 3bd8a372c3..0000000000 --- a/packages/dart/test/telemetry/span/span_test.dart +++ /dev/null @@ -1,512 +0,0 @@ -import 'package:sentry/sentry.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_status_v2.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; -import 'package:test/test.dart'; - -import '../../test_utils.dart'; - -void main() { - group('RecordingSentrySpanV2', () { - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - test('end finishes the span', () { - final span = fixture.createSpan(name: 'test-span'); - - span.end(); - - expect(span.endTimestamp, isNotNull); - expect(span.isEnded, isTrue); - }); - - test('end sets current time by default', () { - final span = fixture.createSpan(name: 'test-span'); - - final before = DateTime.now().toUtc(); - span.end(); - final after = DateTime.now().toUtc(); - - expect(span.endTimestamp, isNotNull); - expect(span.endTimestamp!.isAfter(before) || span.endTimestamp == before, - isTrue, - reason: 'endTimestamp should be >= time before end() was called'); - expect(span.endTimestamp!.isBefore(after) || span.endTimestamp == after, - isTrue, - reason: 'endTimestamp should be <= time after end() was called'); - }); - - test('end with custom timestamp sets end time', () { - final span = fixture.createSpan(name: 'test-span'); - final endTime = DateTime.now().add(Duration(seconds: 5)).toUtc(); - - span.end(endTimestamp: endTime); - - expect(span.endTimestamp, equals(endTime)); - }); - - test('end sets endTimestamp as UTC', () { - final span1 = fixture.createSpan(name: 'test-span'); - span1.end(); - expect(span1.endTimestamp!.isUtc, isTrue); - - final span2 = fixture.createSpan(name: 'test-span'); - // Should transform non-utc time to utc - span2.end(endTimestamp: DateTime.now()); - expect(span2.endTimestamp!.isUtc, isTrue); - }); - - test('end calls onSpanEnded callback', () { - RecordingSentrySpanV2? capturedSpan; - final span = fixture.createSpan( - name: 'test-span', - onSpanEnded: (s) => capturedSpan = s, - ); - - span.end(); - - expect(capturedSpan, same(span)); - }); - - test('end is idempotent once finished', () { - var callCount = 0; - final span = fixture.createSpan( - name: 'test-span', - onSpanEnded: (_) => callCount++, - ); - final firstEndTimestamp = DateTime.utc(2024, 1, 1); - final secondEndTimestamp = DateTime.utc(2024, 1, 2); - - span.end(endTimestamp: firstEndTimestamp); - span.end(endTimestamp: secondEndTimestamp); - - expect(span.endTimestamp, equals(firstEndTimestamp)); - expect(span.isEnded, isTrue); - expect(callCount, 1); - }); - - test('setAttribute sets single attribute', () { - final span = fixture.createSpan(name: 'test-span'); - - final attributeValue = SentryAttribute.string('value'); - span.setAttribute('key', attributeValue); - - expect(span.attributes, equals({'key': attributeValue})); - }); - - test('setAttributes sets multiple attributes', () { - final span = fixture.createSpan(name: 'test-span'); - - final attributes = { - 'key1': SentryAttribute.string('value1'), - 'key2': SentryAttribute.int(42), - }; - span.setAttributes(attributes); - - expect(span.attributes, equals(attributes)); - }); - - test('setName sets span name', () { - final span = fixture.createSpan(name: 'initial-name'); - - span.name = 'updated-name'; - expect(span.name, equals('updated-name')); - }); - - test('setStatus sets span status', () { - final span = fixture.createSpan(name: 'test-span'); - - span.status = SentrySpanStatusV2.ok; - expect(span.status, equals(SentrySpanStatusV2.ok)); - - span.status = SentrySpanStatusV2.error; - expect(span.status, equals(SentrySpanStatusV2.error)); - }); - - test('parentSpan returns the parent span', () { - final parent = fixture.createSpan(name: 'parent'); - final child = fixture.createSpan(name: 'child', parentSpan: parent); - - expect(child.parentSpan, equals(parent)); - }); - - test('parentSpan returns null for root span', () { - final span = fixture.createSpan(name: 'root'); - - expect(span.parentSpan, isNull); - }); - - test('name returns the span name', () { - final span = fixture.createSpan(name: 'my-span-name'); - - expect(span.name, equals('my-span-name')); - }); - - test('spanId is created when span is created', () { - final span = fixture.createSpan(name: 'test-span'); - - expect(span.spanId.toString(), isNot(SpanId.empty().toString())); - }); - - group('segmentSpan', () { - test('returns null when parentSpan is null', () { - final span = fixture.createSpan(name: 'root-span'); - - expect(span.segmentSpan, same(span)); - }); - - test('returns parent segmentSpan when parentSpan is set', () { - final root = fixture.createSpan(name: 'root'); - final child = fixture.createSpan(name: 'child', parentSpan: root); - - expect(child.segmentSpan, same(root)); - }); - - test('returns root segmentSpan for deeply nested spans', () { - final root = fixture.createSpan(name: 'root'); - final child = fixture.createSpan(name: 'child', parentSpan: root); - final grandchild = - fixture.createSpan(name: 'grandchild', parentSpan: child); - final greatGrandchild = fixture.createSpan( - name: 'great-grandchild', parentSpan: grandchild); - - expect(grandchild.segmentSpan, same(root)); - expect(greatGrandchild.segmentSpan, same(root)); - }); - }); - - group('traceId', () { - test('uses defaultTraceId when no parent', () { - final traceId = SentryId.newId(); - final span = fixture.createSpan(name: 'test-span', traceId: traceId); - - expect(span.traceId, equals(traceId)); - }); - - test('child span inherits traceId from parent', () { - final parent = fixture.createSpan(name: 'parent'); - final child = fixture.createSpan(name: 'child', parentSpan: parent); - - expect(child.traceId, equals(parent.traceId)); - }); - - test('child span ignores defaultTraceId when parent exists', () { - final parentTraceId = SentryId.newId(); - final differentTraceId = SentryId.newId(); - - final parent = - fixture.createSpan(name: 'parent', traceId: parentTraceId); - final child = fixture.createSpan( - name: 'child', - parentSpan: parent, - traceId: differentTraceId, - ); - - expect(child.traceId, equals(parentTraceId)); - expect(child.traceId, isNot(equals(differentTraceId))); - }); - }); - - group('when resolving DSC', () { - test('creates DSC on first access for root span', () { - final span = fixture.createSpan(name: 'root-span'); - - final dsc = span.resolveDsc(); - - expect(dsc, isNotNull); - expect(dsc.publicKey, equals('publicKey')); - }); - - test('returns same DSC on subsequent access', () { - final span = fixture.createSpan(name: 'root-span'); - - final dsc1 = span.resolveDsc(); - final dsc2 = span.resolveDsc(); - - expect(identical(dsc1, dsc2), isTrue); - }); - - test('returns DSC from segment span for child span', () { - final root = fixture.createSpan(name: 'root'); - final child = fixture.createSpan(name: 'child', parentSpan: root); - - final rootDsc = root.resolveDsc(); - final childDsc = child.resolveDsc(); - - expect(identical(rootDsc, childDsc), isTrue); - }); - - test('returns same DSC for deeply nested spans', () { - final root = fixture.createSpan(name: 'root'); - final child = fixture.createSpan(name: 'child', parentSpan: root); - final grandchild = - fixture.createSpan(name: 'grandchild', parentSpan: child); - - final rootDsc = root.resolveDsc(); - final childDsc = child.resolveDsc(); - final grandchildDsc = grandchild.resolveDsc(); - - expect(identical(rootDsc, childDsc), isTrue); - expect(identical(rootDsc, grandchildDsc), isTrue); - }); - - test('freezes DSC after first access', () { - var callCount = 0; - final span = fixture.createSpan( - name: 'root-span', - dscCreator: (s) { - callCount++; - return SentryTraceContextHeader(SentryId.newId(), 'publicKey'); - }, - ); - - span.resolveDsc(); - span.resolveDsc(); - span.resolveDsc(); - - expect(callCount, equals(1), - reason: 'DSC creator should only be called once'); - }); - }); - - group('when accessing samplingDecision', () { - test('returns stored decision for root span', () { - final decision = SentryTracesSamplingDecision( - true, - sampleRate: 0.5, - sampleRand: 0.25, - ); - final span = fixture.createSpan( - name: 'root-span', - samplingDecision: decision, - ); - - expect(span.samplingDecision.sampled, isTrue); - expect(span.samplingDecision.sampleRate, equals(0.5)); - expect(span.samplingDecision.sampleRand, equals(0.25)); - }); - - test('returns inherited decision for child span', () { - final decision = SentryTracesSamplingDecision( - true, - sampleRate: 0.75, - sampleRand: 0.1, - ); - final root = fixture.createSpan( - name: 'root', - samplingDecision: decision, - ); - final child = fixture.createSpan(name: 'child', parentSpan: root); - - expect(child.samplingDecision.sampled, - equals(root.samplingDecision.sampled)); - expect(child.samplingDecision.sampleRate, - equals(root.samplingDecision.sampleRate)); - expect(child.samplingDecision.sampleRand, - equals(root.samplingDecision.sampleRand)); - }); - - test('returns root decision for deeply nested span', () { - final decision = SentryTracesSamplingDecision( - true, - sampleRate: 0.3, - sampleRand: 0.99, - ); - final root = fixture.createSpan( - name: 'root', - samplingDecision: decision, - ); - final child = fixture.createSpan(name: 'child', parentSpan: root); - final grandchild = - fixture.createSpan(name: 'grandchild', parentSpan: child); - - expect(grandchild.samplingDecision.sampled, equals(decision.sampled)); - expect(grandchild.samplingDecision.sampleRate, - equals(decision.sampleRate)); - expect(grandchild.samplingDecision.sampleRand, - equals(decision.sampleRand)); - }); - }); - - group('toJson', () { - test('serializes basic span without parent', () { - final span = fixture.createSpan(name: 'test-span'); - span.end(); - - final json = span.toJson(); - - expect(json['trace_id'], equals(span.traceId.toString())); - expect(json['span_id'], equals(span.spanId.toString())); - expect(json['name'], equals('test-span')); - expect(json['is_segment'], isTrue); - expect(json['status'], equals('ok')); - expect(json['start_timestamp'], isA()); - expect(json['end_timestamp'], isA()); - expect(json.containsKey('parent_span_id'), isFalse); - }); - - test('serializes span with parent', () { - final parent = fixture.createSpan(name: 'parent'); - final child = fixture.createSpan(name: 'child', parentSpan: parent); - child.end(); - - final json = child.toJson(); - - expect(json['parent_span_id'], equals(parent.spanId.toString())); - expect(json['is_segment'], isFalse); - }); - - test('serializes span with error status', () { - final span = fixture.createSpan(name: 'test-span'); - span.status = SentrySpanStatusV2.error; - span.end(); - - final json = span.toJson(); - - expect(json['status'], equals('error')); - }); - - test('serializes span with attributes', () { - final span = fixture.createSpan(name: 'test-span'); - span.setAttribute('string_attr', SentryAttribute.string('value')); - span.setAttribute('int_attr', SentryAttribute.int(42)); - span.setAttribute('bool_attr', SentryAttribute.bool(true)); - span.setAttribute('double_attr', SentryAttribute.double(3.14)); - span.end(); - - final json = span.toJson(); - - expect(json.containsKey('attributes'), isTrue); - final attributes = Map.from(json['attributes']); - - expect(attributes['string_attr'], {'value': 'value', 'type': 'string'}); - expect(attributes['int_attr'], {'value': 42, 'type': 'integer'}); - expect(attributes['bool_attr'], {'value': true, 'type': 'boolean'}); - expect(attributes['double_attr'], {'value': 3.14, 'type': 'double'}); - }); - - test('end_timestamp is null when span is not finished', () { - final span = fixture.createSpan(name: 'test-span'); - - final json = span.toJson(); - - expect(json['end_timestamp'], isNull); - }); - - test( - 'timestamps are serialized as unix seconds with microsecond precision', - () { - final span = fixture.createSpan(name: 'test-span'); - final customEndTime = DateTime.utc(2024, 6, 15, 12, 30, 45, 123, 456); - span.end(endTimestamp: customEndTime); - - final json = span.toJson(); - - final endTimestamp = json['end_timestamp'] as double; - final expectedMicros = customEndTime.microsecondsSinceEpoch; - final expectedSeconds = expectedMicros / 1000000; - - expect(endTimestamp, closeTo(expectedSeconds, 0.000001)); - }); - - test('serializes updated name', () { - final span = fixture.createSpan(name: 'original-name'); - span.name = 'updated-name'; - span.end(); - - final json = span.toJson(); - - expect(json['name'], equals('updated-name')); - }); - }); - }); - - group('NoOpSentrySpanV2', () { - test('operations do not throw', () { - const span = NoOpSentrySpanV2(); - - // All operations should be no-ops and not throw - span.end(); - span.end(endTimestamp: DateTime.now()); - span.setAttribute('key', SentryAttribute.string('value')); - span.setAttributes({'key': SentryAttribute.string('value')}); - span.removeAttribute('key'); - span.name = 'name'; - span.status = SentrySpanStatusV2.ok; - span.status = SentrySpanStatusV2.error; - }); - - test('returns default values', () { - const span = NoOpSentrySpanV2(); - - expect(span.spanId.toString(), SpanId.empty().toString()); - expect(span.traceId.toString(), SentryId.empty().toString()); - expect(span.name, 'NoOpSpan'); - expect(span.status, SentrySpanStatusV2.ok); - expect(span.parentSpan, isNull); - expect(span.endTimestamp, isNull); - expect(span.attributes, isEmpty); - }); - }); - - group('UnsetSentrySpanV2', () { - test('all APIs throw to prevent accidental use', () { - const span = UnsetSentrySpanV2(); - - expect(() => span.spanId, throwsA(isA())); - expect(() => span.traceId, throwsA(isA())); - expect(() => span.name, throwsA(isA())); - expect(() => span.status, throwsA(isA())); - expect(() => span.parentSpan, throwsA(isA())); - expect(() => span.endTimestamp, throwsA(isA())); - expect(() => span.attributes, throwsA(isA())); - - expect(() => span.name = 'foo', throwsA(isA())); - expect(() => span.status = SentrySpanStatusV2.ok, - throwsA(isA())); - expect(() => span.setAttribute('k', SentryAttribute.string('v')), - throwsA(isA())); - expect(() => span.setAttributes({'k': SentryAttribute.string('v')}), - throwsA(isA())); - expect( - () => span.removeAttribute('k'), throwsA(isA())); - expect(() => span.end(), throwsA(isA())); - }); - }); -} - -class Fixture { - final options = defaultTestOptions(); - - RecordingSentrySpanV2 createSpan({ - required String name, - RecordingSentrySpanV2? parentSpan, - SentryId? traceId, - OnSpanEndCallback? onSpanEnded, - DscCreatorCallback? dscCreator, - SentryTracesSamplingDecision? samplingDecision, - }) { - final defaultDscCreator = (RecordingSentrySpanV2 span) => - SentryTraceContextHeader(SentryId.newId(), 'publicKey'); - - if (parentSpan != null) { - return RecordingSentrySpanV2.child( - parent: parentSpan, - name: name, - onSpanEnd: onSpanEnded ?? (_) {}, - clock: options.clock, - dscCreator: dscCreator ?? defaultDscCreator, - ); - } - return RecordingSentrySpanV2.root( - name: name, - traceId: traceId ?? SentryId.newId(), - onSpanEnd: onSpanEnded ?? (_) {}, - clock: options.clock, - dscCreator: dscCreator ?? defaultDscCreator, - samplingDecision: samplingDecision ?? SentryTracesSamplingDecision(true), - ); - } -} diff --git a/packages/flutter/test/mocks.dart b/packages/flutter/test/mocks.dart index 34ddbf8a9d..10dc8e259d 100644 --- a/packages/flutter/test/mocks.dart +++ b/packages/flutter/test/mocks.dart @@ -240,16 +240,10 @@ class MockLogItem { } class MockTelemetryProcessor implements TelemetryProcessor { - final List addedSpans = []; final List addedLogs = []; int flushCalls = 0; int closeCalls = 0; - @override - void addSpan(RecordingSentrySpanV2 span) { - addedSpans.add(span); - } - @override void addLog(SentryLog log) { addedLogs.add(log); From 2c617bb0a7aabccdf20cd2fc4e8d7e613442d881 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 17:54:28 +0100 Subject: [PATCH 03/42] Remove span-related tests from sentry_client_test Co-Authored-By: Claude Sonnet 4.5 --- packages/dart/test/sentry_client_test.dart | 48 ---------------------- 1 file changed, 48 deletions(-) diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index 32c51484ef..e7c46ab55e 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -16,7 +16,6 @@ import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/transport/noop_transport.dart'; import 'package:sentry/src/transport/spotlight_http_transport.dart'; import 'package:sentry/src/utils/iterable_utils.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'package:test/test.dart'; import 'package:mockito/mockito.dart'; import 'package:http/http.dart' as http; @@ -2061,53 +2060,6 @@ void main() { }); }); - group('SentryClient', () { - group('when capturing span', () { - late Fixture fixture; - late MockTelemetryProcessor processor; - - setUp(() { - fixture = Fixture(); - processor = MockTelemetryProcessor(); - fixture.options.telemetryProcessor = processor; - }); - - test('adds recording span to telemetry processor', () { - final client = fixture.getSut(); - - final span = RecordingSentrySpanV2.root( - name: 'test-span', - traceId: SentryId.newId(), - onSpanEnd: (_) {}, - clock: fixture.options.clock, - dscCreator: (s) => SentryTraceContextHeader(SentryId.newId(), 'key'), - samplingDecision: SentryTracesSamplingDecision(true), - ); - - client.captureSpan(span); - - expect(processor.addedSpans, hasLength(1)); - expect(processor.addedSpans.first, equals(span)); - }); - - test('does nothing for NoOpSentrySpanV2', () { - final client = fixture.getSut(); - - client.captureSpan(const NoOpSentrySpanV2()); - - expect(processor.addedSpans, isEmpty); - }); - - test('does nothing for UnsetSentrySpanV2', () { - final client = fixture.getSut(); - - client.captureSpan(const UnsetSentrySpanV2()); - - expect(processor.addedSpans, isEmpty); - }); - }); - }); - group('SentryClient captures envelope', () { late Fixture fixture; final fakeEnvelope = getFakeEnvelope(); From 6ef8c3c8aa2951ee73bc142b5ad6e2d22e824574 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 17:54:54 +0100 Subject: [PATCH 04/42] Remove span-related processor tests Co-Authored-By: Claude Sonnet 4.5 --- .../processor_integration_test.dart | 161 ---------------- .../telemetry/processing/processor_test.dart | 173 ------------------ 2 files changed, 334 deletions(-) delete mode 100644 packages/dart/test/telemetry/processing/processor_integration_test.dart delete mode 100644 packages/dart/test/telemetry/processing/processor_test.dart diff --git a/packages/dart/test/telemetry/processing/processor_integration_test.dart b/packages/dart/test/telemetry/processing/processor_integration_test.dart deleted file mode 100644 index 2fedd77d1e..0000000000 --- a/packages/dart/test/telemetry/processing/processor_integration_test.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:sentry/sentry.dart'; -import 'package:sentry/src/telemetry/processing/in_memory_buffer.dart'; -import 'package:sentry/src/telemetry/processing/processor.dart'; -import 'package:sentry/src/telemetry/processing/processor_integration.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; -import 'package:test/test.dart'; - -import '../../mocks/mock_hub.dart'; -import '../../mocks/mock_transport.dart'; -import '../../test_utils.dart'; - -void main() { - group('DefaultTelemetryProcessorIntegration', () { - late _Fixture fixture; - - setUp(() { - fixture = _Fixture(); - }); - - test( - 'sets up DefaultTelemetryProcessor when NoOpTelemetryProcessor is active', - () { - final options = fixture.options; - expect(options.telemetryProcessor, isA()); - - fixture.getSut().call(fixture.hub, options); - - expect(options.telemetryProcessor, isA()); - }); - - test('does not override existing telemetry processor', () { - final options = fixture.options; - final existingProcessor = DefaultTelemetryProcessor(options.log); - options.telemetryProcessor = existingProcessor; - - fixture.getSut().call(fixture.hub, options); - - expect(identical(options.telemetryProcessor, existingProcessor), isTrue); - }); - - test('adds integration name to SDK', () { - final options = fixture.options; - - fixture.getSut().call(fixture.hub, options); - - expect( - options.sdk.integrations, - contains(DefaultTelemetryProcessorIntegration.integrationName), - ); - }); - - test('configures log buffer as InMemoryTelemetryBuffer', () { - final options = fixture.options; - - fixture.getSut().call(fixture.hub, options); - - final processor = options.telemetryProcessor as DefaultTelemetryProcessor; - expect(processor.logBuffer, isA>()); - }); - - test('configures span buffer as GroupedInMemoryTelemetryBuffer', () { - final options = fixture.options; - - fixture.getSut().call(fixture.hub, options); - - final processor = options.telemetryProcessor as DefaultTelemetryProcessor; - expect(processor.spanBuffer, - isA>()); - }); - - test('configures span buffer with group key extractor', () { - final options = fixture.options; - - final integration = fixture.getSut(); - integration.call(fixture.hub, options); - - final processor = options.telemetryProcessor as DefaultTelemetryProcessor; - - expect( - (processor.spanBuffer - as GroupedInMemoryTelemetryBuffer) - .groupKey, - integration.spanGroupKeyExtractor); - }); - - test('spanGroupKeyExtractor uses traceId-spanId format', () { - final options = fixture.options; - - final integration = fixture.getSut(); - integration.call(fixture.hub, options); - - final span = fixture.createSpan(); - final key = integration.spanGroupKeyExtractor(span); - - expect(key, '${span.traceId}-${span.spanId}'); - }); - - group('flush', () { - test('log reaches transport as envelope', () async { - final options = fixture.options; - fixture.getSut().call(fixture.hub, options); - - final processor = - options.telemetryProcessor as DefaultTelemetryProcessor; - processor.addLog(fixture.createLog()); - await processor.flush(); - - expect(fixture.transport.envelopes, hasLength(1)); - }); - - test('span reaches transport as envelope', () async { - final options = fixture.options; - fixture.getSut().call(fixture.hub, options); - - final processor = - options.telemetryProcessor as DefaultTelemetryProcessor; - final span = fixture.createSpan(); - span.end(); - processor.addSpan(span); - await processor.flush(); - - expect(fixture.transport.envelopes, hasLength(1)); - }); - }); - }); -} - -class _Fixture { - final hub = MockHub(); - final transport = MockTransport(); - late SentryOptions options; - - _Fixture() { - options = defaultTestOptions()..transport = transport; - } - - DefaultTelemetryProcessorIntegration getSut() { - return DefaultTelemetryProcessorIntegration(); - } - - SentryLog createLog() { - return SentryLog( - timestamp: DateTime.now().toUtc(), - level: SentryLogLevel.info, - body: 'test log', - attributes: {}, - ); - } - - RecordingSentrySpanV2 createSpan() { - return RecordingSentrySpanV2.root( - name: 'test-span', - traceId: SentryId.newId(), - onSpanEnd: (_) {}, - clock: options.clock, - dscCreator: (_) => - SentryTraceContextHeader(SentryId.newId(), 'publicKey'), - samplingDecision: SentryTracesSamplingDecision(true), - ); - } -} diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart deleted file mode 100644 index 6e20939b09..0000000000 --- a/packages/dart/test/telemetry/processing/processor_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:async'; - -import 'package:sentry/sentry.dart'; -import 'package:sentry/src/telemetry/processing/processor.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; -import 'package:test/test.dart'; - -import '../../mocks/mock_telemetry_buffer.dart'; -import '../../test_utils.dart'; - -void main() { - group('DefaultTelemetryProcessor', () { - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - group('addSpan', () { - test('routes span to span buffer', () { - final mockSpanBuffer = MockTelemetryBuffer(); - final processor = fixture.getSut(spanBuffer: mockSpanBuffer); - - final span = fixture.createSpan(); - span.end(); - processor.addSpan(span); - - expect(mockSpanBuffer.addedItems.length, 1); - expect(mockSpanBuffer.addedItems.first, span); - }); - - test('does not throw when no span buffer registered', () { - final processor = fixture.getSut(); - processor.spanBuffer = null; - - final span = fixture.createSpan(); - span.end(); - processor.addSpan(span); - - // Nothing to assert - just verifying no exception thrown - }); - }); - - group('addLog', () { - test('routes log to log buffer', () { - final mockLogBuffer = MockTelemetryBuffer(); - final processor = - fixture.getSut(enableLogs: true, logBuffer: mockLogBuffer); - - final log = fixture.createLog(); - processor.addLog(log); - - expect(mockLogBuffer.addedItems.length, 1); - expect(mockLogBuffer.addedItems.first, log); - }); - - test('does not throw when no log buffer registered', () { - final processor = fixture.getSut(); - processor.logBuffer = null; - - final log = fixture.createLog(); - processor.addLog(log); - }); - }); - - group('flush', () { - test('flushes all registered buffers', () async { - final mockSpanBuffer = MockTelemetryBuffer(); - final mockLogBuffer = MockTelemetryBuffer(); - final processor = fixture.getSut( - enableLogs: true, - spanBuffer: mockSpanBuffer, - logBuffer: mockLogBuffer, - ); - - await processor.flush(); - - expect(mockSpanBuffer.flushCallCount, 1); - expect(mockLogBuffer.flushCallCount, 1); - }); - - test('flushes only span buffer when log buffer is null', () async { - final mockSpanBuffer = MockTelemetryBuffer(); - final processor = fixture.getSut(spanBuffer: mockSpanBuffer); - processor.logBuffer = null; - - await processor.flush(); - - expect(mockSpanBuffer.flushCallCount, 1); - }); - - test('returns sync (null) when all buffers flush synchronously', () { - final mockSpanBuffer = - MockTelemetryBuffer(asyncFlush: false); - final processor = fixture.getSut(spanBuffer: mockSpanBuffer); - processor.logBuffer = null; - - final result = processor.flush(); - - expect(result, isNull); - }); - - test('returns Future when at least one buffer flushes asynchronously', - () async { - final mockSpanBuffer = - MockTelemetryBuffer(asyncFlush: true); - final processor = fixture.getSut(spanBuffer: mockSpanBuffer); - processor.logBuffer = null; - - final result = processor.flush(); - - expect(result, isA()); - await result; - }); - }); - }); -} - -class Fixture { - late SentryOptions options; - - Fixture() { - options = defaultTestOptions(); - } - - DefaultTelemetryProcessor getSut({ - bool enableLogs = false, - MockTelemetryBuffer? spanBuffer, - MockTelemetryBuffer? logBuffer, - }) { - options.enableLogs = enableLogs; - return DefaultTelemetryProcessor( - options.log, - spanBuffer: spanBuffer, - logBuffer: logBuffer, - ); - } - - RecordingSentrySpanV2 createSpan({String name = 'test-span'}) { - return RecordingSentrySpanV2.root( - name: name, - traceId: SentryId.newId(), - onSpanEnd: (_) {}, - clock: options.clock, - dscCreator: (_) => - SentryTraceContextHeader(SentryId.newId(), 'publicKey'), - samplingDecision: SentryTracesSamplingDecision(true), - ); - } - - RecordingSentrySpanV2 createChildSpan({ - required RecordingSentrySpanV2 parent, - String name = 'child-span', - }) { - return RecordingSentrySpanV2.child( - parent: parent, - name: name, - onSpanEnd: (_) {}, - clock: options.clock, - dscCreator: (_) => - SentryTraceContextHeader(SentryId.newId(), 'publicKey'), - ); - } - - SentryLog createLog({String body = 'test log'}) { - return SentryLog( - timestamp: DateTime.now().toUtc(), - level: SentryLogLevel.info, - body: body, - attributes: {}, - ); - } -} From 3ca4c08a9cb2c79bd7eacfc152745f31d6e5ce5b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 17:57:26 +0100 Subject: [PATCH 05/42] Remove span import from Flutter mocks Co-Authored-By: Claude Sonnet 4.5 --- packages/flutter/test/mocks.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flutter/test/mocks.dart b/packages/flutter/test/mocks.dart index 10dc8e259d..af190bf78e 100644 --- a/packages/flutter/test/mocks.dart +++ b/packages/flutter/test/mocks.dart @@ -9,7 +9,6 @@ import 'package:mockito/mockito.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; -import 'package:sentry/src/telemetry/span/sentry_span_v2.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/frames_tracking/sentry_delayed_frames_tracker.dart'; import 'package:sentry_flutter/src/navigation/time_to_display_tracker.dart'; From 9b34042ffaf502d153876bbc518cd9fd8a6f3236 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 18:52:04 +0100 Subject: [PATCH 06/42] Fix wiring up --- packages/dart/lib/src/sentry.dart | 2 ++ packages/dart/test/sentry_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/dart/lib/src/sentry.dart b/packages/dart/lib/src/sentry.dart index f65bc721b1..06821ccedf 100644 --- a/packages/dart/lib/src/sentry.dart +++ b/packages/dart/lib/src/sentry.dart @@ -23,6 +23,7 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; import 'sentry_run_zoned_guarded.dart'; +import 'telemetry/processing/processor_integration.dart'; import 'tracing.dart'; import 'transport/data_category.dart'; import 'transport/task_queue.dart'; @@ -111,6 +112,7 @@ class Sentry { options.addIntegration(FeatureFlagsIntegration()); options.addIntegration(LogsEnricherIntegration()); + options.addIntegration(DefaultTelemetryProcessorIntegration()); options.addEventProcessor(EnricherEventProcessor(options)); options.addEventProcessor(ExceptionEventProcessor(options)); diff --git a/packages/dart/test/sentry_test.dart b/packages/dart/test/sentry_test.dart index 9230d71396..1c03c84b5e 100644 --- a/packages/dart/test/sentry_test.dart +++ b/packages/dart/test/sentry_test.dart @@ -8,6 +8,7 @@ import 'package:sentry/src/dart_exception_type_identifier.dart'; import 'package:sentry/src/event_processor/deduplication_event_processor.dart'; import 'package:sentry/src/logs_enricher_integration.dart'; import 'package:sentry/src/feature_flags_integration.dart'; +import 'package:sentry/src/telemetry/processing/processor_integration.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -319,6 +320,27 @@ void main() { ); }); + test('should add DefaultTelemetryProcessorIntegration', () async { + late SentryOptions optionsReference; + final options = defaultTestOptions(); + + await Sentry.init( + options: options, + (options) { + options.dsn = fakeDsn; + optionsReference = options; + }, + appRunner: appRunner, + ); + + expect( + optionsReference.integrations + .whereType() + .length, + 1, + ); + }); + test('should add only web compatible default integrations', () async { final options = defaultTestOptions(); await Sentry.init( From e0b564cd7a6420f6aae08e3a22c860f2ed9231b9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 19:32:19 +0100 Subject: [PATCH 07/42] Update --- .../processing/in_memory_buffer.dart | 35 ------ .../telemetry/processing/buffer_test.dart | 111 +----------------- .../processor_integration_test.dart | 97 +++++++++++++++ .../telemetry/processing/processor_test.dart | 104 ++++++++++++++++ 4 files changed, 202 insertions(+), 145 deletions(-) create mode 100644 packages/dart/test/telemetry/processing/processor_integration_test.dart create mode 100644 packages/dart/test/telemetry/processing/processor_test.dart diff --git a/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart b/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart index 9b62344e52..21809f9740 100644 --- a/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart +++ b/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart @@ -12,9 +12,6 @@ typedef OnFlushCallback = FutureOr Function(T data); /// Encodes an item of type [T] into bytes. typedef ItemEncoder = List Function(T item); -/// Extracts a grouping key from items of type [T]. -typedef GroupKeyExtractor = String Function(T item); - /// Base class for in-memory telemetry buffers. /// /// Buffers telemetry items in memory and flushes them when either the @@ -146,35 +143,3 @@ final class InMemoryTelemetryBuffer @override bool get _isEmpty => _storage.isEmpty; } - -/// In-memory buffer that groups telemetry items by a key. -/// -/// Same idea as [InMemoryTelemetryBuffer], but grouped. -final class GroupedInMemoryTelemetryBuffer - extends _BaseInMemoryTelemetryBuffer>, T)>> { - final GroupKeyExtractor _groupKey; - - @visibleForTesting - GroupKeyExtractor get groupKey => _groupKey; - - GroupedInMemoryTelemetryBuffer({ - required super.encoder, - required super.onFlush, - required GroupKeyExtractor groupKeyExtractor, - super.config, - }) : _groupKey = groupKeyExtractor, - super(initialStorage: {}); - - @override - Map>, T)> _createEmptyStorage() => {}; - - @override - void _store(List encoded, T item) { - final key = _groupKey(item); - final bucket = _storage.putIfAbsent(key, () => ([], item)); - bucket.$1.add(encoded); - } - - @override - bool get _isEmpty => _storage.isEmpty; -} diff --git a/packages/dart/test/telemetry/processing/buffer_test.dart b/packages/dart/test/telemetry/processing/buffer_test.dart index e74cb81d01..fe5335f743 100644 --- a/packages/dart/test/telemetry/processing/buffer_test.dart +++ b/packages/dart/test/telemetry/processing/buffer_test.dart @@ -160,96 +160,12 @@ void main() { expect(fixture.flushCallCount, 1); }); }); - - group('GroupedInMemoryTelemetryBuffer', () { - late _GroupedFixture fixture; - - setUp(() { - fixture = _GroupedFixture(); - }); - - test('items are grouped by key', () async { - final buffer = fixture.getSut( - groupKeyExtractor: (item) => item.group, - ); - - buffer.add(_TestItem('item1', group: 'group1')); - buffer.add(_TestItem('item2', group: 'group2')); - buffer.add(_TestItem('item3', group: 'group1')); - - await buffer.flush(); - - expect(fixture.flushCallCount, 1); - expect(fixture.flushedGroups.keys, containsAll(['group1', 'group2'])); - expect( - fixture.flushedGroups['group1']?.$1, hasLength(2)); // item1 and item3 - expect(fixture.flushedGroups['group2']?.$1, hasLength(1)); // item2 - }); - - test('items are flushed after timeout', () async { - final flushTimeout = Duration(milliseconds: 1); - final buffer = fixture.getSut( - config: TelemetryBufferConfig(flushTimeout: flushTimeout), - groupKeyExtractor: (item) => item.group, - ); - - buffer.add(_TestItem('item1', group: 'a')); - buffer.add(_TestItem('item2', group: 'b')); - - expect(fixture.flushedGroups, isEmpty); - - await Future.delayed(flushTimeout + Duration(milliseconds: 10)); - - expect(fixture.flushCallCount, 1); - expect(fixture.flushedGroups.keys, hasLength(2)); - }); - - test('flush with empty buffer returns null', () async { - final buffer = fixture.getSut( - groupKeyExtractor: (item) => item.group, - ); - - final result = buffer.flush(); - - expect(result, isNull); - expect(fixture.flushedGroups, isEmpty); - }); - - test('buffer is cleared after flush', () async { - final buffer = fixture.getSut( - groupKeyExtractor: (item) => item.group, - ); - - buffer.add(_TestItem('item1', group: 'a')); - await buffer.flush(); - - expect(fixture.flushCallCount, 1); - - fixture.reset(); - final result = buffer.flush(); - - expect(result, isNull); - expect(fixture.flushCallCount, 0); - }); - - test('onFlush receives Map>>', () async { - final buffer = fixture.getSut( - groupKeyExtractor: (item) => item.group, - ); - - buffer.add(_TestItem('item1', group: 'myGroup')); - await buffer.flush(); - - expect(fixture.flushedGroups.containsKey('myGroup'), isTrue); - }); - }); } class _TestItem { final String id; - final String group; - _TestItem(this.id, {this.group = 'default'}); + _TestItem(this.id); Map toJson() => {'id': id}; } @@ -283,28 +199,3 @@ class _SimpleFixture { flushCallCount = 0; } } - -class _GroupedFixture { - Map>, _TestItem)> flushedGroups = {}; - int flushCallCount = 0; - - GroupedInMemoryTelemetryBuffer<_TestItem> getSut({ - required GroupKeyExtractor<_TestItem> groupKeyExtractor, - TelemetryBufferConfig config = const TelemetryBufferConfig(), - }) { - return GroupedInMemoryTelemetryBuffer<_TestItem>( - encoder: (item) => utf8.encode(jsonEncode(item.toJson())), - onFlush: (groups) { - flushCallCount++; - flushedGroups = groups; - }, - groupKeyExtractor: groupKeyExtractor, - config: config, - ); - } - - void reset() { - flushedGroups = {}; - flushCallCount = 0; - } -} diff --git a/packages/dart/test/telemetry/processing/processor_integration_test.dart b/packages/dart/test/telemetry/processing/processor_integration_test.dart new file mode 100644 index 0000000000..009a6c3613 --- /dev/null +++ b/packages/dart/test/telemetry/processing/processor_integration_test.dart @@ -0,0 +1,97 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/processing/in_memory_buffer.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:sentry/src/telemetry/processing/processor_integration.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_hub.dart'; +import '../../mocks/mock_transport.dart'; +import '../../test_utils.dart'; + +void main() { + group('DefaultTelemetryProcessorIntegration', () { + late _Fixture fixture; + + setUp(() { + fixture = _Fixture(); + }); + + test( + 'sets up DefaultTelemetryProcessor when NoOpTelemetryProcessor is active', + () { + final options = fixture.options; + expect(options.telemetryProcessor, isA()); + + fixture.getSut().call(fixture.hub, options); + + expect(options.telemetryProcessor, isA()); + }); + + test('does not override existing telemetry processor', () { + final options = fixture.options; + final existingProcessor = DefaultTelemetryProcessor(options.log); + options.telemetryProcessor = existingProcessor; + + fixture.getSut().call(fixture.hub, options); + + expect(identical(options.telemetryProcessor, existingProcessor), isTrue); + }); + + test('adds integration name to SDK', () { + final options = fixture.options; + + fixture.getSut().call(fixture.hub, options); + + expect( + options.sdk.integrations, + contains(DefaultTelemetryProcessorIntegration.integrationName), + ); + }); + + test('configures log buffer as InMemoryTelemetryBuffer', () { + final options = fixture.options; + + fixture.getSut().call(fixture.hub, options); + + final processor = options.telemetryProcessor as DefaultTelemetryProcessor; + expect(processor.logBuffer, isA>()); + }); + + group('flush', () { + test('log reaches transport as envelope', () async { + final options = fixture.options; + fixture.getSut().call(fixture.hub, options); + + final processor = + options.telemetryProcessor as DefaultTelemetryProcessor; + processor.addLog(fixture.createLog()); + await processor.flush(); + + expect(fixture.transport.envelopes, hasLength(1)); + }); + }); + }); +} + +class _Fixture { + final hub = MockHub(); + final transport = MockTransport(); + late SentryOptions options; + + _Fixture() { + options = defaultTestOptions()..transport = transport; + } + + DefaultTelemetryProcessorIntegration getSut() { + return DefaultTelemetryProcessorIntegration(); + } + + SentryLog createLog() { + return SentryLog( + timestamp: DateTime.now().toUtc(), + level: SentryLogLevel.info, + body: 'test log', + attributes: {}, + ); + } +} diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart new file mode 100644 index 0000000000..3dbf1b2887 --- /dev/null +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/processing/processor.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_telemetry_buffer.dart'; +import '../../test_utils.dart'; + +void main() { + group('DefaultTelemetryProcessor', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('addLog', () { + test('routes log to log buffer', () { + final mockLogBuffer = MockTelemetryBuffer(); + final processor = + fixture.getSut(enableLogs: true, logBuffer: mockLogBuffer); + + final log = fixture.createLog(); + processor.addLog(log); + + expect(mockLogBuffer.addedItems.length, 1); + expect(mockLogBuffer.addedItems.first, log); + }); + + test('does not throw when no log buffer registered', () { + final processor = fixture.getSut(); + processor.logBuffer = null; + + final log = fixture.createLog(); + processor.addLog(log); + }); + }); + + group('flush', () { + test('flushes all registered buffers', () async { + final mockLogBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut( + enableLogs: true, + logBuffer: mockLogBuffer, + ); + + await processor.flush(); + + expect(mockLogBuffer.flushCallCount, 1); + }); + + test('returns sync (null) when all buffers flush synchronously', () { + final mockLogBuffer = MockTelemetryBuffer(asyncFlush: false); + final processor = fixture.getSut(logBuffer: mockLogBuffer); + processor.logBuffer = null; + + final result = processor.flush(); + + expect(result, isNull); + }); + + test('returns Future when at least one buffer flushes asynchronously', + () async { + final mockLogBuffer = MockTelemetryBuffer(asyncFlush: true); + final processor = fixture.getSut(logBuffer: mockLogBuffer); + processor.logBuffer = null; + + final result = processor.flush(); + + expect(result, isA()); + await result; + }); + }); + }); +} + +class Fixture { + late SentryOptions options; + + Fixture() { + options = defaultTestOptions(); + } + + DefaultTelemetryProcessor getSut({ + bool enableLogs = false, + MockTelemetryBuffer? logBuffer, + }) { + options.enableLogs = enableLogs; + return DefaultTelemetryProcessor( + options.log, + logBuffer: logBuffer, + ); + } + + SentryLog createLog({String body = 'test log'}) { + return SentryLog( + timestamp: DateTime.now().toUtc(), + level: SentryLogLevel.info, + body: body, + attributes: {}, + ); + } +} From 6da49c8766c49059c058121236ad46d361934408 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 19:32:58 +0100 Subject: [PATCH 08/42] Update --- .../dart/lib/src/telemetry/processing/in_memory_buffer.dart | 2 -- .../lib/src/telemetry/processing/processor_integration.dart | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart b/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart index 21809f9740..be97f5f0a4 100644 --- a/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart +++ b/packages/dart/lib/src/telemetry/processing/in_memory_buffer.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:meta/meta.dart'; - import '../../utils/internal_logger.dart'; import 'buffer.dart'; import 'buffer_config.dart'; diff --git a/packages/dart/lib/src/telemetry/processing/processor_integration.dart b/packages/dart/lib/src/telemetry/processing/processor_integration.dart index a2796882e5..c3581dd505 100644 --- a/packages/dart/lib/src/telemetry/processing/processor_integration.dart +++ b/packages/dart/lib/src/telemetry/processing/processor_integration.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import '../../../sentry.dart'; import 'in_memory_buffer.dart'; import 'processor.dart'; From 1b97198a1a309d8ff98259302d46bfa5ff68b691 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 19:35:07 +0100 Subject: [PATCH 09/42] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8bc0255d..e06a63c8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- Replace log batcher with telemetry processor ([#3448](https://github.com/getsentry/sentry-dart/pull/3448)) + ### Dependencies - Bump Native SDK from v0.10.0 to v0.12.3 ([#3438](https://github.com/getsentry/sentry-dart/pull/3438)) From 82a4374115a0dd72971159f8c43afd7af0186450 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 19:35:46 +0100 Subject: [PATCH 10/42] Update --- packages/dart/test/telemetry/processing/processor_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart index 3dbf1b2887..5429793e3f 100644 --- a/packages/dart/test/telemetry/processing/processor_test.dart +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -64,7 +64,6 @@ void main() { () async { final mockLogBuffer = MockTelemetryBuffer(asyncFlush: true); final processor = fixture.getSut(logBuffer: mockLogBuffer); - processor.logBuffer = null; final result = processor.flush(); From 1a48756bcafd29262580f8bc436590fd65392157 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 14 Jan 2026 23:46:21 +0100 Subject: [PATCH 11/42] Update --- packages/dart/lib/src/hub.dart | 33 ++++++++ packages/dart/lib/src/hub_adapter.dart | 5 ++ packages/dart/lib/src/noop_hub.dart | 4 + packages/dart/lib/src/sentry.dart | 3 + packages/dart/lib/src/sentry_client.dart | 31 +++++++ packages/dart/lib/src/sentry_envelope.dart | 29 +++++++ .../dart/lib/src/sentry_envelope_item.dart | 16 ++++ packages/dart/lib/src/sentry_item_type.dart | 1 + packages/dart/lib/src/sentry_options.dart | 18 +++++ .../lib/src/telemetry/metric/metric_type.dart | 14 ++++ .../src/telemetry/metric/sentry_metric.dart | 81 +++++++++++++++++++ .../src/telemetry/metric/sentry_metrics.dart | 79 ++++++++++++++++++ .../src/telemetry/processing/processor.dart | 47 ++++++++--- .../processing/processor_integration.dart | 17 +++- .../test/mocks/mock_telemetry_processor.dart | 7 ++ .../processor_integration_test.dart | 2 +- .../telemetry/processing/processor_test.dart | 54 +++++++++++-- 17 files changed, 421 insertions(+), 20 deletions(-) create mode 100644 packages/dart/lib/src/telemetry/metric/metric_type.dart create mode 100644 packages/dart/lib/src/telemetry/metric/sentry_metric.dart create mode 100644 packages/dart/lib/src/telemetry/metric/sentry_metrics.dart diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 5346e2cd69..77a2eea307 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -9,6 +9,7 @@ import 'client_reports/discard_reason.dart'; import 'profiling.dart'; import 'sentry_tracer.dart'; import 'sentry_traces_sampler.dart'; +import 'telemetry/metric/sentry_metric.dart'; import 'transport/data_category.dart'; /// Configures the scope through the callback. @@ -317,6 +318,38 @@ class Hub { } } + Future captureMetric(SentryMetric metric) async { + if (!_isEnabled) { + _options.log( + SentryLevel.warning, + "Instance is disabled and this 'captureMetric' call is a no-op.", + ); + } else { + final item = _peek(); + late Scope scope; + final s = _cloneAndRunWithScope(item.scope, null); + if (s is Future) { + scope = await s; + } else { + scope = s; + } + + try { + await item.client.captureMetric( + metric, + scope: scope, + ); + } catch (exception, stacktrace) { + _options.log( + SentryLevel.error, + 'Error while capturing metric', + exception: exception, + stackTrace: stacktrace, + ); + } + } + } + FutureOr _cloneAndRunWithScope( Scope scope, ScopeCallback? withScope) async { if (withScope != null) { diff --git a/packages/dart/lib/src/hub_adapter.dart b/packages/dart/lib/src/hub_adapter.dart index 6491f2a951..ae5a369dc6 100644 --- a/packages/dart/lib/src/hub_adapter.dart +++ b/packages/dart/lib/src/hub_adapter.dart @@ -11,6 +11,7 @@ import 'scope.dart'; import 'sentry.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; +import 'telemetry/metric/sentry_metric.dart'; import 'tracing.dart'; /// Hub adapter to make Integrations testable @@ -200,6 +201,10 @@ class HubAdapter implements Hub { @override FutureOr captureLog(SentryLog log) => Sentry.currentHub.captureLog(log); + @override + Future captureMetric(SentryMetric metric) => + Sentry.currentHub.captureMetric(metric); + @override void setAttributes(Map attributes) => Sentry.currentHub.setAttributes(attributes); diff --git a/packages/dart/lib/src/noop_hub.dart b/packages/dart/lib/src/noop_hub.dart index bb486b9e80..f23f054048 100644 --- a/packages/dart/lib/src/noop_hub.dart +++ b/packages/dart/lib/src/noop_hub.dart @@ -10,6 +10,7 @@ import 'protocol/sentry_feedback.dart'; import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; +import 'telemetry/metric/sentry_metric.dart'; import 'tracing.dart'; class NoOpHub implements Hub { @@ -97,6 +98,9 @@ class NoOpHub implements Hub { @override FutureOr captureLog(SentryLog log) async {} + @override + Future captureMetric(SentryMetric metric) async {} + @override ISentrySpan startTransaction( String name, diff --git a/packages/dart/lib/src/sentry.dart b/packages/dart/lib/src/sentry.dart index 06821ccedf..1c0d65af38 100644 --- a/packages/dart/lib/src/sentry.dart +++ b/packages/dart/lib/src/sentry.dart @@ -23,6 +23,7 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; import 'sentry_run_zoned_guarded.dart'; +import 'telemetry/metric/sentry_metrics.dart'; import 'telemetry/processing/processor_integration.dart'; import 'tracing.dart'; import 'transport/data_category.dart'; @@ -450,4 +451,6 @@ class Sentry { ); static SentryLogger get logger => currentHub.options.logger; + + static SentryMetrics get metrics => currentHub.options.metrics; } diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index 02e6841e1d..de0940c4d7 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -18,6 +18,7 @@ import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'sentry_trace_context_header.dart'; +import 'telemetry/metric/sentry_metric.dart'; import 'transport/client_report_transport.dart'; import 'transport/data_category.dart'; import 'transport/http_transport.dart'; @@ -583,6 +584,36 @@ class SentryClient { } } + Future captureMetric(SentryMetric metric, {Scope? scope}) async { + if (!_options.enableLogs) { + return; + } + + final beforeSendMetric = _options.beforeSendMetric; + SentryMetric? processedMetric = metric; + if (beforeSendMetric != null) { + try { + processedMetric = await beforeSendMetric(metric); + } catch (exception, stackTrace) { + _options.log( + SentryLevel.error, + 'The beforeSendLog callback threw an exception', + exception: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } + } + } + + // TODO: attributes enricher + + if (processedMetric != null) { + _options.telemetryProcessor.addMetric(processedMetric); + } + } + FutureOr close() { final flush = _options.telemetryProcessor.flush(); if (flush is Future) { diff --git a/packages/dart/lib/src/sentry_envelope.dart b/packages/dart/lib/src/sentry_envelope.dart index 23cef605a3..7b2539d1ba 100644 --- a/packages/dart/lib/src/sentry_envelope.dart +++ b/packages/dart/lib/src/sentry_envelope.dart @@ -129,6 +129,21 @@ class SentryEnvelope { ); } + /// Create a [SentryEnvelope] containing raw metric data payload. + /// This is used by the log batcher to send pre-encoded metric batches. + @internal + factory SentryEnvelope.fromMetricsData( + List> encodedMetrics, + SdkVersion sdkVersion, + ) => + SentryEnvelope( + SentryEnvelopeHeader(null, sdkVersion), + [ + SentryEnvelopeItem.fromMetricsData( + _buildItemsPayload(encodedMetrics), encodedMetrics.length) + ], + ); + /// Stream binary data representation of `Envelope` file encoded. Stream> envelopeStream(SentryOptions options) async* { yield utf8JsonEncoder.convert(header.toJson()); @@ -160,6 +175,20 @@ class SentryEnvelope { } } + /// Builds a payload in the format {"items": [item1, item2, ...]} + static Uint8List _buildItemsPayload(List> encodedItems) { + final builder = BytesBuilder(copy: false); + builder.add(utf8.encode('{"items":[')); + for (int i = 0; i < encodedItems.length; i++) { + if (i > 0) { + builder.add(utf8.encode(',')); + } + builder.add(encodedItems[i]); + } + builder.add(utf8.encode(']}')); + return builder.takeBytes(); + } + /// Add an envelope item containing client report data. void addClientReport(ClientReport? clientReport) { if (clientReport != null) { diff --git a/packages/dart/lib/src/sentry_envelope_item.dart b/packages/dart/lib/src/sentry_envelope_item.dart index f626d97882..e633c6cfcc 100644 --- a/packages/dart/lib/src/sentry_envelope_item.dart +++ b/packages/dart/lib/src/sentry_envelope_item.dart @@ -94,6 +94,22 @@ class SentryEnvelopeItem { ); } + /// Create a [SentryEnvelopeItem] which holds pre-encoded metric data. + /// This is used by the buffer to send pre-encoded metric batches. + @internal + factory SentryEnvelopeItem.fromMetricsData( + List payload, int metricsCount) { + return SentryEnvelopeItem( + SentryEnvelopeItemHeader( + SentryItemType.metric, + itemCount: metricsCount, + contentType: 'application/vnd.sentry.items.trace-metric+json', + ), + () => payload, + originalObject: null, + ); + } + /// Header with info about type and length of data in bytes. final SentryEnvelopeItemHeader header; diff --git a/packages/dart/lib/src/sentry_item_type.dart b/packages/dart/lib/src/sentry_item_type.dart index c712ad8793..3d0fe8d956 100644 --- a/packages/dart/lib/src/sentry_item_type.dart +++ b/packages/dart/lib/src/sentry_item_type.dart @@ -6,5 +6,6 @@ class SentryItemType { static const String profile = 'profile'; static const String statsd = 'statsd'; static const String log = 'log'; + static const String metric = 'trace_metric'; static const String unknown = '__unknown__'; } diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index f0b7472f3e..1ffb1ff25b 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -12,6 +12,8 @@ import 'noop_client.dart'; import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; +import 'telemetry/metric/sentry_metric.dart'; +import 'telemetry/metric/sentry_metrics.dart'; import 'telemetry/processing/processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; @@ -217,6 +219,10 @@ class SentryOptions { /// Can return a modified log or null to drop the log. BeforeSendLogCallback? beforeSendLog; + /// This function is called right before a metric is about to be sent. + /// Can return a modified metric or null to drop the log. + BeforeSendMetricCallback? beforeSendMetric; + /// Sets the release. SDK will try to automatically configure a release out of the box /// See [docs for further information](https://docs.sentry.io/platforms/flutter/configuration/releases/) String? release; @@ -545,6 +551,11 @@ class SentryOptions { /// Disabled by default. bool enableLogs = false; + /// Enable to capture and send metrics to Sentry. + /// + /// Disabled by default. + bool enableMetrics = false; + /// Enables adding the module in [SentryStackFrame.module]. /// This option only has an effect in non-obfuscated builds. /// Enabling this option may change grouping. @@ -552,6 +563,8 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); + late final metrics = SentryMetrics(HubAdapter(), clock); + @internal TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); @@ -688,6 +701,11 @@ typedef BeforeMetricCallback = bool Function( /// Can return a modified log or null to drop the log. typedef BeforeSendLogCallback = FutureOr Function(SentryLog log); +/// This function is called right before a metric is about to be emitted. +/// Can return true to emit the metric, or false to drop it. +typedef BeforeSendMetricCallback = FutureOr Function( + SentryMetric metric); + /// Used to provide timestamp for logging. typedef ClockProvider = DateTime Function(); diff --git a/packages/dart/lib/src/telemetry/metric/metric_type.dart b/packages/dart/lib/src/telemetry/metric/metric_type.dart new file mode 100644 index 0000000000..2e7f86f2bf --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/metric_type.dart @@ -0,0 +1,14 @@ +/// The type of metric being recorded +enum SentryMetricType { + /// A metric that increments counts + counter('counter'), + + /// A metric that tracks a value that can go up or down + gauge('gauge'), + + /// A metric that tracks statistical distribution of values + distribution('distribution'); + + final String value; + const SentryMetricType(this.value); +} diff --git a/packages/dart/lib/src/telemetry/metric/sentry_metric.dart b/packages/dart/lib/src/telemetry/metric/sentry_metric.dart new file mode 100644 index 0000000000..ecdbdacc4a --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/sentry_metric.dart @@ -0,0 +1,81 @@ +import 'package:meta/meta.dart'; + +import '../../../sentry.dart'; +import 'metric_type.dart'; + +/// Base sealed class for all Sentry metrics +sealed class SentryMetric { + final DateTime timestamp; + final SentryMetricType type; + final String name; + final num value; + final SentryId traceId; + final SpanId? spanId; + final String? unit; + final Map attributes; + + const SentryMetric({ + required this.timestamp, + required this.type, + required this.name, + required this.value, + required this.traceId, + this.spanId, + this.unit, + this.attributes = const {}, + }); + + @internal + Map toJson() { + return { + 'timestamp': timestamp.millisecondsSinceEpoch / 1000.0, + 'type': type.value, + 'name': name, + 'value': value, + 'trace_id': traceId, + if (spanId != null) 'span_id': spanId, + if (unit != null) 'unit': unit, + if (attributes.isNotEmpty) + 'attributes': attributes.map((k, v) => MapEntry(k, v.toJson())), + }; + } +} + +/// Counter metric - increments counts (only increases) +final class SentryCounterMetric extends SentryMetric { + const SentryCounterMetric({ + required super.timestamp, + required super.name, + required super.value, + required super.traceId, + super.spanId, + super.unit, + super.attributes, + }) : super(type: SentryMetricType.counter); +} + +/// Gauge metric - tracks values that can go up or down +final class SentryGaugeMetric extends SentryMetric { + const SentryGaugeMetric({ + required super.timestamp, + required super.name, + required super.value, + required super.traceId, + super.spanId, + super.unit, + super.attributes, + }) : super(type: SentryMetricType.gauge); +} + +/// Distribution metric - tracks statistical distribution of values +final class SentryDistributionMetric extends SentryMetric { + const SentryDistributionMetric({ + required super.timestamp, + required super.name, + required super.value, + required super.traceId, + super.spanId, + super.unit, + super.attributes, + }) : super(type: SentryMetricType.distribution); +} diff --git a/packages/dart/lib/src/telemetry/metric/sentry_metrics.dart b/packages/dart/lib/src/telemetry/metric/sentry_metrics.dart new file mode 100644 index 0000000000..924539aa51 --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/sentry_metrics.dart @@ -0,0 +1,79 @@ +import '../../../sentry.dart'; +import 'sentry_metric.dart'; + +/// Public API for recording metrics +final class SentryMetrics { + final Hub _hub; + final ClockProvider _clockProvider; + + SentryMetrics(this._hub, this._clockProvider); + + /// Records a counter metric + void count( + String name, + int value, { + Map? attributes, + Scope? scope, + }) { + if (!_isEnabled) return; + + final metric = SentryCounterMetric( + timestamp: _clockProvider(), + name: name, + value: value, + spanId: _hub.scope.span?.context.spanId, + traceId: _traceIdFromScope(scope), + attributes: attributes ?? {}); + + _hub.captureMetric(metric); + } + + /// Records a gauge metric + void gauge( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }) { + if (!_isEnabled) return; + + final metric = SentryGaugeMetric( + timestamp: _clockProvider(), + name: name, + value: value, + spanId: _hub.scope.span?.context.spanId, + traceId: _traceIdFromScope(scope), + attributes: attributes ?? {}); + + _hub.captureMetric(metric); + } + + /// Records a distribution metric + void distribution( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }) { + if (!_isEnabled) return; + + final metric = SentryDistributionMetric( + timestamp: _clockProvider(), + name: name, + value: value, + unit: unit, + spanId: _hub.scope.span?.context.spanId, + traceId: _traceIdFromScope(scope), + attributes: attributes ?? {}); + + _hub.captureMetric(metric); + } + + bool get _isEnabled => _hub.options.enableMetrics; + + SentryId _traceIdFromScope(Scope? scope) => + scope?.propagationContext.traceId ?? + _hub.scope.propagationContext.traceId; +} diff --git a/packages/dart/lib/src/telemetry/processing/processor.dart b/packages/dart/lib/src/telemetry/processing/processor.dart index 2e4268bdb2..81b04694fb 100644 --- a/packages/dart/lib/src/telemetry/processing/processor.dart +++ b/packages/dart/lib/src/telemetry/processing/processor.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../../../sentry.dart'; +import '../../utils/internal_logger.dart'; +import '../metric/sentry_metric.dart'; import 'buffer.dart'; /// Interface for processing and buffering telemetry data before sending. @@ -13,6 +15,9 @@ abstract class TelemetryProcessor { /// Adds a log to be processed and buffered. void addLog(SentryLog log); + /// Adds a metric to be processed and buffered. + void addMetric(SentryMetric log); + /// Flushes all buffered telemetry data. /// /// Returns a [Future] if any buffer performs async flushing, otherwise @@ -26,22 +31,23 @@ abstract class TelemetryProcessor { /// instances. If no buffer is registered for a telemetry type, items are /// dropped with a warning. class DefaultTelemetryProcessor implements TelemetryProcessor { - final SdkLogCallback _logger; - /// The buffer for log data, or `null` if log buffering is disabled. @visibleForTesting TelemetryBuffer? logBuffer; - DefaultTelemetryProcessor( - this._logger, { + /// The buffer for metric data, or `null` if metric buffering is disabled. + @visibleForTesting + TelemetryBuffer? metricBuffer; + + DefaultTelemetryProcessor({ this.logBuffer, + this.metricBuffer, }); @override void addLog(SentryLog log) { if (logBuffer == null) { - _logger( - SentryLevel.warning, + internalLogger.warning( '$runtimeType: No buffer registered for ${log.runtimeType} - item was dropped', ); return; @@ -50,17 +56,33 @@ class DefaultTelemetryProcessor implements TelemetryProcessor { logBuffer!.add(log); } + @override + void addMetric(SentryMetric metric) { + print('in here'); + if (metricBuffer == null) { + internalLogger.warning( + '$runtimeType: No buffer registered for ${metric.runtimeType} - item was dropped', + ); + return; + } + + print('metricjson: ${metric.toJson()}'); + + metricBuffer!.add(metric); + } + @override FutureOr flush() { - _logger(SentryLevel.debug, '$runtimeType: Clearing buffers'); + internalLogger.debug('$runtimeType: Clearing buffers'); - final result = logBuffer?.flush(); + final results = [logBuffer?.flush(), metricBuffer?.flush()]; - if (result is Future) { - return result; + final futures = results.whereType().toList(); + if (futures.isEmpty) { + return null; } - return null; + return Future.wait(futures).then((_) {}); } } @@ -68,6 +90,9 @@ class NoOpTelemetryProcessor implements TelemetryProcessor { @override void addLog(SentryLog log) {} + @override + void addMetric(SentryMetric log) {} + @override FutureOr flush() {} } diff --git a/packages/dart/lib/src/telemetry/processing/processor_integration.dart b/packages/dart/lib/src/telemetry/processing/processor_integration.dart index c3581dd505..d07e2a6a9f 100644 --- a/packages/dart/lib/src/telemetry/processing/processor_integration.dart +++ b/packages/dart/lib/src/telemetry/processing/processor_integration.dart @@ -1,4 +1,5 @@ import '../../../sentry.dart'; +import '../metric/sentry_metric.dart'; import 'in_memory_buffer.dart'; import 'processor.dart'; @@ -15,8 +16,9 @@ class DefaultTelemetryProcessorIntegration extends Integration { return; } - options.telemetryProcessor = DefaultTelemetryProcessor(options.log, - logBuffer: _createLogBuffer(options)); + options.telemetryProcessor = DefaultTelemetryProcessor( + logBuffer: _createLogBuffer(options), + metricBuffer: _createMetricBuffer(options)); options.sdk.addIntegration(integrationName); } @@ -29,4 +31,15 @@ class DefaultTelemetryProcessorIntegration extends Integration { items.map((item) => item).toList(), options.sdk); return options.transport.send(envelope).then((_) {}); }); + + InMemoryTelemetryBuffer _createMetricBuffer( + SentryOptions options) => + InMemoryTelemetryBuffer( + encoder: (SentryMetric item) => + utf8JsonEncoder.convert(item.toJson()), + onFlush: (items) { + final envelope = SentryEnvelope.fromMetricsData( + items.map((item) => item).toList(), options.sdk); + return options.transport.send(envelope).then((_) {}); + }); } diff --git a/packages/dart/test/mocks/mock_telemetry_processor.dart b/packages/dart/test/mocks/mock_telemetry_processor.dart index a52fd97a2f..e2179bf143 100644 --- a/packages/dart/test/mocks/mock_telemetry_processor.dart +++ b/packages/dart/test/mocks/mock_telemetry_processor.dart @@ -1,8 +1,10 @@ import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/sentry_metric.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; class MockTelemetryProcessor implements TelemetryProcessor { final List addedLogs = []; + final List addedMetrics = []; int flushCalls = 0; int closeCalls = 0; @@ -11,6 +13,11 @@ class MockTelemetryProcessor implements TelemetryProcessor { addedLogs.add(log); } + @override + void addMetric(SentryMetric metric) { + addedMetrics.add(metric); + } + @override void flush() { flushCalls++; diff --git a/packages/dart/test/telemetry/processing/processor_integration_test.dart b/packages/dart/test/telemetry/processing/processor_integration_test.dart index 009a6c3613..0d13db5dec 100644 --- a/packages/dart/test/telemetry/processing/processor_integration_test.dart +++ b/packages/dart/test/telemetry/processing/processor_integration_test.dart @@ -29,7 +29,7 @@ void main() { test('does not override existing telemetry processor', () { final options = fixture.options; - final existingProcessor = DefaultTelemetryProcessor(options.log); + final existingProcessor = DefaultTelemetryProcessor(); options.telemetryProcessor = existingProcessor; fixture.getSut().call(fixture.hub, options); diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart index 5429793e3f..936ef6c385 100644 --- a/packages/dart/test/telemetry/processing/processor_test.dart +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/sentry_metric.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; import 'package:test/test.dart'; @@ -37,23 +38,52 @@ void main() { }); }); + group('addMetric', () { + test('routes metric to metric buffer', () { + final mockMetricBuffer = MockTelemetryBuffer(); + final processor = + fixture.getSut(enableMetrics: true, metricBuffer: mockMetricBuffer); + + final metric = fixture.createMetric(); + processor.addMetric(metric); + + expect(mockMetricBuffer.addedItems.length, 1); + expect(mockMetricBuffer.addedItems.first, metric); + }); + + test('does not throw when no metric buffer registered', () { + final processor = fixture.getSut(); + processor.logBuffer = null; + + final log = fixture.createLog(); + processor.addLog(log); + }); + }); + group('flush', () { test('flushes all registered buffers', () async { final mockLogBuffer = MockTelemetryBuffer(); + final mockMetricBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut( enableLogs: true, logBuffer: mockLogBuffer, + metricBuffer: mockMetricBuffer, ); await processor.flush(); expect(mockLogBuffer.flushCallCount, 1); + expect(mockMetricBuffer.flushCallCount, 1); }); test('returns sync (null) when all buffers flush synchronously', () { final mockLogBuffer = MockTelemetryBuffer(asyncFlush: false); - final processor = fixture.getSut(logBuffer: mockLogBuffer); - processor.logBuffer = null; + final mockMetricBuffer = + MockTelemetryBuffer(asyncFlush: false); + + final processor = fixture.getSut( + logBuffer: mockLogBuffer, metricBuffer: mockMetricBuffer); final result = processor.flush(); @@ -63,7 +93,10 @@ void main() { test('returns Future when at least one buffer flushes asynchronously', () async { final mockLogBuffer = MockTelemetryBuffer(asyncFlush: true); - final processor = fixture.getSut(logBuffer: mockLogBuffer); + final mockMetricBuffer = + MockTelemetryBuffer(asyncFlush: false); + final processor = fixture.getSut( + logBuffer: mockLogBuffer, metricBuffer: mockMetricBuffer); final result = processor.flush(); @@ -83,13 +116,14 @@ class Fixture { DefaultTelemetryProcessor getSut({ bool enableLogs = false, + bool enableMetrics = false, MockTelemetryBuffer? logBuffer, + MockTelemetryBuffer? metricBuffer, }) { options.enableLogs = enableLogs; + options.enableMetrics = enableMetrics; return DefaultTelemetryProcessor( - options.log, - logBuffer: logBuffer, - ); + logBuffer: logBuffer, metricBuffer: metricBuffer); } SentryLog createLog({String body = 'test log'}) { @@ -100,4 +134,12 @@ class Fixture { attributes: {}, ); } + + SentryMetric createMetric() => SentryCounterMetric( + timestamp: DateTime.now().toUtc(), + attributes: {}, + name: 'test-metric', + value: 1, + traceId: SentryId.newId(), + ); } From 1081ca32e0830c35bdf20f010256e21a305dc7f6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 15 Jan 2026 00:51:40 +0100 Subject: [PATCH 12/42] Update --- packages/dart/lib/src/hub.dart | 2 +- packages/dart/lib/src/hub_adapter.dart | 2 +- packages/dart/lib/src/noop_hub.dart | 2 +- packages/dart/lib/src/sentry.dart | 4 +- packages/dart/lib/src/sentry_client.dart | 2 +- packages/dart/lib/src/sentry_options.dart | 7 +- .../{sentry_metric.dart => metric.dart} | 29 ++-- .../lib/src/telemetry/metric/metrics.dart | 139 ++++++++++++++++++ .../metric/metrics_setup_integration.dart | 17 +++ .../src/telemetry/metric/sentry_metrics.dart | 79 ---------- .../src/telemetry/processing/processor.dart | 5 +- .../processing/processor_integration.dart | 2 +- .../test/mocks/mock_telemetry_processor.dart | 2 +- .../telemetry/processing/processor_test.dart | 2 +- 14 files changed, 186 insertions(+), 108 deletions(-) rename packages/dart/lib/src/telemetry/metric/{sentry_metric.dart => metric.dart} (81%) create mode 100644 packages/dart/lib/src/telemetry/metric/metrics.dart create mode 100644 packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart delete mode 100644 packages/dart/lib/src/telemetry/metric/sentry_metrics.dart diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 77a2eea307..c980419d68 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -9,7 +9,7 @@ import 'client_reports/discard_reason.dart'; import 'profiling.dart'; import 'sentry_tracer.dart'; import 'sentry_traces_sampler.dart'; -import 'telemetry/metric/sentry_metric.dart'; +import 'telemetry/metric/metric.dart'; import 'transport/data_category.dart'; /// Configures the scope through the callback. diff --git a/packages/dart/lib/src/hub_adapter.dart b/packages/dart/lib/src/hub_adapter.dart index ae5a369dc6..73ee33c4cf 100644 --- a/packages/dart/lib/src/hub_adapter.dart +++ b/packages/dart/lib/src/hub_adapter.dart @@ -11,7 +11,7 @@ import 'scope.dart'; import 'sentry.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; -import 'telemetry/metric/sentry_metric.dart'; +import 'telemetry/metric/metric.dart'; import 'tracing.dart'; /// Hub adapter to make Integrations testable diff --git a/packages/dart/lib/src/noop_hub.dart b/packages/dart/lib/src/noop_hub.dart index f23f054048..81855f0bef 100644 --- a/packages/dart/lib/src/noop_hub.dart +++ b/packages/dart/lib/src/noop_hub.dart @@ -10,7 +10,7 @@ import 'protocol/sentry_feedback.dart'; import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; -import 'telemetry/metric/sentry_metric.dart'; +import 'telemetry/metric/metric.dart'; import 'tracing.dart'; class NoOpHub implements Hub { diff --git a/packages/dart/lib/src/sentry.dart b/packages/dart/lib/src/sentry.dart index 1c0d65af38..68f6db0b37 100644 --- a/packages/dart/lib/src/sentry.dart +++ b/packages/dart/lib/src/sentry.dart @@ -23,7 +23,8 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; import 'sentry_run_zoned_guarded.dart'; -import 'telemetry/metric/sentry_metrics.dart'; +import 'telemetry/metric/metrics.dart'; +import 'telemetry/metric/metrics_setup_integration.dart'; import 'telemetry/processing/processor_integration.dart'; import 'tracing.dart'; import 'transport/data_category.dart'; @@ -111,6 +112,7 @@ class Sentry { options.addIntegration(LoadDartDebugImagesIntegration()); } + options.addIntegration(MetricsSetupIntegration()); options.addIntegration(FeatureFlagsIntegration()); options.addIntegration(LogsEnricherIntegration()); options.addIntegration(DefaultTelemetryProcessorIntegration()); diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index de0940c4d7..3ba406c5d7 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -18,7 +18,7 @@ import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'sentry_trace_context_header.dart'; -import 'telemetry/metric/sentry_metric.dart'; +import 'telemetry/metric/metric.dart'; import 'transport/client_report_transport.dart'; import 'transport/data_category.dart'; import 'transport/http_transport.dart'; diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 1ffb1ff25b..b682c129e5 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -12,8 +12,8 @@ import 'noop_client.dart'; import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; -import 'telemetry/metric/sentry_metric.dart'; -import 'telemetry/metric/sentry_metrics.dart'; +import 'telemetry/metric/metric.dart'; +import 'telemetry/metric/metrics.dart'; import 'telemetry/processing/processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; @@ -563,7 +563,8 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); - late final metrics = SentryMetrics(HubAdapter(), clock); + @internal + SentryMetrics metrics = NoOpSentryMetrics.instance; @internal TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); diff --git a/packages/dart/lib/src/telemetry/metric/sentry_metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart similarity index 81% rename from packages/dart/lib/src/telemetry/metric/sentry_metric.dart rename to packages/dart/lib/src/telemetry/metric/metric.dart index ecdbdacc4a..39ecef6597 100644 --- a/packages/dart/lib/src/telemetry/metric/sentry_metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -5,16 +5,17 @@ import 'metric_type.dart'; /// Base sealed class for all Sentry metrics sealed class SentryMetric { - final DateTime timestamp; final SentryMetricType type; - final String name; - final num value; - final SentryId traceId; - final SpanId? spanId; - final String? unit; - final Map attributes; - const SentryMetric({ + DateTime timestamp; + String name; + num value; + SentryId traceId; + SpanId? spanId; + String? unit; + Map attributes; + + SentryMetric({ required this.timestamp, required this.type, required this.name, @@ -22,8 +23,8 @@ sealed class SentryMetric { required this.traceId, this.spanId, this.unit, - this.attributes = const {}, - }); + Map? attributes, + }) : attributes = attributes ?? {}; @internal Map toJson() { @@ -41,9 +42,9 @@ sealed class SentryMetric { } } -/// Counter metric - increments counts (only increases) +/// Counter metric - increments counts final class SentryCounterMetric extends SentryMetric { - const SentryCounterMetric({ + SentryCounterMetric({ required super.timestamp, required super.name, required super.value, @@ -56,7 +57,7 @@ final class SentryCounterMetric extends SentryMetric { /// Gauge metric - tracks values that can go up or down final class SentryGaugeMetric extends SentryMetric { - const SentryGaugeMetric({ + SentryGaugeMetric({ required super.timestamp, required super.name, required super.value, @@ -69,7 +70,7 @@ final class SentryGaugeMetric extends SentryMetric { /// Distribution metric - tracks statistical distribution of values final class SentryDistributionMetric extends SentryMetric { - const SentryDistributionMetric({ + SentryDistributionMetric({ required super.timestamp, required super.name, required super.value, diff --git a/packages/dart/lib/src/telemetry/metric/metrics.dart b/packages/dart/lib/src/telemetry/metric/metrics.dart new file mode 100644 index 0000000000..6788e6c713 --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/metrics.dart @@ -0,0 +1,139 @@ +import 'package:meta/meta.dart'; + +import '../../../sentry.dart'; +import 'metric.dart'; + +/// Public API for recording metrics +abstract base class SentryMetrics { + void count( + String name, + int value, { + Map? attributes, + Scope? scope, + }); + + void gauge( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }); + + void distribution( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }); +} + +typedef CaptureMetricCallback = Future Function(SentryMetric metric); +typedef ScopeProvider = Scope Function(); + +@internal +final class DefaultSentryMetrics implements SentryMetrics { + final bool _isMetricsEnabled; + final CaptureMetricCallback _captureMetricCallback; + final ClockProvider _clockProvider; + final ScopeProvider _defaultScopeProvider; + + DefaultSentryMetrics( + {required bool isMetricsEnabled, + required CaptureMetricCallback captureMetricCallback, + required ClockProvider clockProvider, + required ScopeProvider defaultScopeProvider}) + : _isMetricsEnabled = isMetricsEnabled, + _captureMetricCallback = captureMetricCallback, + _clockProvider = clockProvider, + _defaultScopeProvider = defaultScopeProvider; + + @override + void count( + String name, + int value, { + Map? attributes, + Scope? scope, + }) { + if (!_isMetricsEnabled) return; + + final metric = SentryCounterMetric( + timestamp: _clockProvider(), + name: name, + value: value, + spanId: _activeSpanIdFor(scope), + traceId: _traceIdFor(scope), + attributes: attributes ?? {}); + + _captureMetricCallback(metric); + } + + @override + void gauge( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }) { + if (!_isMetricsEnabled) return; + + final metric = SentryGaugeMetric( + timestamp: _clockProvider(), + name: name, + value: value, + spanId: _activeSpanIdFor(scope), + traceId: _traceIdFor(scope), + attributes: attributes ?? {}); + + _captureMetricCallback(metric); + } + + @override + void distribution( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }) { + if (!_isMetricsEnabled) return; + + final metric = SentryDistributionMetric( + timestamp: _clockProvider(), + name: name, + value: value, + unit: unit, + spanId: _activeSpanIdFor(scope), + traceId: _traceIdFor(scope), + attributes: attributes ?? {}); + + _captureMetricCallback(metric); + } + + SentryId _traceIdFor(Scope? scope) => + (scope ?? _defaultScopeProvider()).propagationContext.traceId; + + SpanId? _activeSpanIdFor(Scope? scope) => + (scope ?? _defaultScopeProvider()).span?.context.spanId; +} + +@internal +final class NoOpSentryMetrics implements SentryMetrics { + const NoOpSentryMetrics(); + + static const instance = NoOpSentryMetrics(); + + @override + void count(String name, int value, + {Map? attributes, Scope? scope}) {} + + @override + void distribution(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} + + @override + void gauge(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} +} diff --git a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart new file mode 100644 index 0000000000..f87628a6fd --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart @@ -0,0 +1,17 @@ +import '../../../sentry.dart'; +import 'metrics.dart'; + +class MetricsSetupIntegration extends Integration { + static const integrationName = 'MetricsSetup'; + + @override + void call(Hub hub, SentryOptions options) { + options.metrics = DefaultSentryMetrics( + isMetricsEnabled: options.enableMetrics, + captureMetricCallback: hub.captureMetric, + clockProvider: options.clock, + defaultScopeProvider: () => hub.scope); + + options.sdk.addIntegration(integrationName); + } +} diff --git a/packages/dart/lib/src/telemetry/metric/sentry_metrics.dart b/packages/dart/lib/src/telemetry/metric/sentry_metrics.dart deleted file mode 100644 index 924539aa51..0000000000 --- a/packages/dart/lib/src/telemetry/metric/sentry_metrics.dart +++ /dev/null @@ -1,79 +0,0 @@ -import '../../../sentry.dart'; -import 'sentry_metric.dart'; - -/// Public API for recording metrics -final class SentryMetrics { - final Hub _hub; - final ClockProvider _clockProvider; - - SentryMetrics(this._hub, this._clockProvider); - - /// Records a counter metric - void count( - String name, - int value, { - Map? attributes, - Scope? scope, - }) { - if (!_isEnabled) return; - - final metric = SentryCounterMetric( - timestamp: _clockProvider(), - name: name, - value: value, - spanId: _hub.scope.span?.context.spanId, - traceId: _traceIdFromScope(scope), - attributes: attributes ?? {}); - - _hub.captureMetric(metric); - } - - /// Records a gauge metric - void gauge( - String name, - num value, { - String? unit, - Map? attributes, - Scope? scope, - }) { - if (!_isEnabled) return; - - final metric = SentryGaugeMetric( - timestamp: _clockProvider(), - name: name, - value: value, - spanId: _hub.scope.span?.context.spanId, - traceId: _traceIdFromScope(scope), - attributes: attributes ?? {}); - - _hub.captureMetric(metric); - } - - /// Records a distribution metric - void distribution( - String name, - num value, { - String? unit, - Map? attributes, - Scope? scope, - }) { - if (!_isEnabled) return; - - final metric = SentryDistributionMetric( - timestamp: _clockProvider(), - name: name, - value: value, - unit: unit, - spanId: _hub.scope.span?.context.spanId, - traceId: _traceIdFromScope(scope), - attributes: attributes ?? {}); - - _hub.captureMetric(metric); - } - - bool get _isEnabled => _hub.options.enableMetrics; - - SentryId _traceIdFromScope(Scope? scope) => - scope?.propagationContext.traceId ?? - _hub.scope.propagationContext.traceId; -} diff --git a/packages/dart/lib/src/telemetry/processing/processor.dart b/packages/dart/lib/src/telemetry/processing/processor.dart index 81b04694fb..d0798f75b4 100644 --- a/packages/dart/lib/src/telemetry/processing/processor.dart +++ b/packages/dart/lib/src/telemetry/processing/processor.dart @@ -4,7 +4,7 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; import '../../utils/internal_logger.dart'; -import '../metric/sentry_metric.dart'; +import '../metric/metric.dart'; import 'buffer.dart'; /// Interface for processing and buffering telemetry data before sending. @@ -58,7 +58,6 @@ class DefaultTelemetryProcessor implements TelemetryProcessor { @override void addMetric(SentryMetric metric) { - print('in here'); if (metricBuffer == null) { internalLogger.warning( '$runtimeType: No buffer registered for ${metric.runtimeType} - item was dropped', @@ -66,8 +65,6 @@ class DefaultTelemetryProcessor implements TelemetryProcessor { return; } - print('metricjson: ${metric.toJson()}'); - metricBuffer!.add(metric); } diff --git a/packages/dart/lib/src/telemetry/processing/processor_integration.dart b/packages/dart/lib/src/telemetry/processing/processor_integration.dart index d07e2a6a9f..4bfeb0ae54 100644 --- a/packages/dart/lib/src/telemetry/processing/processor_integration.dart +++ b/packages/dart/lib/src/telemetry/processing/processor_integration.dart @@ -1,5 +1,5 @@ import '../../../sentry.dart'; -import '../metric/sentry_metric.dart'; +import '../metric/metric.dart'; import 'in_memory_buffer.dart'; import 'processor.dart'; diff --git a/packages/dart/test/mocks/mock_telemetry_processor.dart b/packages/dart/test/mocks/mock_telemetry_processor.dart index e2179bf143..450f03d0f3 100644 --- a/packages/dart/test/mocks/mock_telemetry_processor.dart +++ b/packages/dart/test/mocks/mock_telemetry_processor.dart @@ -1,5 +1,5 @@ import 'package:sentry/sentry.dart'; -import 'package:sentry/src/telemetry/metric/sentry_metric.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; class MockTelemetryProcessor implements TelemetryProcessor { diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart index 936ef6c385..e754610dc5 100644 --- a/packages/dart/test/telemetry/processing/processor_test.dart +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; -import 'package:sentry/src/telemetry/metric/sentry_metric.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; import 'package:test/test.dart'; From ffd9fc77b8b7041eafc7dc79b50cc830cfed13da Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 15 Jan 2026 11:17:38 +0100 Subject: [PATCH 13/42] Update --- .../dart/lib/src/telemetry/metric/metric.dart | 16 ++++- .../lib/src/telemetry/metric/metric_type.dart | 14 ----- .../lib/src/telemetry/metric/metrics.dart | 63 ++++++------------- .../metric/metrics_setup_integration.dart | 6 +- 4 files changed, 39 insertions(+), 60 deletions(-) delete mode 100644 packages/dart/lib/src/telemetry/metric/metric_type.dart diff --git a/packages/dart/lib/src/telemetry/metric/metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart index 39ecef6597..ef6735cb9a 100644 --- a/packages/dart/lib/src/telemetry/metric/metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -1,7 +1,21 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; -import 'metric_type.dart'; + +/// The type of metric being recorded +enum SentryMetricType { + /// A metric that increments counts + counter('counter'), + + /// A metric that tracks a value that can go up or down + gauge('gauge'), + + /// A metric that tracks statistical distribution of values + distribution('distribution'); + + final String value; + const SentryMetricType(this.value); +} /// Base sealed class for all Sentry metrics sealed class SentryMetric { diff --git a/packages/dart/lib/src/telemetry/metric/metric_type.dart b/packages/dart/lib/src/telemetry/metric/metric_type.dart deleted file mode 100644 index 2e7f86f2bf..0000000000 --- a/packages/dart/lib/src/telemetry/metric/metric_type.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// The type of metric being recorded -enum SentryMetricType { - /// A metric that increments counts - counter('counter'), - - /// A metric that tracks a value that can go up or down - gauge('gauge'), - - /// A metric that tracks statistical distribution of values - distribution('distribution'); - - final String value; - const SentryMetricType(this.value); -} diff --git a/packages/dart/lib/src/telemetry/metric/metrics.dart b/packages/dart/lib/src/telemetry/metric/metrics.dart index 6788e6c713..7ba78adae9 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics.dart @@ -3,61 +3,29 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; import 'metric.dart'; -/// Public API for recording metrics -abstract base class SentryMetrics { - void count( - String name, - int value, { - Map? attributes, - Scope? scope, - }); - - void gauge( - String name, - num value, { - String? unit, - Map? attributes, - Scope? scope, - }); - - void distribution( - String name, - num value, { - String? unit, - Map? attributes, - Scope? scope, - }); -} - typedef CaptureMetricCallback = Future Function(SentryMetric metric); typedef ScopeProvider = Scope Function(); @internal -final class DefaultSentryMetrics implements SentryMetrics { - final bool _isMetricsEnabled; +final class SentryMetrics { final CaptureMetricCallback _captureMetricCallback; final ClockProvider _clockProvider; final ScopeProvider _defaultScopeProvider; - DefaultSentryMetrics( - {required bool isMetricsEnabled, - required CaptureMetricCallback captureMetricCallback, + SentryMetrics( + {required CaptureMetricCallback captureMetricCallback, required ClockProvider clockProvider, required ScopeProvider defaultScopeProvider}) - : _isMetricsEnabled = isMetricsEnabled, - _captureMetricCallback = captureMetricCallback, + : _captureMetricCallback = captureMetricCallback, _clockProvider = clockProvider, _defaultScopeProvider = defaultScopeProvider; - @override void count( String name, int value, { Map? attributes, Scope? scope, }) { - if (!_isMetricsEnabled) return; - final metric = SentryCounterMetric( timestamp: _clockProvider(), name: name, @@ -69,7 +37,6 @@ final class DefaultSentryMetrics implements SentryMetrics { _captureMetricCallback(metric); } - @override void gauge( String name, num value, { @@ -77,8 +44,6 @@ final class DefaultSentryMetrics implements SentryMetrics { Map? attributes, Scope? scope, }) { - if (!_isMetricsEnabled) return; - final metric = SentryGaugeMetric( timestamp: _clockProvider(), name: name, @@ -90,7 +55,6 @@ final class DefaultSentryMetrics implements SentryMetrics { _captureMetricCallback(metric); } - @override void distribution( String name, num value, { @@ -98,8 +62,6 @@ final class DefaultSentryMetrics implements SentryMetrics { Map? attributes, Scope? scope, }) { - if (!_isMetricsEnabled) return; - final metric = SentryDistributionMetric( timestamp: _clockProvider(), name: name, @@ -119,7 +81,6 @@ final class DefaultSentryMetrics implements SentryMetrics { (scope ?? _defaultScopeProvider()).span?.context.spanId; } -@internal final class NoOpSentryMetrics implements SentryMetrics { const NoOpSentryMetrics(); @@ -136,4 +97,20 @@ final class NoOpSentryMetrics implements SentryMetrics { @override void gauge(String name, num value, {String? unit, Map? attributes, Scope? scope}) {} + + @override + SpanId? _activeSpanIdFor(Scope? scope) => null; + + @override + CaptureMetricCallback get _captureMetricCallback => (_) async {}; + + @override + ClockProvider get _clockProvider => + () => DateTime.fromMillisecondsSinceEpoch(0); + + @override + ScopeProvider get _defaultScopeProvider => () => Scope(SentryOptions()); + + @override + SentryId _traceIdFor(Scope? scope) => SentryId.empty(); } diff --git a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart index f87628a6fd..5787f17cef 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart @@ -6,8 +6,10 @@ class MetricsSetupIntegration extends Integration { @override void call(Hub hub, SentryOptions options) { - options.metrics = DefaultSentryMetrics( - isMetricsEnabled: options.enableMetrics, + if (!options.enableMetrics) return; + if (options.metrics is! NoOpSentryMetrics) return; + + options.metrics = SentryMetrics( captureMetricCallback: hub.captureMetric, clockProvider: options.clock, defaultScopeProvider: () => hub.scope); From b56a272fc21fffedc0da1aadd7e71ebef5024e92 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 15 Jan 2026 11:21:58 +0100 Subject: [PATCH 14/42] Update --- packages/dart/lib/src/telemetry/processing/processor.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/telemetry/processing/processor.dart b/packages/dart/lib/src/telemetry/processing/processor.dart index d0798f75b4..71e2a1cb5a 100644 --- a/packages/dart/lib/src/telemetry/processing/processor.dart +++ b/packages/dart/lib/src/telemetry/processing/processor.dart @@ -16,7 +16,7 @@ abstract class TelemetryProcessor { void addLog(SentryLog log); /// Adds a metric to be processed and buffered. - void addMetric(SentryMetric log); + void addMetric(SentryMetric metric); /// Flushes all buffered telemetry data. /// From 586ae3d402578b2251a17ffd92a126aa85ae9a35 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 15 Jan 2026 11:35:57 +0100 Subject: [PATCH 15/42] Update --- packages/dart/lib/src/noop_sentry_client.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/dart/lib/src/noop_sentry_client.dart b/packages/dart/lib/src/noop_sentry_client.dart index 05e526e393..a889df5d20 100644 --- a/packages/dart/lib/src/noop_sentry_client.dart +++ b/packages/dart/lib/src/noop_sentry_client.dart @@ -7,6 +7,7 @@ import 'scope.dart'; import 'sentry_client.dart'; import 'sentry_envelope.dart'; import 'sentry_trace_context_header.dart'; +import 'telemetry/metric/metric.dart'; class NoOpSentryClient implements SentryClient { NoOpSentryClient._(); @@ -69,4 +70,7 @@ class NoOpSentryClient implements SentryClient { @override FutureOr captureLog(SentryLog log, {Scope? scope}) async {} + + @override + Future captureMetric(SentryMetric metric, {Scope? scope}) async {} } From 33f991f07969c23319e1a19a249e0c67b93089b5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Sat, 17 Jan 2026 04:24:03 +0100 Subject: [PATCH 16/42] feat: integrate telemetry metrics into Sentry options and core functionality - Added imports for telemetry metrics in `sentry_options.dart` and `sentry.dart`. - Updated `SentryMetric` class to use string types for metric types instead of an enum. - Adjusted metric constructors to reflect the new string-based type system. - Modified tests to accommodate changes in metric handling. --- packages/dart/lib/src/sentry.dart | 2 ++ packages/dart/lib/src/sentry_options.dart | 2 ++ .../dart/lib/src/telemetry/metric/metric.dart | 27 +++++-------------- .../telemetry/processing/processor_test.dart | 6 ++--- 4 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/dart/lib/src/sentry.dart b/packages/dart/lib/src/sentry.dart index 7079c33c08..3f9f69f6a1 100644 --- a/packages/dart/lib/src/sentry.dart +++ b/packages/dart/lib/src/sentry.dart @@ -23,6 +23,8 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; import 'sentry_run_zoned_guarded.dart'; +import 'telemetry/metric/metrics.dart'; +import 'telemetry/metric/metrics_setup_integration.dart'; import 'telemetry/processing/processor_integration.dart'; import 'tracing.dart'; import 'transport/data_category.dart'; diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 125ea4a4f6..b682c129e5 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -12,6 +12,8 @@ import 'noop_client.dart'; import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; +import 'telemetry/metric/metric.dart'; +import 'telemetry/metric/metrics.dart'; import 'telemetry/processing/processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; diff --git a/packages/dart/lib/src/telemetry/metric/metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart index ef6735cb9a..7f00dc5aee 100644 --- a/packages/dart/lib/src/telemetry/metric/metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -2,24 +2,9 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; -/// The type of metric being recorded -enum SentryMetricType { - /// A metric that increments counts - counter('counter'), - - /// A metric that tracks a value that can go up or down - gauge('gauge'), - - /// A metric that tracks statistical distribution of values - distribution('distribution'); - - final String value; - const SentryMetricType(this.value); -} - -/// Base sealed class for all Sentry metrics +/// The metrics telemetry. sealed class SentryMetric { - final SentryMetricType type; + final String type; DateTime timestamp; String name; @@ -44,7 +29,7 @@ sealed class SentryMetric { Map toJson() { return { 'timestamp': timestamp.millisecondsSinceEpoch / 1000.0, - 'type': type.value, + 'type': type, 'name': name, 'value': value, 'trace_id': traceId, @@ -66,7 +51,7 @@ final class SentryCounterMetric extends SentryMetric { super.spanId, super.unit, super.attributes, - }) : super(type: SentryMetricType.counter); + }) : super(type: 'counter'); } /// Gauge metric - tracks values that can go up or down @@ -79,7 +64,7 @@ final class SentryGaugeMetric extends SentryMetric { super.spanId, super.unit, super.attributes, - }) : super(type: SentryMetricType.gauge); + }) : super(type: 'gauge'); } /// Distribution metric - tracks statistical distribution of values @@ -92,5 +77,5 @@ final class SentryDistributionMetric extends SentryMetric { super.spanId, super.unit, super.attributes, - }) : super(type: SentryMetricType.distribution); + }) : super(type: 'distribution'); } diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart index e754610dc5..dd0d100b70 100644 --- a/packages/dart/test/telemetry/processing/processor_test.dart +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -31,7 +31,6 @@ void main() { test('does not throw when no log buffer registered', () { final processor = fixture.getSut(); - processor.logBuffer = null; final log = fixture.createLog(); processor.addLog(log); @@ -53,10 +52,9 @@ void main() { test('does not throw when no metric buffer registered', () { final processor = fixture.getSut(); - processor.logBuffer = null; - final log = fixture.createLog(); - processor.addLog(log); + final metric = fixture.createMetric(); + processor.addMetric(metric); }); }); From b6bff0dc8a7e4cc90b7b8e90ae397a3e3d231399 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Sat, 17 Jan 2026 04:27:55 +0100 Subject: [PATCH 17/42] feat: implement SentryMetrics with Default and NoOp implementations - Introduced DefaultSentryMetrics and NoOpSentryMetrics classes for metric handling. - Updated MetricsSetupIntegration to utilize DefaultSentryMetrics. - Refactored SentryMetrics interface to abstract metric methods. - Added metrics implementation to the Sentry export in sentry.dart. --- packages/dart/lib/sentry.dart | 1 + packages/dart/lib/src/sentry.dart | 2 +- packages/dart/lib/src/sentry_options.dart | 4 +- .../lib/src/telemetry/metric/metrics.dart | 114 +----------------- .../src/telemetry/metric/metrics_impl.dart | 101 ++++++++++++++++ .../metric/metrics_setup_integration.dart | 3 +- 6 files changed, 111 insertions(+), 114 deletions(-) create mode 100644 packages/dart/lib/src/telemetry/metric/metrics_impl.dart diff --git a/packages/dart/lib/sentry.dart b/packages/dart/lib/sentry.dart index 8d01a3181c..46186a1949 100644 --- a/packages/dart/lib/sentry.dart +++ b/packages/dart/lib/sentry.dart @@ -61,5 +61,6 @@ export 'src/utils/url_details.dart'; // ignore: invalid_export_of_internal_element export 'src/utils/breadcrumb_log_level.dart'; export 'src/sentry_logger.dart'; +export 'src/telemetry/metric/metrics.dart'; // ignore: invalid_export_of_internal_element export 'src/utils/internal_logger.dart' show SentryInternalLogger; diff --git a/packages/dart/lib/src/sentry.dart b/packages/dart/lib/src/sentry.dart index 3f9f69f6a1..8e598c9b4b 100644 --- a/packages/dart/lib/src/sentry.dart +++ b/packages/dart/lib/src/sentry.dart @@ -23,8 +23,8 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_client.dart'; import 'sentry_options.dart'; import 'sentry_run_zoned_guarded.dart'; -import 'telemetry/metric/metrics.dart'; import 'telemetry/metric/metrics_setup_integration.dart'; +import 'telemetry/metric/metrics.dart'; import 'telemetry/processing/processor_integration.dart'; import 'tracing.dart'; import 'transport/data_category.dart'; diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index b682c129e5..54bd628d4b 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -13,14 +13,14 @@ import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; import 'telemetry/metric/metric.dart'; -import 'telemetry/metric/metrics.dart'; +import 'telemetry/metric/metrics_impl.dart'; import 'telemetry/processing/processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; import 'dart:developer' as developer; // TODO: shutdownTimeout, flushTimeoutMillis -// https://api.dart.dev/stable/2.10.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually +// https://api.dart.dev/stable/2.1w0.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually /// Sentry SDK options class SentryOptions { diff --git a/packages/dart/lib/src/telemetry/metric/metrics.dart b/packages/dart/lib/src/telemetry/metric/metrics.dart index 7ba78adae9..ae9a826ff9 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics.dart @@ -1,116 +1,10 @@ -import 'package:meta/meta.dart'; - import '../../../sentry.dart'; -import 'metric.dart'; - -typedef CaptureMetricCallback = Future Function(SentryMetric metric); -typedef ScopeProvider = Scope Function(); - -@internal -final class SentryMetrics { - final CaptureMetricCallback _captureMetricCallback; - final ClockProvider _clockProvider; - final ScopeProvider _defaultScopeProvider; - - SentryMetrics( - {required CaptureMetricCallback captureMetricCallback, - required ClockProvider clockProvider, - required ScopeProvider defaultScopeProvider}) - : _captureMetricCallback = captureMetricCallback, - _clockProvider = clockProvider, - _defaultScopeProvider = defaultScopeProvider; - - void count( - String name, - int value, { - Map? attributes, - Scope? scope, - }) { - final metric = SentryCounterMetric( - timestamp: _clockProvider(), - name: name, - value: value, - spanId: _activeSpanIdFor(scope), - traceId: _traceIdFor(scope), - attributes: attributes ?? {}); - - _captureMetricCallback(metric); - } - - void gauge( - String name, - num value, { - String? unit, - Map? attributes, - Scope? scope, - }) { - final metric = SentryGaugeMetric( - timestamp: _clockProvider(), - name: name, - value: value, - spanId: _activeSpanIdFor(scope), - traceId: _traceIdFor(scope), - attributes: attributes ?? {}); - - _captureMetricCallback(metric); - } - - void distribution( - String name, - num value, { - String? unit, - Map? attributes, - Scope? scope, - }) { - final metric = SentryDistributionMetric( - timestamp: _clockProvider(), - name: name, - value: value, - unit: unit, - spanId: _activeSpanIdFor(scope), - traceId: _traceIdFor(scope), - attributes: attributes ?? {}); - - _captureMetricCallback(metric); - } - - SentryId _traceIdFor(Scope? scope) => - (scope ?? _defaultScopeProvider()).propagationContext.traceId; - SpanId? _activeSpanIdFor(Scope? scope) => - (scope ?? _defaultScopeProvider()).span?.context.spanId; -} - -final class NoOpSentryMetrics implements SentryMetrics { - const NoOpSentryMetrics(); - - static const instance = NoOpSentryMetrics(); - - @override +abstract interface class SentryMetrics { void count(String name, int value, - {Map? attributes, Scope? scope}) {} - - @override + {Map? attributes, Scope? scope}); void distribution(String name, num value, - {String? unit, Map? attributes, Scope? scope}) {} - - @override + {String? unit, Map? attributes, Scope? scope}); void gauge(String name, num value, - {String? unit, Map? attributes, Scope? scope}) {} - - @override - SpanId? _activeSpanIdFor(Scope? scope) => null; - - @override - CaptureMetricCallback get _captureMetricCallback => (_) async {}; - - @override - ClockProvider get _clockProvider => - () => DateTime.fromMillisecondsSinceEpoch(0); - - @override - ScopeProvider get _defaultScopeProvider => () => Scope(SentryOptions()); - - @override - SentryId _traceIdFor(Scope? scope) => SentryId.empty(); + {String? unit, Map? attributes, Scope? scope}); } diff --git a/packages/dart/lib/src/telemetry/metric/metrics_impl.dart b/packages/dart/lib/src/telemetry/metric/metrics_impl.dart new file mode 100644 index 0000000000..a62d216eb9 --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/metrics_impl.dart @@ -0,0 +1,101 @@ +import '../../../sentry.dart'; +import 'metric.dart'; +import 'metrics.dart'; + +typedef CaptureMetricCallback = void Function(SentryMetric metric); +typedef ScopeProvider = Scope Function(); + +final class DefaultSentryMetrics implements SentryMetrics { + final CaptureMetricCallback _captureMetricCallback; + final ClockProvider _clockProvider; + final ScopeProvider _defaultScopeProvider; + + DefaultSentryMetrics( + {required CaptureMetricCallback captureMetricCallback, + required ClockProvider clockProvider, + required ScopeProvider defaultScopeProvider}) + : _captureMetricCallback = captureMetricCallback, + _clockProvider = clockProvider, + _defaultScopeProvider = defaultScopeProvider; + + @override + void count( + String name, + int value, { + Map? attributes, + Scope? scope, + }) { + final metric = SentryCounterMetric( + timestamp: _clockProvider(), + name: name, + value: value, + spanId: _activeSpanIdFor(scope), + traceId: _traceIdFor(scope), + attributes: attributes ?? {}); + + _captureMetricCallback(metric); + } + + @override + void gauge( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }) { + final metric = SentryGaugeMetric( + timestamp: _clockProvider(), + name: name, + value: value, + spanId: _activeSpanIdFor(scope), + traceId: _traceIdFor(scope), + attributes: attributes ?? {}); + + _captureMetricCallback(metric); + } + + @override + void distribution( + String name, + num value, { + String? unit, + Map? attributes, + Scope? scope, + }) { + final metric = SentryDistributionMetric( + timestamp: _clockProvider(), + name: name, + value: value, + unit: unit, + spanId: _activeSpanIdFor(scope), + traceId: _traceIdFor(scope), + attributes: attributes ?? {}); + + _captureMetricCallback(metric); + } + + SentryId _traceIdFor(Scope? scope) => + (scope ?? _defaultScopeProvider()).propagationContext.traceId; + + SpanId? _activeSpanIdFor(Scope? scope) => + (scope ?? _defaultScopeProvider()).span?.context.spanId; +} + +final class NoOpSentryMetrics implements SentryMetrics { + const NoOpSentryMetrics(); + + static const instance = NoOpSentryMetrics(); + + @override + void count(String name, int value, + {Map? attributes, Scope? scope}) {} + + @override + void distribution(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} + + @override + void gauge(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} +} diff --git a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart index 5787f17cef..ccec9dc682 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart @@ -1,5 +1,6 @@ import '../../../sentry.dart'; import 'metrics.dart'; +import 'metrics_impl.dart'; class MetricsSetupIntegration extends Integration { static const integrationName = 'MetricsSetup'; @@ -9,7 +10,7 @@ class MetricsSetupIntegration extends Integration { if (!options.enableMetrics) return; if (options.metrics is! NoOpSentryMetrics) return; - options.metrics = SentryMetrics( + options.metrics = DefaultSentryMetrics( captureMetricCallback: hub.captureMetric, clockProvider: options.clock, defaultScopeProvider: () => hub.scope); From 435917d9145ac9df7318a00cffe09c54512b260c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 19 Jan 2026 17:04:31 +0100 Subject: [PATCH 18/42] feat: enhance telemetry metrics with MetricCapturePipeline and default attributes - Introduced MetricCapturePipeline for capturing and processing metrics. - Added default attributes for telemetry metrics in the new default_attributes.dart file. - Implemented DefaultSentryMetrics for metric handling and integrated it into SentryClient. - Updated SentryClient to utilize the MetricCapturePipeline for capturing metrics. - Added tests to ensure proper functionality of the new metric capturing and processing features. --- packages/dart/lib/src/constants.dart | 70 ++++++ .../dart/lib/src/sdk_lifecycle_hooks.dart | 8 + packages/dart/lib/src/sentry_client.dart | 41 +--- packages/dart/lib/src/sentry_options.dart | 2 +- .../lib/src/telemetry/default_attributes.dart | 55 +++++ ...metrics_impl.dart => default_metrics.dart} | 18 -- .../metric/metric_capture_pipeline.dart | 50 +++++ .../metric/metrics_setup_integration.dart | 4 +- .../src/telemetry/metric/noop_metrics.dart | 19 ++ .../src/telemetry/processing/processor.dart | 13 +- packages/dart/lib/src/utils.dart | 9 + .../mocks/mock_metric_capture_pipeline.dart | 18 ++ packages/dart/test/sentry_client_test.dart | 30 +++ .../metric/metric_capture_pipeline_test.dart | 205 ++++++++++++++++++ .../telemetry/processing/processor_test.dart | 7 - 15 files changed, 482 insertions(+), 67 deletions(-) create mode 100644 packages/dart/lib/src/telemetry/default_attributes.dart rename packages/dart/lib/src/telemetry/metric/{metrics_impl.dart => default_metrics.dart} (81%) create mode 100644 packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart create mode 100644 packages/dart/lib/src/telemetry/metric/noop_metrics.dart create mode 100644 packages/dart/test/mocks/mock_metric_capture_pipeline.dart create mode 100644 packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart diff --git a/packages/dart/lib/src/constants.dart b/packages/dart/lib/src/constants.dart index 640b776832..7cef712c67 100644 --- a/packages/dart/lib/src/constants.dart +++ b/packages/dart/lib/src/constants.dart @@ -39,3 +39,73 @@ class SentrySpanDescriptions { static String dbOpen({required String dbName}) => 'Open database $dbName'; static String dbClose({required String dbName}) => 'Close database $dbName'; } + +/// Semantic attributes for telemetry. +/// +/// Not all attributes apply to every telemetry type. +/// +/// See https://getsentry.github.io/sentry-conventions/generated/attributes/ +/// for more details. +@internal +abstract class SemanticAttributesConstants { + SemanticAttributesConstants._(); + + /// The source of a span, also referred to as transaction source. + /// + /// Known values are: `'custom'`, `'url'`, `'route'`, `'component'`, `'view'`, `'task'`. + static const sentrySpanSource = 'sentry.span.source'; + + /// Attributes that holds the sample rate that was locally applied to a span. + /// If this attribute is not defined, it means that the span inherited a sampling decision. + /// + /// NOTE: Is only defined on root spans. + static const sentrySampleRate = 'sentry.sample_rate'; + + /// Use this attribute to represent the origin of a span. + static const sentryOrigin = 'sentry.origin'; + + /// The release version of the application + static const sentryRelease = 'sentry.release'; + + /// The environment name (e.g., "production", "staging", "development") + static const sentryEnvironment = 'sentry.environment'; + + /// The segment name (e.g., "GET /users") + static const sentrySegmentName = 'sentry.segment.name'; + + /// The span id of the segment that this span belongs to. + static const sentrySegmentId = 'sentry.segment.id'; + + /// The name of the Sentry SDK (e.g., "sentry.dart.flutter") + static const sentrySdkName = 'sentry.sdk.name'; + + /// The version of the Sentry SDK + static const sentrySdkVersion = 'sentry.sdk.version'; + + /// The user ID (gated by `sendDefaultPii`). + static const userId = 'user.id'; + + /// The user email (gated by `sendDefaultPii`). + static const userEmail = 'user.email'; + + /// The user IP address (gated by `sendDefaultPii`). + static const userIpAddress = 'user.ip_address'; + + /// The user username (gated by `sendDefaultPii`). + static const userName = 'user.name'; + + /// The operating system name. + static const osName = 'os.name'; + + /// The operating system version. + static const osVersion = 'os.version'; + + /// The device brand (e.g., "Apple", "Samsung"). + static const deviceBrand = 'device.brand'; + + /// The device model identifier (e.g., "iPhone14,2"). + static const deviceModel = 'device.model'; + + /// The device family (e.g., "iOS", "Android"). + static const deviceFamily = 'device.family'; +} diff --git a/packages/dart/lib/src/sdk_lifecycle_hooks.dart b/packages/dart/lib/src/sdk_lifecycle_hooks.dart index fe803e5e27..b0df1b19b9 100644 --- a/packages/dart/lib/src/sdk_lifecycle_hooks.dart +++ b/packages/dart/lib/src/sdk_lifecycle_hooks.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../sentry.dart'; +import 'telemetry/metric/metric.dart'; @internal typedef SdkLifecycleCallback = FutureOr @@ -96,3 +97,10 @@ class OnSpanFinish extends SdkLifecycleEvent { final ISentrySpan span; } + +@internal +class OnProcessMetric extends SdkLifecycleEvent { + final SentryMetric metric; + + OnProcessMetric(this.metric); +} diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index 3ba406c5d7..bcd4a45419 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -19,6 +19,7 @@ import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'sentry_trace_context_header.dart'; import 'telemetry/metric/metric.dart'; +import 'telemetry/metric/metric_capture_pipeline.dart'; import 'transport/client_report_transport.dart'; import 'transport/data_category.dart'; import 'transport/http_transport.dart'; @@ -43,6 +44,7 @@ String get defaultIpAddress => _defaultIpAddress; class SentryClient { final SentryOptions _options; final Random? _random; + final MetricCapturePipeline _metricCapturePipeline; static final _emptySentryId = Future.value(SentryId.empty()); @@ -50,7 +52,8 @@ class SentryClient { SentryStackTraceFactory get _stackTraceFactory => _options.stackTraceFactory; /// Instantiates a client using [SentryOptions] - factory SentryClient(SentryOptions options) { + factory SentryClient(SentryOptions options, + {MetricCapturePipeline? metricCapturePipeline}) { if (options.sendClientReports) { options.recorder = ClientReportRecorder(options.clock); } @@ -75,11 +78,12 @@ class SentryClient { if (enableFlutterSpotlight) { options.transport = SpotlightHttpTransport(options, options.transport); } - return SentryClient._(options); + return SentryClient._( + options, metricCapturePipeline ?? MetricCapturePipeline(options)); } /// Instantiates a client using [SentryOptions] - SentryClient._(this._options) + SentryClient._(this._options, this._metricCapturePipeline) : _random = _options.sampleRate == null ? null : Random(); /// Reports an [event] to Sentry.io. @@ -584,35 +588,8 @@ class SentryClient { } } - Future captureMetric(SentryMetric metric, {Scope? scope}) async { - if (!_options.enableLogs) { - return; - } - - final beforeSendMetric = _options.beforeSendMetric; - SentryMetric? processedMetric = metric; - if (beforeSendMetric != null) { - try { - processedMetric = await beforeSendMetric(metric); - } catch (exception, stackTrace) { - _options.log( - SentryLevel.error, - 'The beforeSendLog callback threw an exception', - exception: exception, - stackTrace: stackTrace, - ); - if (_options.automatedTestMode) { - rethrow; - } - } - } - - // TODO: attributes enricher - - if (processedMetric != null) { - _options.telemetryProcessor.addMetric(processedMetric); - } - } + Future captureMetric(SentryMetric metric, {Scope? scope}) => + _metricCapturePipeline.captureMetric(metric, scope: scope); FutureOr close() { final flush = _options.telemetryProcessor.flush(); diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 54bd628d4b..e5d725d5a9 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -13,7 +13,7 @@ import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; import 'telemetry/metric/metric.dart'; -import 'telemetry/metric/metrics_impl.dart'; +import 'telemetry/metric/noop_metrics.dart'; import 'telemetry/processing/processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; diff --git a/packages/dart/lib/src/telemetry/default_attributes.dart b/packages/dart/lib/src/telemetry/default_attributes.dart new file mode 100644 index 0000000000..93facb066a --- /dev/null +++ b/packages/dart/lib/src/telemetry/default_attributes.dart @@ -0,0 +1,55 @@ +import '../../sentry.dart'; +import '../utils/os_utils.dart'; + +final _operatingSystem = getSentryOperatingSystem(); + +Map defaultAttributes(SentryOptions options, + {Scope? scope}) { + final attributes = {}; + + attributes[SemanticAttributesConstants.sentrySdkName] = + SentryAttribute.string(options.sdk.name); + + attributes[SemanticAttributesConstants.sentrySdkVersion] = + SentryAttribute.string(options.sdk.version); + + if (options.environment != null) { + attributes[SemanticAttributesConstants.sentryEnvironment] = + SentryAttribute.string(options.environment!); + } + + if (options.release != null) { + attributes[SemanticAttributesConstants.sentryRelease] = + SentryAttribute.string(options.release!); + } + + if (options.sendDefaultPii) { + final user = scope?.user; + if (user != null) { + if (user.id != null) { + attributes[SemanticAttributesConstants.userId] = + SentryAttribute.string(user.id!); + } + if (user.name != null) { + attributes[SemanticAttributesConstants.userName] = + SentryAttribute.string(user.name!); + } + if (user.email != null) { + attributes[SemanticAttributesConstants.userEmail] = + SentryAttribute.string(user.email!); + } + } + } + + if (_operatingSystem.name != null) { + attributes[SemanticAttributesConstants.osName] = + SentryAttribute.string(_operatingSystem.name!); + } + + if (_operatingSystem.version != null) { + attributes[SemanticAttributesConstants.osVersion] = + SentryAttribute.string(_operatingSystem.version!); + } + + return attributes; +} diff --git a/packages/dart/lib/src/telemetry/metric/metrics_impl.dart b/packages/dart/lib/src/telemetry/metric/default_metrics.dart similarity index 81% rename from packages/dart/lib/src/telemetry/metric/metrics_impl.dart rename to packages/dart/lib/src/telemetry/metric/default_metrics.dart index a62d216eb9..6940db9e9e 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics_impl.dart +++ b/packages/dart/lib/src/telemetry/metric/default_metrics.dart @@ -81,21 +81,3 @@ final class DefaultSentryMetrics implements SentryMetrics { SpanId? _activeSpanIdFor(Scope? scope) => (scope ?? _defaultScopeProvider()).span?.context.spanId; } - -final class NoOpSentryMetrics implements SentryMetrics { - const NoOpSentryMetrics(); - - static const instance = NoOpSentryMetrics(); - - @override - void count(String name, int value, - {Map? attributes, Scope? scope}) {} - - @override - void distribution(String name, num value, - {String? unit, Map? attributes, Scope? scope}) {} - - @override - void gauge(String name, num value, - {String? unit, Map? attributes, Scope? scope}) {} -} diff --git a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart new file mode 100644 index 0000000000..b9fc62373b --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart @@ -0,0 +1,50 @@ +import 'package:meta/meta.dart'; + +import '../../../sentry.dart'; +import '../default_attributes.dart'; +import 'metric.dart'; + +@internal +class MetricCapturePipeline { + final SentryOptions _options; + + MetricCapturePipeline(this._options); + + Future captureMetric(SentryMetric metric, {Scope? scope}) async { + if (!_options.enableMetrics) { + return; + } + + if (scope != null) { + metric.attributes.addAllIfAbsent(scope.attributes); + } + + await _options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); + + metric.attributes.addAllIfAbsent(defaultAttributes(_options, scope: scope)); + + final beforeSendMetric = _options.beforeSendMetric; + SentryMetric? processedMetric = metric; + if (beforeSendMetric != null) { + try { + processedMetric = await beforeSendMetric(metric); + } catch (exception, stackTrace) { + _options.log( + SentryLevel.error, + 'The beforeSendLog callback threw an exception', + exception: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } + } + } + if (processedMetric == null) { + return; + } + + _options.telemetryProcessor.addMetric(metric); + } +} diff --git a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart index ccec9dc682..f664eb7b16 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart @@ -1,6 +1,6 @@ import '../../../sentry.dart'; -import 'metrics.dart'; -import 'metrics_impl.dart'; +import 'default_metrics.dart'; +import 'noop_metrics.dart'; class MetricsSetupIntegration extends Integration { static const integrationName = 'MetricsSetup'; diff --git a/packages/dart/lib/src/telemetry/metric/noop_metrics.dart b/packages/dart/lib/src/telemetry/metric/noop_metrics.dart new file mode 100644 index 0000000000..125c8b274b --- /dev/null +++ b/packages/dart/lib/src/telemetry/metric/noop_metrics.dart @@ -0,0 +1,19 @@ +import '../../../sentry.dart'; + +final class NoOpSentryMetrics implements SentryMetrics { + const NoOpSentryMetrics(); + + static const instance = NoOpSentryMetrics(); + + @override + void count(String name, int value, + {Map? attributes, Scope? scope}) {} + + @override + void distribution(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} + + @override + void gauge(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} +} diff --git a/packages/dart/lib/src/telemetry/processing/processor.dart b/packages/dart/lib/src/telemetry/processing/processor.dart index 9916e6323b..928dd029fa 100644 --- a/packages/dart/lib/src/telemetry/processing/processor.dart +++ b/packages/dart/lib/src/telemetry/processing/processor.dart @@ -33,12 +33,11 @@ abstract class TelemetryProcessor { class DefaultTelemetryProcessor implements TelemetryProcessor { /// The buffer for metric data, or `null` if metric buffering is disabled. final TelemetryBuffer? _metricBuffer; - - + @visibleForTesting TelemetryBuffer? get metricBuffer => _metricBuffer; - /// The buffer for log data, or `null` if log buffering is disabled. + /// The buffer for log data, or `null` if log buffering is disabled. final TelemetryBuffer? _logBuffer; @visibleForTesting @@ -47,8 +46,8 @@ class DefaultTelemetryProcessor implements TelemetryProcessor { DefaultTelemetryProcessor({ TelemetryBuffer? metricBuffer, TelemetryBuffer? logBuffer, - }) : _metricBuffer = metricBuffer, - _logBuffer = logBuffer; + }) : _metricBuffer = metricBuffer, + _logBuffer = logBuffer; @override void addLog(SentryLog log) { @@ -59,7 +58,7 @@ class DefaultTelemetryProcessor implements TelemetryProcessor { return; } - _logBuffer!.add(log); + _logBuffer.add(log); } @override @@ -71,7 +70,7 @@ class DefaultTelemetryProcessor implements TelemetryProcessor { return; } - _metricBuffer!.add(metric); + _metricBuffer.add(metric); } @override diff --git a/packages/dart/lib/src/utils.dart b/packages/dart/lib/src/utils.dart index db5ca614c2..1d6af4c968 100644 --- a/packages/dart/lib/src/utils.dart +++ b/packages/dart/lib/src/utils.dart @@ -34,3 +34,12 @@ Object? jsonSerializationFallback(Object? nonEncodable) { } return nonEncodable.toString(); } + +@internal +extension AddAllAbsentX on Map { + void addAllIfAbsent(Map other) { + for (final e in other.entries) { + putIfAbsent(e.key, () => e.value); + } + } +} diff --git a/packages/dart/test/mocks/mock_metric_capture_pipeline.dart b/packages/dart/test/mocks/mock_metric_capture_pipeline.dart new file mode 100644 index 0000000000..8f9a5146d7 --- /dev/null +++ b/packages/dart/test/mocks/mock_metric_capture_pipeline.dart @@ -0,0 +1,18 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; +import 'package:sentry/src/telemetry/metric/metric_capture_pipeline.dart'; + +class FakeMetricCapturePipeline extends MetricCapturePipeline { + FakeMetricCapturePipeline(super.options); + + int callCount = 0; + SentryMetric? capturedMetric; + Scope? capturedScope; + + @override + Future captureMetric(SentryMetric metric, {Scope? scope}) async { + callCount++; + capturedMetric = metric; + capturedScope = scope; + } +} diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index e7c46ab55e..b0b5923ce6 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -14,6 +14,7 @@ import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/transport/client_report_transport.dart'; import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/transport/noop_transport.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/transport/spotlight_http_transport.dart'; import 'package:sentry/src/utils/iterable_utils.dart'; import 'package:test/test.dart'; @@ -23,6 +24,7 @@ import 'package:http/http.dart' as http; import 'mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_hub.dart'; +import 'mocks/mock_metric_capture_pipeline.dart'; import 'mocks/mock_telemetry_processor.dart'; import 'mocks/mock_transport.dart'; import 'test_utils.dart'; @@ -2060,6 +2062,34 @@ void main() { }); }); + group('SentryClient captureMetric', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('delegates to metric pipeline', () async { + final pipeline = FakeMetricCapturePipeline(fixture.options); + final client = + SentryClient(fixture.options, metricCapturePipeline: pipeline); + final scope = Scope(fixture.options); + + final metric = SentryCounterMetric( + timestamp: DateTime.now().toUtc(), + name: 'test-metric', + value: 1, + traceId: SentryId.newId(), + ); + + await client.captureMetric(metric, scope: scope); + + expect(pipeline.callCount, 1); + expect(pipeline.capturedMetric, same(metric)); + expect(pipeline.capturedScope, same(scope)); + }); + }); + group('SentryClient captures envelope', () { late Fixture fixture; final fakeEnvelope = getFakeEnvelope(); diff --git a/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart b/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart new file mode 100644 index 0000000000..9a7de99fb2 --- /dev/null +++ b/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart @@ -0,0 +1,205 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; +import 'package:sentry/src/telemetry/metric/metric_capture_pipeline.dart'; +import 'package:test/test.dart'; + +import '../../mocks/mock_telemetry_processor.dart'; +import '../../test_utils.dart'; + +void main() { + group('$MetricCapturePipeline', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('when capturing a metric', () { + test('forwards to telemetry processor', () async { + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect(fixture.processor.addedMetrics.length, 1); + expect(fixture.processor.addedMetrics.first, same(metric)); + }); + + test('adds default attributes', () async { + await fixture.scope.setUser(SentryUser(id: 'user-id')); + fixture.scope.setAttributes({ + 'scope-key': SentryAttribute.string('scope-value'), + }); + + final metric = fixture.createMetric() + ..attributes['custom'] = SentryAttribute.string('metric-value'); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + final attributes = metric.attributes; + expect(attributes['scope-key']?.value, 'scope-value'); + expect(attributes['custom']?.value, 'metric-value'); + expect(attributes[SemanticAttributesConstants.sentryEnvironment]?.value, + 'test-env'); + expect(attributes[SemanticAttributesConstants.sentryRelease]?.value, + 'test-release'); + expect(attributes[SemanticAttributesConstants.sentrySdkName]?.value, + fixture.options.sdk.name); + expect(attributes[SemanticAttributesConstants.sentrySdkVersion]?.value, + fixture.options.sdk.version); + expect( + attributes[SemanticAttributesConstants.userId]?.value, 'user-id'); + }); + + test('prefers scope attributes over defaults', () async { + fixture.scope.setAttributes({ + SemanticAttributesConstants.sentryEnvironment: + SentryAttribute.string('scope-env'), + }); + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + final attributes = metric.attributes; + expect(attributes[SemanticAttributesConstants.sentryEnvironment]?.value, + 'scope-env'); + expect(attributes[SemanticAttributesConstants.sentryRelease]?.value, + 'test-release'); + }); + + test( + 'dispatches OnProcessMetric after scope merge but before beforeSendMetric', + () async { + final operations = []; + bool hasScopeAttrInCallback = false; + + fixture.scope.setAttributes({ + 'scope-attr': SentryAttribute.string('scope-value'), + }); + + fixture.options.lifecycleRegistry + .registerCallback((event) { + operations.add('onProcessMetric'); + hasScopeAttrInCallback = + event.metric.attributes.containsKey('scope-attr'); + }); + + fixture.options.beforeSendMetric = (metric) { + operations.add('beforeSendMetric'); + return metric; + }; + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect(operations, ['onProcessMetric', 'beforeSendMetric']); + expect(hasScopeAttrInCallback, isTrue); + }); + + test('keeps attributes added by lifecycle callbacks', () async { + fixture.options.lifecycleRegistry + .registerCallback((event) { + event.metric.attributes['callback-key'] = + SentryAttribute.string('callback-value'); + event.metric + .attributes[SemanticAttributesConstants.sentryEnvironment] = + SentryAttribute.string('callback-env'); + }); + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + final attributes = metric.attributes; + expect(attributes['callback-key']?.value, 'callback-value'); + expect(attributes[SemanticAttributesConstants.sentryEnvironment]?.value, + 'callback-env'); + }); + + test('does not add user attributes when sendDefaultPii is false', + () async { + fixture.options.sendDefaultPii = false; + await fixture.scope.setUser(SentryUser(id: 'user-id')); + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect( + metric.attributes.containsKey(SemanticAttributesConstants.userId), + isFalse, + ); + }); + }); + + group('when metrics are disabled', () { + test('does not add metrics to processor', () async { + fixture.options.enableMetrics = false; + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect(fixture.processor.addedMetrics, isEmpty); + }); + }); + + group('when beforeSendMetric is configured', () { + test('returning null drops the metric', () async { + fixture.options.beforeSendMetric = (_) => null; + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect(fixture.processor.addedMetrics, isEmpty); + }); + + test('can mutate the metric', () async { + fixture.options.beforeSendMetric = (metric) { + metric.name = 'modified-name'; + metric.attributes['added-key'] = SentryAttribute.string('added'); + return metric; + }; + + final metric = fixture.createMetric(name: 'original-name'); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect(fixture.processor.addedMetrics.length, 1); + final captured = fixture.processor.addedMetrics.first; + expect(captured.name, 'modified-name'); + expect(captured.attributes['added-key']?.value, 'added'); + }); + }); + }); +} + +class Fixture { + final options = defaultTestOptions() + ..environment = 'test-env' + ..release = 'test-release' + ..sendDefaultPii = true + ..enableMetrics = true; + + final processor = MockTelemetryProcessor(); + + late final Scope scope; + late final MetricCapturePipeline pipeline; + + Fixture() { + options.telemetryProcessor = processor; + scope = Scope(options); + pipeline = MetricCapturePipeline(options); + } + + SentryMetric createMetric({String name = 'test-metric'}) { + return SentryCounterMetric( + timestamp: DateTime.now().toUtc(), + name: name, + value: 1, + traceId: SentryId.newId(), + ); + } +} diff --git a/packages/dart/test/telemetry/processing/processor_test.dart b/packages/dart/test/telemetry/processing/processor_test.dart index dd0d100b70..1f81288c43 100644 --- a/packages/dart/test/telemetry/processing/processor_test.dart +++ b/packages/dart/test/telemetry/processing/processor_test.dart @@ -49,13 +49,6 @@ void main() { expect(mockMetricBuffer.addedItems.length, 1); expect(mockMetricBuffer.addedItems.first, metric); }); - - test('does not throw when no metric buffer registered', () { - final processor = fixture.getSut(); - - final metric = fixture.createMetric(); - processor.addMetric(metric); - }); }); group('flush', () { From 07b5051ee25e9b48cc72a17781b0792b7c9beade Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 19 Jan 2026 17:11:41 +0100 Subject: [PATCH 19/42] Add more tests --- packages/dart/lib/src/sentry_options.dart | 2 +- packages/dart/test/sentry_test.dart | 42 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index e5d725d5a9..600e243dd0 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -20,7 +20,7 @@ import 'version.dart'; import 'dart:developer' as developer; // TODO: shutdownTimeout, flushTimeoutMillis -// https://api.dart.dev/stable/2.1w0.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually +// https://api.dart.dev/stable/2.10.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually /// Sentry SDK options class SentryOptions { diff --git a/packages/dart/test/sentry_test.dart b/packages/dart/test/sentry_test.dart index ffb609b062..55cf4bc3b4 100644 --- a/packages/dart/test/sentry_test.dart +++ b/packages/dart/test/sentry_test.dart @@ -8,6 +8,7 @@ import 'package:sentry/src/dart_exception_type_identifier.dart'; import 'package:sentry/src/event_processor/deduplication_event_processor.dart'; import 'package:sentry/src/logs_enricher_integration.dart'; import 'package:sentry/src/feature_flags_integration.dart'; +import 'package:sentry/src/telemetry/metric/metrics_setup_integration.dart'; import 'package:sentry/src/telemetry/processing/processor_integration.dart'; import 'package:test/test.dart'; @@ -268,6 +269,27 @@ void main() { expect(integration.callCalls, 1); }); + test('should add $MetricsSetupIntegration', () async { + late SentryOptions optionsReference; + final options = defaultTestOptions(); + + await Sentry.init( + options: options, + (options) { + options.dsn = fakeDsn; + optionsReference = options; + }, + appRunner: appRunner, + ); + + expect( + optionsReference.integrations + .whereType() + .length, + 1, + ); + }); + test('should add default integrations', () async { late SentryOptions optionsReference; final options = defaultTestOptions(); @@ -375,6 +397,26 @@ void main() { ); }); + test('should add feature flag $MetricsSetupIntegration', () async { + await Sentry.init( + options: defaultTestOptions(), + (options) => options.dsn = fakeDsn, + ); + + await Sentry.addFeatureFlag('foo', true); + + expect( + Sentry.currentHub.scope.contexts[SentryFeatureFlags.type]?.values.first + .flag, + equals('foo'), + ); + expect( + Sentry.currentHub.scope.contexts[SentryFeatureFlags.type]?.values.first + .result, + equals(true), + ); + }); + test('addFeatureFlag should ignore non-boolean values', () async { await Sentry.init( options: defaultTestOptions(), From 875ca84622ecb5d719a7d97c29242bda6309370d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 02:37:07 +0100 Subject: [PATCH 20/42] feat: enhance metric capturing and logging in MetricCapturePipeline and MetricsSetupIntegration - Added internal logging for metric capture events in MetricCapturePipeline to provide better visibility on dropped and captured metrics. - Updated MetricsSetupIntegration to log when metrics are disabled or when custom metrics are already configured. - Introduced tests for capturing metrics in the Hub, ensuring proper functionality and scope handling. - Added tests for creating SentryEnvelope and SentryEnvelopeItem from metrics data, verifying correct headers and payloads. --- .../metric/metric_capture_pipeline.dart | 9 +- .../metric/metrics_setup_integration.dart | 15 +- packages/dart/test/hub_test.dart | 56 +++++++ .../dart/test/mocks/mock_sentry_client.dart | 14 ++ .../dart/test/sentry_envelope_item_test.dart | 26 ++++ packages/dart/test/sentry_envelope_test.dart | 31 ++++ .../test/telemetry/metric/metric_test.dart | 73 +++++++++ .../metrics_setup_integration_test.dart | 95 ++++++++++++ .../test/telemetry/metric/metrics_test.dart | 146 ++++++++++++++++++ 9 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 packages/dart/test/telemetry/metric/metric_test.dart create mode 100644 packages/dart/test/telemetry/metric/metrics_setup_integration_test.dart create mode 100644 packages/dart/test/telemetry/metric/metrics_test.dart diff --git a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart index b9fc62373b..a096dd885c 100644 --- a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart +++ b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; +import '../../utils/internal_logger.dart'; import '../default_attributes.dart'; import 'metric.dart'; @@ -12,6 +13,8 @@ class MetricCapturePipeline { Future captureMetric(SentryMetric metric, {Scope? scope}) async { if (!_options.enableMetrics) { + internalLogger.debug( + '$MetricCapturePipeline: Metrics disabled, dropping ${metric.name}'); return; } @@ -32,7 +35,7 @@ class MetricCapturePipeline { } catch (exception, stackTrace) { _options.log( SentryLevel.error, - 'The beforeSendLog callback threw an exception', + 'The beforeSendMetric callback threw an exception', exception: exception, stackTrace: stackTrace, ); @@ -42,9 +45,13 @@ class MetricCapturePipeline { } } if (processedMetric == null) { + internalLogger.debug( + '$MetricCapturePipeline: Metric ${metric.name} dropped by beforeSendMetric'); return; } _options.telemetryProcessor.addMetric(metric); + internalLogger.debug( + '$MetricCapturePipeline: Metric ${metric.name} (${metric.type}) captured'); } } diff --git a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart index f664eb7b16..5c0cfd4742 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics_setup_integration.dart @@ -1,4 +1,5 @@ import '../../../sentry.dart'; +import '../../utils/internal_logger.dart'; import 'default_metrics.dart'; import 'noop_metrics.dart'; @@ -7,8 +8,17 @@ class MetricsSetupIntegration extends Integration { @override void call(Hub hub, SentryOptions options) { - if (!options.enableMetrics) return; - if (options.metrics is! NoOpSentryMetrics) return; + if (!options.enableMetrics) { + internalLogger + .debug('$integrationName: Metrics disabled, skipping setup'); + return; + } + + if (options.metrics is! NoOpSentryMetrics) { + internalLogger.debug( + '$integrationName: Custom metrics already configured, skipping setup'); + return; + } options.metrics = DefaultSentryMetrics( captureMetricCallback: hub.captureMetric, @@ -16,5 +26,6 @@ class MetricsSetupIntegration extends Integration { defaultScopeProvider: () => hub.scope); options.sdk.addIntegration(integrationName); + internalLogger.debug('$integrationName: Metrics configured successfully'); } } diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index ab9f11e048..9c97993ffb 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -4,6 +4,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/propagation_context.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; @@ -943,6 +944,61 @@ void main() { expect(fixture.client.captureLogCalls.first.log, log); }); }); + + group('Hub Metrics', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + SentryMetric givenMetric() { + return SentryCounterMetric( + timestamp: DateTime.now().toUtc(), + name: 'test-metric', + value: 1, + traceId: SentryId.newId(), + attributes: { + 'attribute': SentryAttribute.string('value'), + }, + ); + } + + test('captures metrics', () async { + final hub = fixture.getSut(); + + final metric = givenMetric(); + await hub.captureMetric(metric); + + expect(fixture.client.captureMetricCalls.length, 1); + expect(fixture.client.captureMetricCalls.first.metric, metric); + }); + + test('does not capture metric when hub is disabled', () async { + final hub = fixture.getSut(); + await hub.close(); + + final metric = givenMetric(); + await hub.captureMetric(metric); + + expect(fixture.client.captureMetricCalls, isEmpty); + }); + + test('passes scope to client', () async { + final hub = fixture.getSut(); + hub.configureScope((scope) { + scope.setTag('test-tag', 'test-value'); + }); + + final metric = givenMetric(); + await hub.captureMetric(metric); + + expect(fixture.client.captureMetricCalls.length, 1); + final capturedScope = fixture.client.captureMetricCalls.first.scope; + expect(capturedScope, isNotNull); + expect(capturedScope!.tags['test-tag'], 'test-value'); + }); + }); } class Fixture { diff --git a/packages/dart/test/mocks/mock_sentry_client.dart b/packages/dart/test/mocks/mock_sentry_client.dart index 00a61cc203..0286e3e6b3 100644 --- a/packages/dart/test/mocks/mock_sentry_client.dart +++ b/packages/dart/test/mocks/mock_sentry_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import 'no_such_method_provider.dart'; @@ -11,6 +12,7 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureTransactionCalls = []; List captureFeedbackCalls = []; List captureLogCalls = []; + List captureMetricCalls = []; int closeCalls = 0; @override @@ -90,6 +92,11 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { captureLogCalls.add(CaptureLogCall(log, scope)); } + @override + Future captureMetric(SentryMetric metric, {Scope? scope}) async { + captureMetricCalls.add(CaptureMetricCall(metric, scope)); + } + @override void close() { closeCalls = closeCalls + 1; @@ -186,3 +193,10 @@ class CaptureLogCall { CaptureLogCall(this.log, this.scope); } + +class CaptureMetricCall { + final SentryMetric metric; + final Scope? scope; + + CaptureMetricCall(this.metric, this.scope); +} diff --git a/packages/dart/test/sentry_envelope_item_test.dart b/packages/dart/test/sentry_envelope_item_test.dart index 54f534de61..cca111b6a0 100644 --- a/packages/dart/test/sentry_envelope_item_test.dart +++ b/packages/dart/test/sentry_envelope_item_test.dart @@ -157,5 +157,31 @@ void main() { expect(sut.originalObject, null); }); + + test('fromMetricsData creates item with correct headers and payload', + () async { + final payload = + utf8.encode('{"items":[{"test":"metric1"},{"test":"metric2"}]}'); + final metricsCount = 2; + + final sut = SentryEnvelopeItem.fromMetricsData(payload, metricsCount); + + expect(sut.header.contentType, + 'application/vnd.sentry.items.trace-metric+json'); + expect(sut.header.type, SentryItemType.metric); + expect(sut.header.itemCount, metricsCount); + + final actualData = await sut.dataFactory(); + expect(actualData, payload); + }); + + test('fromMetricsData does not set originalObject', () async { + final payload = utf8.encode('{"items":[{"test":"metric"}]}'); + final metricsCount = 1; + + final sut = SentryEnvelopeItem.fromMetricsData(payload, metricsCount); + + expect(sut.originalObject, null); + }); }); } diff --git a/packages/dart/test/sentry_envelope_test.dart b/packages/dart/test/sentry_envelope_test.dart index f0953624c7..2225c811e1 100644 --- a/packages/dart/test/sentry_envelope_test.dart +++ b/packages/dart/test/sentry_envelope_test.dart @@ -209,6 +209,37 @@ void main() { expect(actualItem, expectedItem); }); + test('fromMetricsData creates envelope with wrapped metrics payload', + () async { + final encodedMetrics = [ + utf8.encode( + '{"timestamp":1672531200.0,"type":"counter","name":"metric1","value":1,"trace_id":"abc"}'), + utf8.encode( + '{"timestamp":1672531201.0,"type":"gauge","name":"metric2","value":42,"trace_id":"def"}'), + ]; + + final sdkVersion = + SdkVersion(name: 'fixture-name', version: 'fixture-version'); + final sut = SentryEnvelope.fromMetricsData(encodedMetrics, sdkVersion); + + expect(sut.header.eventId, null); + expect(sut.header.sdkVersion, sdkVersion); + expect(sut.items.length, 1); + + expect(sut.items[0].header.contentType, + 'application/vnd.sentry.items.trace-metric+json'); + expect(sut.items[0].header.type, SentryItemType.metric); + expect(sut.items[0].header.itemCount, 2); + + final actualItem = await sut.items[0].dataFactory(); + final expectedPayload = utf8.encode('{"items":[') + + encodedMetrics[0] + + utf8.encode(',') + + encodedMetrics[1] + + utf8.encode(']}'); + expect(actualItem, expectedPayload); + }); + test('max attachment size', () async { final attachment = SentryAttachment.fromLoader( loader: () => Uint8List.fromList([1, 2, 3, 4]), diff --git a/packages/dart/test/telemetry/metric/metric_test.dart b/packages/dart/test/telemetry/metric/metric_test.dart new file mode 100644 index 0000000000..08cf6e80e2 --- /dev/null +++ b/packages/dart/test/telemetry/metric/metric_test.dart @@ -0,0 +1,73 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; +import 'package:test/test.dart'; + +void main() { + group('SentryMetric toJson', () { + test('serializes all fields correctly', () { + final traceId = SentryId.newId(); + final spanId = SpanId.newId(); + final timestamp = DateTime.utc(2024, 1, 15, 10, 30, 0); + + final metric = SentryCounterMetric( + timestamp: timestamp, + name: 'button_clicks', + value: 5, + traceId: traceId, + spanId: spanId, + unit: 'click', + attributes: {'key': SentryAttribute.string('value')}, + ); + + final json = metric.toJson(); + + expect(json['timestamp'], 1705314600.0); + expect(json['type'], 'counter'); + expect(json['name'], 'button_clicks'); + expect(json['value'], 5); + expect(json['trace_id'], traceId); + expect(json['span_id'], spanId); + expect(json['unit'], 'click'); + expect(json['attributes']['key'], {'type': 'string', 'value': 'value'}); + }); + + test('omits optional fields when null', () { + final metric = SentryCounterMetric( + timestamp: DateTime.utc(2024, 1, 15), + name: 'test', + value: 1, + traceId: SentryId.newId(), + ); + + final json = metric.toJson(); + + expect(json.containsKey('span_id'), isFalse); + expect(json.containsKey('unit'), isFalse); + expect(json.containsKey('attributes'), isFalse); + }); + + test('each metric type sets correct type field', () { + final traceId = SentryId.newId(); + final timestamp = DateTime.utc(2024, 1, 15); + + expect( + SentryCounterMetric( + timestamp: timestamp, name: 't', value: 1, traceId: traceId) + .toJson()['type'], + 'counter', + ); + expect( + SentryGaugeMetric( + timestamp: timestamp, name: 't', value: 1, traceId: traceId) + .toJson()['type'], + 'gauge', + ); + expect( + SentryDistributionMetric( + timestamp: timestamp, name: 't', value: 1, traceId: traceId) + .toJson()['type'], + 'distribution', + ); + }); + }); +} diff --git a/packages/dart/test/telemetry/metric/metrics_setup_integration_test.dart b/packages/dart/test/telemetry/metric/metrics_setup_integration_test.dart new file mode 100644 index 0000000000..5dc7aef1d2 --- /dev/null +++ b/packages/dart/test/telemetry/metric/metrics_setup_integration_test.dart @@ -0,0 +1,95 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/default_metrics.dart'; +import 'package:sentry/src/telemetry/metric/metrics_setup_integration.dart'; +import 'package:sentry/src/telemetry/metric/noop_metrics.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +void main() { + group('$MetricsSetupIntegration', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('when metrics are enabled', () { + test('configures DefaultSentryMetrics', () { + fixture.options.enableMetrics = true; + + fixture.sut.call(fixture.hub, fixture.options); + + expect(fixture.options.metrics, isA()); + }); + + test('adds integration to SDK', () { + fixture.options.enableMetrics = true; + + fixture.sut.call(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations, + contains(MetricsSetupIntegration.integrationName), + ); + }); + + test('does not override existing non-noop metrics', () { + fixture.options.enableMetrics = true; + final customMetrics = _CustomSentryMetrics(); + fixture.options.metrics = customMetrics; + + fixture.sut.call(fixture.hub, fixture.options); + + expect(fixture.options.metrics, same(customMetrics)); + }); + }); + + group('when metrics are disabled', () { + test('does not configure metrics', () { + fixture.options.enableMetrics = false; + + fixture.sut.call(fixture.hub, fixture.options); + + expect(fixture.options.metrics, isA()); + }); + + test('does not add integration to SDK', () { + fixture.options.enableMetrics = false; + + fixture.sut.call(fixture.hub, fixture.options); + + expect( + fixture.options.sdk.integrations, + isNot(contains(MetricsSetupIntegration.integrationName)), + ); + }); + }); + }); +} + +class Fixture { + final options = defaultTestOptions(); + + late final Hub hub; + late final MetricsSetupIntegration sut; + + Fixture() { + hub = Hub(options); + sut = MetricsSetupIntegration(); + } +} + +class _CustomSentryMetrics implements SentryMetrics { + @override + void count(String name, int value, + {Map? attributes, Scope? scope}) {} + + @override + void distribution(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} + + @override + void gauge(String name, num value, + {String? unit, Map? attributes, Scope? scope}) {} +} diff --git a/packages/dart/test/telemetry/metric/metrics_test.dart b/packages/dart/test/telemetry/metric/metrics_test.dart new file mode 100644 index 0000000000..45f2d06cd3 --- /dev/null +++ b/packages/dart/test/telemetry/metric/metrics_test.dart @@ -0,0 +1,146 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/telemetry/metric/default_metrics.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; +import 'package:sentry/src/telemetry/metric/noop_metrics.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +void main() { + group('$DefaultSentryMetrics', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('when calling count', () { + test('creates counter metric with correct type', () { + fixture.sut.count('test-counter', 5); + + expect(fixture.capturedMetrics.length, 1); + final metric = fixture.capturedMetrics.first; + expect(metric, isA()); + expect(metric.type, 'counter'); + }); + + test('sets name and value', () { + fixture.sut.count('my-counter', 42); + + final metric = fixture.capturedMetrics.first; + expect(metric.name, 'my-counter'); + expect(metric.value, 42); + }); + + test('includes attributes when provided', () { + fixture.sut.count( + 'test-counter', + 1, + attributes: {'key': SentryAttribute.string('value')}, + ); + + final metric = fixture.capturedMetrics.first; + expect(metric.attributes['key']?.value, 'value'); + }); + + test('sets trace id from scope', () { + fixture.sut.count('test-counter', 1); + + final metric = fixture.capturedMetrics.first; + expect(metric.traceId, fixture.scope.propagationContext.traceId); + }); + + test('sets timestamp from clock', () { + fixture.sut.count('test-counter', 1); + + final metric = fixture.capturedMetrics.first; + expect(metric.timestamp, fixture.fixedTimestamp); + }); + }); + + group('when calling gauge', () { + test('creates gauge metric with correct type', () { + fixture.sut.gauge('test-gauge', 42.5); + + expect(fixture.capturedMetrics.length, 1); + final metric = fixture.capturedMetrics.first; + expect(metric, isA()); + expect(metric.type, 'gauge'); + }); + + test('sets name and value', () { + fixture.sut.gauge('memory-usage', 75.5); + + final metric = fixture.capturedMetrics.first; + expect(metric.name, 'memory-usage'); + expect(metric.value, 75.5); + }); + + test('includes attributes when provided', () { + fixture.sut.gauge( + 'test-gauge', + 10, + attributes: {'env': SentryAttribute.string('prod')}, + ); + + final metric = fixture.capturedMetrics.first; + expect(metric.attributes['env']?.value, 'prod'); + }); + }); + + group('when calling distribution', () { + test('creates distribution metric with correct type', () { + fixture.sut.distribution('test-distribution', 100); + + expect(fixture.capturedMetrics.length, 1); + final metric = fixture.capturedMetrics.first; + expect(metric, isA()); + expect(metric.type, 'distribution'); + }); + + test('sets name and value', () { + fixture.sut.distribution('response-time', 250); + + final metric = fixture.capturedMetrics.first; + expect(metric.name, 'response-time'); + expect(metric.value, 250); + }); + + test('includes unit when provided', () { + fixture.sut.distribution('response-time', 250, unit: 'millisecond'); + + final metric = fixture.capturedMetrics.first; + expect(metric.unit, 'millisecond'); + }); + + test('includes attributes when provided', () { + fixture.sut.distribution( + 'test-distribution', + 50, + attributes: {'route': SentryAttribute.string('/api/users')}, + ); + + final metric = fixture.capturedMetrics.first; + expect(metric.attributes['route']?.value, '/api/users'); + }); + }); + }); +} + +class Fixture { + final options = defaultTestOptions(); + final capturedMetrics = []; + final fixedTimestamp = DateTime.utc(2024, 1, 15, 10, 30, 0); + + late final Scope scope; + late final DefaultSentryMetrics sut; + + Fixture() { + scope = Scope(options); + sut = DefaultSentryMetrics( + captureMetricCallback: (metric) => capturedMetrics.add(metric), + clockProvider: () => fixedTimestamp, + defaultScopeProvider: () => scope, + ); + } +} From b5eee19ab9947e85fc98ff73e410e4db34c8ab15 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 03:09:41 +0100 Subject: [PATCH 21/42] feat: enhance Sentry attribute formatting and replay integration - Added new constants for replay ID and buffering state in SemanticAttributesConstants. - Refactored SentryLogger to utilize a new extension for formatting Sentry attributes. - Introduced utility extensions for formatting Sentry attributes and maps of attributes. - Updated MetricCapturePipeline to log metrics with formatted attributes. - Replaced ReplayLogIntegration with ReplayTelemetryIntegration in Flutter integration tests and codebase. - Added tests to ensure proper functionality of the new replay integration and attribute formatting. --- .../src/client_reports/discarded_event.dart | 2 + packages/dart/lib/src/constants.dart | 7 + packages/dart/lib/src/sentry_logger.dart | 44 +-- .../src/telemetry/metric/default_metrics.dart | 18 +- .../metric/metric_capture_pipeline.dart | 4 + .../dart/lib/src/transport/data_category.dart | 3 + packages/dart/lib/src/utils.dart | 56 ++++ .../metric/metric_capture_pipeline_test.dart | 19 ++ .../platform_integrations_test.dart | 10 +- .../integrations/replay_log_integration.dart | 63 ---- .../replay_telemetry_integration.dart | 97 ++++++ packages/flutter/lib/src/sentry_flutter.dart | 6 +- .../replay_log_integration_test.dart | 303 ------------------ .../replay_telemetry_integration_test.dart | 221 +++++++++++++ packages/flutter/test/mocks.dart | 7 + 15 files changed, 443 insertions(+), 417 deletions(-) delete mode 100644 packages/flutter/lib/src/integrations/replay_log_integration.dart create mode 100644 packages/flutter/lib/src/integrations/replay_telemetry_integration.dart delete mode 100644 packages/flutter/test/integrations/replay_log_integration_test.dart create mode 100644 packages/flutter/test/integrations/replay_telemetry_integration_test.dart diff --git a/packages/dart/lib/src/client_reports/discarded_event.dart b/packages/dart/lib/src/client_reports/discarded_event.dart index 24a3471df0..53d8b95bb8 100644 --- a/packages/dart/lib/src/client_reports/discarded_event.dart +++ b/packages/dart/lib/src/client_reports/discarded_event.dart @@ -70,6 +70,8 @@ extension _DataCategoryExtension on DataCategory { return 'feedback'; case DataCategory.metricBucket: return 'metric_bucket'; + case DataCategory.metric: + return 'trace_metric'; } } } diff --git a/packages/dart/lib/src/constants.dart b/packages/dart/lib/src/constants.dart index 7cef712c67..94759424df 100644 --- a/packages/dart/lib/src/constants.dart +++ b/packages/dart/lib/src/constants.dart @@ -82,6 +82,13 @@ abstract class SemanticAttributesConstants { /// The version of the Sentry SDK static const sentrySdkVersion = 'sentry.sdk.version'; + /// The replay ID. + static const sentryReplayId = 'sentry.replay_id'; + + /// Whether the replay is buffering (onErrorSampleRate). + static const sentryInternalReplayIsBuffering = + 'sentry._internal.replay_is_buffering'; + /// The user ID (gated by `sendDefaultPii`). static const userId = 'user.id'; diff --git a/packages/dart/lib/src/sentry_logger.dart b/packages/dart/lib/src/sentry_logger.dart index 1d324e0678..a643ac8eec 100644 --- a/packages/dart/lib/src/sentry_logger.dart +++ b/packages/dart/lib/src/sentry_logger.dart @@ -6,6 +6,7 @@ import 'protocol/sentry_log_level.dart'; import 'protocol/sentry_attribute.dart'; import 'sentry_options.dart'; import 'sentry_logger_formatter.dart'; +import 'utils.dart'; class SentryLogger { SentryLogger(this._clock, {Hub? hub}) : _hub = hub ?? HubAdapter(); @@ -89,47 +90,6 @@ class SentryLogger { 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(SentryAttribute 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(); + return '$body ${attributes.toFormattedString()}'; } } diff --git a/packages/dart/lib/src/telemetry/metric/default_metrics.dart b/packages/dart/lib/src/telemetry/metric/default_metrics.dart index 6940db9e9e..d8d0d903a9 100644 --- a/packages/dart/lib/src/telemetry/metric/default_metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/default_metrics.dart @@ -1,6 +1,6 @@ import '../../../sentry.dart'; +import '../../utils/internal_logger.dart'; import 'metric.dart'; -import 'metrics.dart'; typedef CaptureMetricCallback = void Function(SentryMetric metric); typedef ScopeProvider = Scope Function(); @@ -25,6 +25,9 @@ final class DefaultSentryMetrics implements SentryMetrics { Map? attributes, Scope? scope, }) { + internalLogger.debug(() => + 'Sentry.metrics.count("$name", $value) called with attributes ${_formatAttributes(attributes)}'); + final metric = SentryCounterMetric( timestamp: _clockProvider(), name: name, @@ -44,6 +47,9 @@ final class DefaultSentryMetrics implements SentryMetrics { Map? attributes, Scope? scope, }) { + internalLogger.debug(() => + 'Sentry.metrics.gauge("$name", $value${_formatUnit(unit)}) called with attributes ${_formatAttributes(attributes)}'); + final metric = SentryGaugeMetric( timestamp: _clockProvider(), name: name, @@ -63,6 +69,9 @@ final class DefaultSentryMetrics implements SentryMetrics { Map? attributes, Scope? scope, }) { + internalLogger.debug(() => + 'Sentry.metrics.distribution("$name", $value${_formatUnit(unit)}) called with attributes ${_formatAttributes(attributes)}'); + final metric = SentryDistributionMetric( timestamp: _clockProvider(), name: name, @@ -80,4 +89,11 @@ final class DefaultSentryMetrics implements SentryMetrics { SpanId? _activeSpanIdFor(Scope? scope) => (scope ?? _defaultScopeProvider()).span?.context.spanId; + + String _formatUnit(String? unit) => unit != null ? ', unit: $unit' : ''; + + String _formatAttributes(Map? attributes) { + final formatted = attributes?.toFormattedString() ?? ''; + return formatted.isEmpty ? '' : ' $formatted'; + } } diff --git a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart index a096dd885c..7c097850ef 100644 --- a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart +++ b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart @@ -1,6 +1,8 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; +import '../../client_reports/discard_reason.dart'; +import '../../transport/data_category.dart'; import '../../utils/internal_logger.dart'; import '../default_attributes.dart'; import 'metric.dart'; @@ -45,6 +47,8 @@ class MetricCapturePipeline { } } if (processedMetric == null) { + _options.recorder + .recordLostEvent(DiscardReason.beforeSend, DataCategory.metric); internalLogger.debug( '$MetricCapturePipeline: Metric ${metric.name} dropped by beforeSendMetric'); return; diff --git a/packages/dart/lib/src/transport/data_category.dart b/packages/dart/lib/src/transport/data_category.dart index 89f983f3a7..5dbba0f392 100644 --- a/packages/dart/lib/src/transport/data_category.dart +++ b/packages/dart/lib/src/transport/data_category.dart @@ -11,6 +11,7 @@ enum DataCategory { metricBucket, logItem, feedback, + metric, unknown; static DataCategory fromItemType(String itemType) { @@ -29,6 +30,8 @@ enum DataCategory { return DataCategory.metricBucket; case 'log': return DataCategory.logItem; + case 'trace_metric': + return DataCategory.metric; case 'feedback': return DataCategory.feedback; default: diff --git a/packages/dart/lib/src/utils.dart b/packages/dart/lib/src/utils.dart index 1d6af4c968..407bceab07 100644 --- a/packages/dart/lib/src/utils.dart +++ b/packages/dart/lib/src/utils.dart @@ -6,6 +6,8 @@ import 'dart:convert'; import 'package:meta/meta.dart'; +import 'protocol/sentry_attribute.dart'; + /// Sentry does not take a timezone and instead expects the date-time to be /// submitted in UTC timezone. @internal @@ -43,3 +45,57 @@ extension AddAllAbsentX on Map { } } } + +@internal +extension SentryAttributeFormatting on SentryAttribute { + /// Formats the attribute value for debug/log output. + /// + /// Strings are quoted, numbers and booleans are shown as-is. + String toFormattedString() { + switch (type) { + case 'string': + if (value is String) { + return '"$value"'; + } + break; + case 'boolean': + if (value is bool) { + return value.toString(); + } + break; + case 'integer': + if (value is int) { + return value.toString(); + } + break; + case 'double': + if (value is double) { + final doubleValue = value as double; + // Handle special double values + if (doubleValue.isNaN || doubleValue.isInfinite) { + return doubleValue.toString(); + } + // Ensure doubles always show decimal notation to distinguish from ints + return doubleValue == doubleValue.toInt() + ? doubleValue.toStringAsFixed(1) + : doubleValue.toString(); + } + break; + } + return value.toString(); + } +} + +@internal +extension SentryAttributeMapFormatting on Map { + /// Formats attributes as `{key1: value1, key2: value2}`. + /// + /// Returns an empty string if the map is empty. + String toFormattedString() { + if (isEmpty) return ''; + final attrsStr = entries + .map((e) => '"${e.key}": ${e.value.toFormattedString()}') + .join(', '); + return '{$attrsStr}'; + } +} diff --git a/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart b/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart index 9a7de99fb2..5467756b8c 100644 --- a/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart +++ b/packages/dart/test/telemetry/metric/metric_capture_pipeline_test.dart @@ -1,8 +1,11 @@ import 'package:sentry/sentry.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/telemetry/metric/metric_capture_pipeline.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; +import '../../mocks/mock_client_report_recorder.dart'; import '../../mocks/mock_telemetry_processor.dart'; import '../../test_utils.dart'; @@ -156,6 +159,20 @@ void main() { expect(fixture.processor.addedMetrics, isEmpty); }); + test('returning null records lost event in client report', () async { + fixture.options.beforeSendMetric = (_) => null; + + final metric = fixture.createMetric(); + + await fixture.pipeline.captureMetric(metric, scope: fixture.scope); + + expect(fixture.recorder.discardedEvents.length, 1); + expect(fixture.recorder.discardedEvents.first.reason, + DiscardReason.beforeSend); + expect(fixture.recorder.discardedEvents.first.category, + DataCategory.metric); + }); + test('can mutate the metric', () async { fixture.options.beforeSendMetric = (metric) { metric.name = 'modified-name'; @@ -184,12 +201,14 @@ class Fixture { ..enableMetrics = true; final processor = MockTelemetryProcessor(); + final recorder = MockClientReportRecorder(); late final Scope scope; late final MetricCapturePipeline pipeline; Fixture() { options.telemetryProcessor = processor; + options.recorder = recorder; scope = Scope(options); pipeline = MetricCapturePipeline(options); } diff --git a/packages/flutter/example/integration_test/platform_integrations_test.dart b/packages/flutter/example/integration_test/platform_integrations_test.dart index 75c675e754..d6a760166b 100644 --- a/packages/flutter/example/integration_test/platform_integrations_test.dart +++ b/packages/flutter/example/integration_test/platform_integrations_test.dart @@ -14,7 +14,7 @@ import 'package:sentry_flutter/src/integrations/generic_app_start_integration.da import 'package:sentry_flutter/src/integrations/load_contexts_integration.dart'; import 'package:sentry_flutter/src/integrations/native_load_debug_images_integration.dart'; import 'package:sentry_flutter/src/integrations/native_sdk_integration.dart'; -import 'package:sentry_flutter/src/integrations/replay_log_integration.dart'; +import 'package:sentry_flutter/src/integrations/replay_telemetry_integration.dart'; import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; import 'package:sentry_flutter/src/integrations/thread_info_integration.dart'; import 'package:sentry_flutter/src/integrations/web_session_integration.dart'; @@ -162,14 +162,14 @@ void main() { isTrue); expect( options.integrations.any((i) => i is ReplayIntegration), isTrue); - expect(options.integrations.any((i) => i is ReplayLogIntegration), + expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), isTrue); } else if (isIOS) { expect(options.integrations.any((i) => i is LoadContextsIntegration), isTrue); expect( options.integrations.any((i) => i is ReplayIntegration), isTrue); - expect(options.integrations.any((i) => i is ReplayLogIntegration), + expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), isTrue); } else if (isMacOS) { expect(options.integrations.any((i) => i is LoadContextsIntegration), @@ -180,7 +180,7 @@ void main() { // still not add it expect( options.integrations.any((i) => i is ReplayIntegration), isTrue); - expect(options.integrations.any((i) => i is ReplayLogIntegration), + expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), isFalse); } }); @@ -235,7 +235,7 @@ void main() { isFalse); expect( options.integrations.any((i) => i is ReplayIntegration), isFalse); - expect(options.integrations.any((i) => i is ReplayLogIntegration), + expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), isFalse); // Ordering: RunZonedGuarded before Widgets diff --git a/packages/flutter/lib/src/integrations/replay_log_integration.dart b/packages/flutter/lib/src/integrations/replay_log_integration.dart deleted file mode 100644 index 2747930cdd..0000000000 --- a/packages/flutter/lib/src/integrations/replay_log_integration.dart +++ /dev/null @@ -1,63 +0,0 @@ -// ignore_for_file: invalid_use_of_internal_member - -import 'package:sentry/sentry.dart'; -import '../sentry_flutter_options.dart'; -import '../native/sentry_native_binding.dart'; - -/// Integration that adds replay-related information to logs using lifecycle callbacks -class ReplayLogIntegration implements Integration { - static const String integrationName = 'ReplayLog'; - - final SentryNativeBinding? _native; - ReplayLogIntegration(this._native); - - SentryFlutterOptions? _options; - SdkLifecycleCallback? _addReplayInformation; - - @override - Future call(Hub hub, SentryFlutterOptions options) async { - if (!options.replay.isEnabled) { - return; - } - final sessionSampleRate = options.replay.sessionSampleRate ?? 0; - final onErrorSampleRate = options.replay.onErrorSampleRate ?? 0; - - _options = options; - _addReplayInformation = (OnBeforeCaptureLog event) { - final scopeReplayId = hub.scope.replayId; - final replayId = scopeReplayId ?? _native?.replayId; - final replayIsBuffering = replayId != null && scopeReplayId == null; - - if (sessionSampleRate > 0 && replayId != null && !replayIsBuffering) { - event.log.attributes['sentry.replay_id'] = SentryAttribute.string( - scopeReplayId.toString(), - ); - } else if (onErrorSampleRate > 0 && - replayId != null && - replayIsBuffering) { - event.log.attributes['sentry.replay_id'] = SentryAttribute.string( - replayId.toString(), - ); - event.log.attributes['sentry._internal.replay_is_buffering'] = - SentryAttribute.bool(true); - } - }; - options.lifecycleRegistry - .registerCallback(_addReplayInformation!); - options.sdk.addIntegration(integrationName); - } - - @override - Future close() async { - final options = _options; - final addReplayInformation = _addReplayInformation; - - if (options != null && addReplayInformation != null) { - options.lifecycleRegistry - .removeCallback(addReplayInformation); - } - - _options = null; - _addReplayInformation = null; - } -} diff --git a/packages/flutter/lib/src/integrations/replay_telemetry_integration.dart b/packages/flutter/lib/src/integrations/replay_telemetry_integration.dart new file mode 100644 index 0000000000..4883ad1bb1 --- /dev/null +++ b/packages/flutter/lib/src/integrations/replay_telemetry_integration.dart @@ -0,0 +1,97 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'package:meta/meta.dart'; +import 'package:sentry/sentry.dart'; +import '../sentry_flutter_options.dart'; +import '../native/sentry_native_binding.dart'; + +/// Integration that adds replay-related information to logs and metrics +/// using lifecycle callbacks. +@internal +class ReplayTelemetryIntegration implements Integration { + static const String integrationName = 'ReplayTelemetry'; + + final SentryNativeBinding? _native; + ReplayTelemetryIntegration(this._native); + + SentryFlutterOptions? _options; + SdkLifecycleCallback? _onBeforeCaptureLog; + SdkLifecycleCallback? _onProcessMetric; + + @override + Future call(Hub hub, SentryFlutterOptions options) async { + if (!options.replay.isEnabled) { + return; + } + final sessionSampleRate = options.replay.sessionSampleRate ?? 0; + final onErrorSampleRate = options.replay.onErrorSampleRate ?? 0; + + _options = options; + + _onBeforeCaptureLog = (OnBeforeCaptureLog event) { + _addReplayAttributes( + hub.scope.replayId, + event.log.attributes, + sessionSampleRate: sessionSampleRate, + onErrorSampleRate: onErrorSampleRate, + ); + }; + + _onProcessMetric = (OnProcessMetric event) { + _addReplayAttributes( + hub.scope.replayId, + event.metric.attributes, + sessionSampleRate: sessionSampleRate, + onErrorSampleRate: onErrorSampleRate, + ); + }; + + options.lifecycleRegistry + .registerCallback(_onBeforeCaptureLog!); + options.lifecycleRegistry + .registerCallback(_onProcessMetric!); + options.sdk.addIntegration(integrationName); + } + + void _addReplayAttributes( + SentryId? scopeReplayId, + Map attributes, { + required double sessionSampleRate, + required double onErrorSampleRate, + }) { + final replayId = scopeReplayId ?? _native?.replayId; + final replayIsBuffering = replayId != null && scopeReplayId == null; + + if (sessionSampleRate > 0 && replayId != null && !replayIsBuffering) { + attributes[SemanticAttributesConstants.sentryReplayId] = + SentryAttribute.string(scopeReplayId.toString()); + } else if (onErrorSampleRate > 0 && replayId != null && replayIsBuffering) { + attributes[SemanticAttributesConstants.sentryReplayId] = + SentryAttribute.string(replayId.toString()); + attributes[SemanticAttributesConstants.sentryInternalReplayIsBuffering] = + SentryAttribute.bool(true); + } + } + + @override + Future close() async { + final options = _options; + final onBeforeCaptureLog = _onBeforeCaptureLog; + final onProcessMetric = _onProcessMetric; + + if (options != null) { + if (onBeforeCaptureLog != null) { + options.lifecycleRegistry + .removeCallback(onBeforeCaptureLog); + } + if (onProcessMetric != null) { + options.lifecycleRegistry + .removeCallback(onProcessMetric); + } + } + + _options = null; + _onBeforeCaptureLog = null; + _onProcessMetric = null; + } +} diff --git a/packages/flutter/lib/src/sentry_flutter.dart b/packages/flutter/lib/src/sentry_flutter.dart index 0609d087bf..3ec4d43cd9 100644 --- a/packages/flutter/lib/src/sentry_flutter.dart +++ b/packages/flutter/lib/src/sentry_flutter.dart @@ -22,7 +22,7 @@ import 'integrations/flutter_framework_feature_flag_integration.dart'; import 'integrations/frames_tracking_integration.dart'; import 'integrations/integrations.dart'; import 'integrations/native_app_start_handler.dart'; -import 'integrations/replay_log_integration.dart'; +import 'integrations/replay_telemetry_integration.dart'; import 'integrations/screenshot_integration.dart'; import 'integrations/generic_app_start_integration.dart'; import 'integrations/thread_info_integration.dart'; @@ -232,9 +232,9 @@ mixin SentryFlutter { integrations.add(DebugPrintIntegration()); - // Only add ReplayLogIntegration on platforms that support replay + // Only add ReplayTelemetryIntegration on platforms that support replay if (native != null && native.supportsReplay) { - integrations.add(ReplayLogIntegration(native)); + integrations.add(ReplayTelemetryIntegration(native)); } if (!platform.isWeb) { diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart deleted file mode 100644 index 7a667a050e..0000000000 --- a/packages/flutter/test/integrations/replay_log_integration_test.dart +++ /dev/null @@ -1,303 +0,0 @@ -// ignore_for_file: invalid_use_of_internal_member - -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/integrations/replay_log_integration.dart'; - -import '../mocks.mocks.dart'; - -void main() { - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - group('ReplayLogIntegration', () { - test('does not register when replay is disabled', () async { - final integration = fixture.getSut(); - fixture.options.replay.sessionSampleRate = 0.0; - fixture.options.replay.onErrorSampleRate = 0.0; - - await integration.call(fixture.hub, fixture.options); - - // Integration should not be registered when replay is disabled - expect(fixture.options.sdk.integrations.contains('ReplayLog'), false); - }); - - test( - 'adds replay_id attribute when sessionSampleRate > 0 and scope replayId is set', - () async { - final integration = fixture.getSut(); - - fixture.options.replay.sessionSampleRate = 0.5; - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); - expect(log.attributes['sentry.replay_id']?.type, 'string'); - - // When scope replayId is set via session sample rate, no buffering flag should be added - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test( - 'does not add replay_id when sessionSampleRate is 0 even if scope replayId is set', - () async { - final integration = fixture.getSut(); - - fixture.options.replay.sessionSampleRate = 0.0; - fixture.options.replay.onErrorSampleRate = 0.5; // Needed to enable replay - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // With sessionSampleRate = 0, scope replay ID should not be used - // (even though it's set, we're not in session mode) - expect(log.attributes.containsKey('sentry.replay_id'), false); - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test( - 'does not add replay_id when sessionSampleRate is null even if scope replayId is set', - () async { - final integration = fixture.getSut(); - - fixture.options.replay.sessionSampleRate = null; - fixture.options.replay.onErrorSampleRate = 0.5; // Needed to enable replay - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // With sessionSampleRate = null (treated as 0), scope replay ID should not be used - expect(log.attributes.containsKey('sentry.replay_id'), false); - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test( - 'uses replay_id when set on scope and sessionSampleRate > 0 (active session mode)', - () async { - final integration = fixture.getSut(); - - fixture.options.replay.sessionSampleRate = 0.5; - fixture.options.replay.onErrorSampleRate = 0.5; - final replayId = SentryId.fromId('test-replay-id'); - fixture.hub.scope.replayId = replayId; - - // Mock native replayId with the same value (same replay, just also set on scope) - when(fixture.nativeBinding.replayId).thenReturn(replayId); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // Should use the replay ID from scope (active session mode) - expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); - expect(log.attributes['sentry.replay_id']?.type, 'string'); - - // Should NOT add buffering flag when replay is active on scope - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test( - 'adds replay_id and buffering flag when replay is in buffer mode (scope null, native has ID)', - () async { - final integration = fixture.getSut(); - fixture.options.replay.onErrorSampleRate = 0.5; - // Scope replay ID is null (default), so we're in buffer mode - - // Mock native replayId to simulate buffering mode (same replay, not on scope yet) - final replayId = SentryId.fromId('test-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(replayId); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // In buffering mode, use native replay ID and add buffering flag - expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); - expect(log.attributes['sentry.replay_id']?.type, 'string'); - - expect( - log.attributes['sentry._internal.replay_is_buffering']?.value, true); - expect(log.attributes['sentry._internal.replay_is_buffering']?.type, - 'boolean'); - }); - - test( - 'does not add anything when onErrorSampleRate is 0 and no scope replayId', - () async { - final integration = fixture.getSut(); - fixture.options.replay.sessionSampleRate = 0.5; // Needed to enable replay - fixture.options.replay.onErrorSampleRate = 0.0; - // Scope replay ID is null (default) - - // Mock native replayId to simulate what would be buffering mode - final replayId = SentryId.fromId('test-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(replayId); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // When onErrorSampleRate is 0, native replayId should be ignored even if it exists - expect(log.attributes.containsKey('sentry.replay_id'), false); - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test( - 'does not add anything when onErrorSampleRate is null and no scope replayId', - () async { - final integration = fixture.getSut(); - fixture.options.replay.sessionSampleRate = 0.5; // Needed to enable replay - fixture.options.replay.onErrorSampleRate = null; - // Scope replay ID is null (default) - - // Mock native replayId to simulate what would be buffering mode - final replayId = SentryId.fromId('test-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(replayId); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // When onErrorSampleRate is null (treated as 0), native replayId should be ignored - expect(log.attributes.containsKey('sentry.replay_id'), false); - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test( - 'adds replay_id when scope is null but native has ID and onErrorSampleRate > 0 (buffer mode)', - () async { - final integration = fixture.getSut(); - fixture.options.replay.sessionSampleRate = 0.0; - fixture.options.replay.onErrorSampleRate = 0.5; - // Scope replay ID is null (default), so we're in buffer mode - - // Mock native replayId to simulate buffering mode (replay exists but not on scope) - final replayId = SentryId.fromId('test-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(replayId); - - await integration.call(fixture.hub, fixture.options); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - // When scope is null but native has replay ID, use it in buffer mode - expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); - expect(log.attributes['sentry.replay_id']?.type, 'string'); - expect( - log.attributes['sentry._internal.replay_is_buffering']?.value, true); - expect(log.attributes['sentry._internal.replay_is_buffering']?.type, - 'boolean'); - }); - - test('registers integration name in SDK with sessionSampleRate', () async { - final integration = fixture.getSut(); - - fixture.options.replay.sessionSampleRate = 0.5; - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); - - await integration.call(fixture.hub, fixture.options); - - // Integration name is registered in SDK - expect(fixture.options.sdk.integrations.contains('ReplayLog'), true); - }); - - test('registers integration name in SDK with onErrorSampleRate', () async { - final integration = fixture.getSut(); - - fixture.options.replay.onErrorSampleRate = 0.5; - - // Mock native replayId - final replayId = SentryId.fromId('test-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(replayId); - - await integration.call(fixture.hub, fixture.options); - - // Integration name is registered in SDK - expect(fixture.options.sdk.integrations.contains('ReplayLog'), true); - }); - - test('removes callback on close', () async { - final integration = fixture.getSut(); - - fixture.options.replay.sessionSampleRate = 0.5; - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); - - await integration.call(fixture.hub, fixture.options); - await integration.close(); - - final log = fixture.createTestLog(); - await fixture.hub.captureLog(log); - - expect(log.attributes.containsKey('sentry.replay_id'), false); - expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), - false); - }); - - test('integration name is correct', () { - expect(ReplayLogIntegration.integrationName, 'ReplayLog'); - }); - }); -} - -class Fixture { - final options = - SentryFlutterOptions(dsn: 'https://abc@def.ingest.sentry.io/1234567'); - final hub = MockHub(); - final nativeBinding = MockSentryNativeBinding(); - - Fixture() { - options.enableLogs = true; - options.environment = 'test'; - options.release = 'test-release'; - - final scope = Scope(options); - when(hub.options).thenReturn(options); - when(hub.scope).thenReturn(scope); - when(hub.captureLog(any)).thenAnswer((invocation) async { - final log = invocation.positionalArguments.first as SentryLog; - // Trigger the lifecycle callback - await options.lifecycleRegistry.dispatchCallback(OnBeforeCaptureLog(log)); - }); - - // Default: no native replayId - when(nativeBinding.replayId).thenReturn(null); - } - - SentryLog createTestLog() { - return SentryLog( - timestamp: DateTime.now(), - traceId: SentryId.newId(), - level: SentryLogLevel.info, - body: 'test log message', - attributes: {}, - ); - } - - ReplayLogIntegration getSut() { - return ReplayLogIntegration(nativeBinding); - } -} diff --git a/packages/flutter/test/integrations/replay_telemetry_integration_test.dart b/packages/flutter/test/integrations/replay_telemetry_integration_test.dart new file mode 100644 index 0000000000..190625966d --- /dev/null +++ b/packages/flutter/test/integrations/replay_telemetry_integration_test.dart @@ -0,0 +1,221 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/replay_telemetry_integration.dart'; + +import '../mocks.mocks.dart'; + +const _replayId = SemanticAttributesConstants.sentryReplayId; +const _replayIsBuffering = + SemanticAttributesConstants.sentryInternalReplayIsBuffering; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('$ReplayTelemetryIntegration', () { + group('when replay is disabled', () { + test('does not register', () async { + fixture.options.replay.sessionSampleRate = 0.0; + fixture.options.replay.onErrorSampleRate = 0.0; + + await fixture.getSut().call(fixture.hub, fixture.options); + + expect(fixture.options.sdk.integrations, + isNot(contains('ReplayTelemetry'))); + }); + }); + + group('when replay is enabled', () { + test('registers integration', () async { + fixture.options.replay.sessionSampleRate = 0.5; + + await fixture.getSut().call(fixture.hub, fixture.options); + + expect(fixture.options.sdk.integrations, contains('ReplayTelemetry')); + }); + }); + + group('in session mode', () { + setUp(() { + fixture.options.replay.sessionSampleRate = 0.5; + fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); + }); + + test('adds replay_id to logs', () async { + await fixture.getSut().call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes[_replayId]?.value, 'testreplayid'); + }); + + test('adds replay_id to metrics', () async { + await fixture.getSut().call(fixture.hub, fixture.options); + + final metric = fixture.createTestMetric(); + await fixture.options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); + + expect(metric.attributes[_replayId]?.value, 'testreplayid'); + }); + + test('does not add buffering flag', () async { + await fixture.getSut().call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes.containsKey(_replayIsBuffering), false); + }); + }); + + group('in buffer mode', () { + setUp(() { + fixture.options.replay.onErrorSampleRate = 0.5; + when(fixture.nativeBinding.replayId) + .thenReturn(SentryId.fromId('test-replay-id')); + }); + + test('adds replay_id to logs', () async { + await fixture.getSut().call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes[_replayId]?.value, 'testreplayid'); + }); + + test('adds replay_id to metrics', () async { + await fixture.getSut().call(fixture.hub, fixture.options); + + final metric = fixture.createTestMetric(); + await fixture.options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); + + expect(metric.attributes[_replayId]?.value, 'testreplayid'); + }); + + test('adds buffering flag', () async { + await fixture.getSut().call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes[_replayIsBuffering]?.value, true); + }); + }); + + group('with zero or null sample rates', () { + for (final rate in [0.0, null]) { + test('ignores scope replayId when sessionSampleRate is $rate', + () async { + fixture.options.replay.sessionSampleRate = rate; + fixture.options.replay.onErrorSampleRate = 0.5; + fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); + + await fixture.getSut().call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes.containsKey(_replayId), false); + }); + + test('ignores native replayId when onErrorSampleRate is $rate', + () async { + fixture.options.replay.sessionSampleRate = 0.5; + fixture.options.replay.onErrorSampleRate = rate; + when(fixture.nativeBinding.replayId) + .thenReturn(SentryId.fromId('test-replay-id')); + + await fixture.getSut().call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes.containsKey(_replayId), false); + }); + } + }); + + group('when closed', () { + test('removes log callback', () async { + fixture.options.replay.sessionSampleRate = 0.5; + fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); + + final sut = fixture.getSut(); + await sut.call(fixture.hub, fixture.options); + await sut.close(); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + expect(log.attributes.containsKey(_replayId), false); + }); + + test('removes metric callback', () async { + fixture.options.replay.sessionSampleRate = 0.5; + fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); + + final sut = fixture.getSut(); + await sut.call(fixture.hub, fixture.options); + await sut.close(); + + final metric = fixture.createTestMetric(); + await fixture.options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); + + expect(metric.attributes.containsKey(_replayId), false); + }); + }); + }); +} + +class Fixture { + final options = + SentryFlutterOptions(dsn: 'https://abc@def.ingest.sentry.io/1234567'); + final hub = MockHub(); + final nativeBinding = MockSentryNativeBinding(); + + Fixture() { + options.enableLogs = true; + options.environment = 'test'; + options.release = 'test-release'; + + final scope = Scope(options); + when(hub.options).thenReturn(options); + when(hub.scope).thenReturn(scope); + when(hub.captureLog(any)).thenAnswer((invocation) async { + final log = invocation.positionalArguments.first as SentryLog; + await options.lifecycleRegistry.dispatchCallback(OnBeforeCaptureLog(log)); + }); + when(nativeBinding.replayId).thenReturn(null); + } + + SentryLog createTestLog() => SentryLog( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test log message', + attributes: {}, + ); + + SentryMetric createTestMetric() => SentryCounterMetric( + timestamp: DateTime.now(), + name: 'test-metric', + value: 1, + traceId: SentryId.newId(), + attributes: {}, + ); + + ReplayTelemetryIntegration getSut() => + ReplayTelemetryIntegration(nativeBinding); +} diff --git a/packages/flutter/test/mocks.dart b/packages/flutter/test/mocks.dart index af190bf78e..2230a545c9 100644 --- a/packages/flutter/test/mocks.dart +++ b/packages/flutter/test/mocks.dart @@ -8,6 +8,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/telemetry/processing/processor.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/frames_tracking/sentry_delayed_frames_tracker.dart'; @@ -240,6 +241,7 @@ class MockLogItem { class MockTelemetryProcessor implements TelemetryProcessor { final List addedLogs = []; + final List addedMetrics = []; int flushCalls = 0; int closeCalls = 0; @@ -248,6 +250,11 @@ class MockTelemetryProcessor implements TelemetryProcessor { addedLogs.add(log); } + @override + void addMetric(SentryMetric metric) { + addedMetrics.add(metric); + } + @override void flush() { flushCalls++; From 61bb4bcb3b16e760c6b60067d27ccbd95fd857c9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 03:16:17 +0100 Subject: [PATCH 22/42] feat: include unit in DefaultSentryMetrics and update tests - Added a unit field to the DefaultSentryMetrics for better metric representation. - Updated the metrics test to verify that the unit is included when provided. --- .../dart/lib/src/telemetry/metric/default_metrics.dart | 1 + packages/dart/test/telemetry/metric/metrics_test.dart | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/dart/lib/src/telemetry/metric/default_metrics.dart b/packages/dart/lib/src/telemetry/metric/default_metrics.dart index d8d0d903a9..c54ae034f3 100644 --- a/packages/dart/lib/src/telemetry/metric/default_metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/default_metrics.dart @@ -54,6 +54,7 @@ final class DefaultSentryMetrics implements SentryMetrics { timestamp: _clockProvider(), name: name, value: value, + unit: unit, spanId: _activeSpanIdFor(scope), traceId: _traceIdFor(scope), attributes: attributes ?? {}); diff --git a/packages/dart/test/telemetry/metric/metrics_test.dart b/packages/dart/test/telemetry/metric/metrics_test.dart index 45f2d06cd3..925213136c 100644 --- a/packages/dart/test/telemetry/metric/metrics_test.dart +++ b/packages/dart/test/telemetry/metric/metrics_test.dart @@ -76,6 +76,13 @@ void main() { expect(metric.value, 75.5); }); + test('includes unit when provided', () { + fixture.sut.gauge('response-time', 250, unit: 'millisecond'); + + final metric = fixture.capturedMetrics.first; + expect(metric.unit, 'millisecond'); + }); + test('includes attributes when provided', () { fixture.sut.gauge( 'test-gauge', From 5b55d0af6ee8aeb8b4c4dde0735aad4496826006 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 03:20:17 +0100 Subject: [PATCH 23/42] fix: update metric logging to use processed metrics - Changed the MetricCapturePipeline to log the processed metric instead of the original metric. - Removed unused import from metrics_test.dart to clean up the codebase. --- .../lib/src/telemetry/metric/metric_capture_pipeline.dart | 4 ++-- packages/dart/test/telemetry/metric/metrics_test.dart | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart index 7c097850ef..bf8de7dc33 100644 --- a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart +++ b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart @@ -54,8 +54,8 @@ class MetricCapturePipeline { return; } - _options.telemetryProcessor.addMetric(metric); + _options.telemetryProcessor.addMetric(processedMetric); internalLogger.debug( - '$MetricCapturePipeline: Metric ${metric.name} (${metric.type}) captured'); + '$MetricCapturePipeline: Metric ${processedMetric.name} (${processedMetric.type}) captured'); } } diff --git a/packages/dart/test/telemetry/metric/metrics_test.dart b/packages/dart/test/telemetry/metric/metrics_test.dart index 925213136c..134b1fd390 100644 --- a/packages/dart/test/telemetry/metric/metrics_test.dart +++ b/packages/dart/test/telemetry/metric/metrics_test.dart @@ -1,7 +1,6 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/telemetry/metric/default_metrics.dart'; import 'package:sentry/src/telemetry/metric/metric.dart'; -import 'package:sentry/src/telemetry/metric/noop_metrics.dart'; import 'package:test/test.dart'; import '../../test_utils.dart'; From 264f72bc8d0d7ca42818ab3129e07a36f0d57596 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:13:02 +0100 Subject: [PATCH 24/42] Fix missing spec --- packages/dart/lib/src/sentry_options.dart | 2 +- packages/dart/test/sentry_options_test.dart | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 600e243dd0..8591c859de 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -554,7 +554,7 @@ class SentryOptions { /// Enable to capture and send metrics to Sentry. /// /// Disabled by default. - bool enableMetrics = false; + bool enableMetrics = true; /// Enables adding the module in [SentryStackFrame.module]. /// This option only has an effect in non-obfuscated builds. diff --git a/packages/dart/test/sentry_options_test.dart b/packages/dart/test/sentry_options_test.dart index 25402e2f74..cb20034987 100644 --- a/packages/dart/test/sentry_options_test.dart +++ b/packages/dart/test/sentry_options_test.dart @@ -183,4 +183,10 @@ void main() { expect(() => options.parsedDsn, throwsA(isA())); }); + + test('enableMetrics is true by default', () { + final options = defaultTestOptions(); + + expect(options.enableMetrics, true); + }); } From f49e37141e554866dd16993ea142004cab775b84 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:18:16 +0100 Subject: [PATCH 25/42] Update CHANGELOG --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9328eefd..953cd3e886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Features + +- Trace connected metrics ([#3450](https://github.com/getsentry/sentry-dart/pull/3450)) + - This feature is enabled by default. + - To send metrics use the following APIs: + - `Sentry.metrics.gauge(...)` + - `Sentry.metrics.count(...)` + - `Sentry.metrics.distribution(...)` + + ## 9.10.0 ### Fixes From 5cfeba64db1601219d19f35a0b3e2df9c973bdff Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:34:05 +0100 Subject: [PATCH 26/42] Add examples --- .../platform_integrations_test.dart | 9 ++++-- packages/flutter/example/lib/main.dart | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/flutter/example/integration_test/platform_integrations_test.dart b/packages/flutter/example/integration_test/platform_integrations_test.dart index d6a760166b..a76cd94cc6 100644 --- a/packages/flutter/example/integration_test/platform_integrations_test.dart +++ b/packages/flutter/example/integration_test/platform_integrations_test.dart @@ -162,14 +162,16 @@ void main() { isTrue); expect( options.integrations.any((i) => i is ReplayIntegration), isTrue); - expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), + expect( + options.integrations.any((i) => i is ReplayTelemetryIntegration), isTrue); } else if (isIOS) { expect(options.integrations.any((i) => i is LoadContextsIntegration), isTrue); expect( options.integrations.any((i) => i is ReplayIntegration), isTrue); - expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), + expect( + options.integrations.any((i) => i is ReplayTelemetryIntegration), isTrue); } else if (isMacOS) { expect(options.integrations.any((i) => i is LoadContextsIntegration), @@ -180,7 +182,8 @@ void main() { // still not add it expect( options.integrations.any((i) => i is ReplayIntegration), isTrue); - expect(options.integrations.any((i) => i is ReplayTelemetryIntegration), + expect( + options.integrations.any((i) => i is ReplayTelemetryIntegration), isFalse); } }); diff --git a/packages/flutter/example/lib/main.dart b/packages/flutter/example/lib/main.dart index d1e2a18fef..d528c46659 100644 --- a/packages/flutter/example/lib/main.dart +++ b/packages/flutter/example/lib/main.dart @@ -544,6 +544,37 @@ class MainScaffold extends StatelessWidget { text: 'Demonstrates the feature flags.', buttonTitle: 'Add "feature-one" flag', ), + TooltipButton( + onPressed: () { + Sentry.metrics.count( + 'screen.view', + 1, + attributes: { + 'screen': SentryAttribute.string('HomeScreen'), + 'source': SentryAttribute.string('navigation'), + }, + ); + Sentry.metrics.gauge( + 'app.memory_usage', + 128, + unit: 'megabyte', + attributes: { + 'state': SentryAttribute.string('foreground'), + }, + ); + Sentry.metrics.distribution( + 'ui.render_time', + 16.7, + unit: 'millisecond', + attributes: { + 'widget': SentryAttribute.string('ListView'), + 'item_count': SentryAttribute.int(50), + }, + ); + }, + text: 'Demonstrates Sentry Metrics.', + buttonTitle: 'Send Metrics', + ), TooltipButton( onPressed: () { Sentry.logger From fbfcc745500f37ad10b5e5245cd1bf9e343d5d17 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:41:56 +0100 Subject: [PATCH 27/42] Update doc --- packages/dart/lib/src/sentry_options.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 8591c859de..ede49a8e05 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -220,7 +220,7 @@ class SentryOptions { BeforeSendLogCallback? beforeSendLog; /// This function is called right before a metric is about to be sent. - /// Can return a modified metric or null to drop the log. + /// Can return a modified metric or null to drop the metric. BeforeSendMetricCallback? beforeSendMetric; /// Sets the release. SDK will try to automatically configure a release out of the box From c7ca90cf5c88a9beb9af4c0b3b4bb2a215d2d2c0 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:42:56 +0100 Subject: [PATCH 28/42] Update doc --- packages/dart/lib/src/sentry_options.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index ede49a8e05..9c202caf07 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -553,7 +553,7 @@ class SentryOptions { /// Enable to capture and send metrics to Sentry. /// - /// Disabled by default. + /// Enabled by default. bool enableMetrics = true; /// Enables adding the module in [SentryStackFrame.module]. From 7f3b4916b92c28cad3618f238064caab8e2a6240 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:52:04 +0100 Subject: [PATCH 29/42] Update --- .../dart/lib/src/telemetry/metric/metric.dart | 2 +- .../load_contexts_integration.dart | 109 +++++++++++------- .../load_contexts_integrations_test.dart | 49 +++++++- 3 files changed, 114 insertions(+), 46 deletions(-) diff --git a/packages/dart/lib/src/telemetry/metric/metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart index 7f00dc5aee..03381e753e 100644 --- a/packages/dart/lib/src/telemetry/metric/metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -3,7 +3,7 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; /// The metrics telemetry. -sealed class SentryMetric { +abstract class SentryMetric { final String type; DateTime timestamp; diff --git a/packages/flutter/lib/src/integrations/load_contexts_integration.dart b/packages/flutter/lib/src/integrations/load_contexts_integration.dart index fa9b625ab5..822a1d6182 100644 --- a/packages/flutter/lib/src/integrations/load_contexts_integration.dart +++ b/packages/flutter/lib/src/integrations/load_contexts_integration.dart @@ -1,13 +1,14 @@ +// ignore_for_file: implementation_imports, invalid_use_of_internal_member + import 'dart:async'; import 'package:sentry/sentry.dart'; import 'package:collection/collection.dart'; -// ignore: implementation_imports import 'package:sentry/src/event_processor/enricher/enricher_event_processor.dart'; -// ignore: implementation_imports import 'package:sentry/src/logs_enricher_integration.dart'; import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; +import '../utils/internal_logger.dart'; /// Load Device's Contexts from the iOS & Android SDKs. /// @@ -21,6 +22,7 @@ import '../sentry_flutter_options.dart'; /// This integration is only executed on iOS, macOS & Android Apps. class LoadContextsIntegration extends Integration { final SentryNativeBinding _native; + Map? _cachedAttributes; LoadContextsIntegration(this._native); @@ -45,7 +47,6 @@ class LoadContextsIntegration extends Integration { } if (options.enableLogs) { final logsEnricherIntegration = options.integrations.firstWhereOrNull( - // ignore: invalid_use_of_internal_member (element) => element is LogsEnricherIntegration, ); if (logsEnricherIntegration != null) { @@ -54,55 +55,78 @@ class LoadContextsIntegration extends Integration { options.removeIntegration(logsEnricherIntegration); } - // ignore: invalid_use_of_internal_member options.lifecycleRegistry.registerCallback( (event) async { try { - final infos = await _native.loadContexts() ?? {}; - - final contextsMap = infos['contexts'] as Map?; - final contexts = - Contexts(); // We just need the the native contexts. - _mergeNativeWithLocalContexts(contextsMap, contexts); - - if (contexts.operatingSystem?.name != null) { - event.log.attributes['os.name'] = SentryAttribute.string( - contexts.operatingSystem?.name ?? '', - ); - } - if (contexts.operatingSystem?.version != null) { - event.log.attributes['os.version'] = SentryAttribute.string( - contexts.operatingSystem?.version ?? '', - ); - } - if (contexts.device?.brand != null) { - event.log.attributes['device.brand'] = SentryAttribute.string( - contexts.device?.brand ?? '', - ); - } - if (contexts.device?.model != null) { - event.log.attributes['device.model'] = SentryAttribute.string( - contexts.device?.model ?? '', - ); - } - if (contexts.device?.family != null) { - event.log.attributes['device.family'] = SentryAttribute.string( - contexts.device?.family ?? '', - ); - } + final attributes = await _nativeContextAttributes(); + event.log.attributes.addAllIfAbsent(attributes); } catch (exception, stackTrace) { - options.log( - SentryLevel.error, - 'LoadContextsIntegration failed to load contexts', - exception: exception, + internalLogger.error( + 'LoadContextsIntegration failed to load contexts for $OnBeforeCaptureLog', + error: exception, stackTrace: stackTrace, ); } }, ); } + + if (options.enableMetrics) { + options.lifecycleRegistry + .registerCallback((event) async { + try { + final attributes = await _nativeContextAttributes(); + event.metric.attributes.addAllIfAbsent(attributes); + } catch (exception, stackTrace) { + internalLogger.error( + 'LoadContextsIntegration failed to load contexts for $OnProcessMetric', + error: exception, + stackTrace: stackTrace, + ); + } + }); + } + options.sdk.addIntegration('loadContextsIntegration'); } + + Future> _nativeContextAttributes() async { + if (_cachedAttributes != null) { + return _cachedAttributes!; + } + + final nativeContexts = await _native.loadContexts() ?? {}; + + final contextsMap = nativeContexts['contexts'] as Map?; + final contexts = Contexts(); + _mergeNativeWithLocalContexts(contextsMap, contexts); + + final attributes = {}; + if (contexts.operatingSystem?.name != null) { + attributes[SemanticAttributesConstants.osName] = + SentryAttribute.string(contexts.operatingSystem!.name!); + } + if (contexts.operatingSystem?.version != null) { + attributes[SemanticAttributesConstants.osVersion] = + SentryAttribute.string(contexts.operatingSystem!.version!); + } + if (contexts.device?.brand != null) { + attributes[SemanticAttributesConstants.deviceBrand] = + SentryAttribute.string(contexts.device!.brand!); + } + if (contexts.device?.model != null) { + attributes[SemanticAttributesConstants.deviceModel] = + SentryAttribute.string(contexts.device!.model!); + } + if (contexts.device?.family != null) { + attributes[SemanticAttributesConstants.deviceFamily] = + SentryAttribute.string(contexts.device!.family!); + } + + _cachedAttributes = attributes; + + return attributes; + } } class _LoadContextsIntegrationEventProcessor implements EventProcessor { @@ -247,10 +271,9 @@ class _LoadContextsIntegrationEventProcessor implements EventProcessor { event.tags = tags; } } catch (exception, stackTrace) { - _options.log( - SentryLevel.error, + internalLogger.error( 'loadContextsIntegration failed', - exception: exception, + error: exception, stackTrace: stackTrace, ); if (_options.automatedTestMode) { diff --git a/packages/flutter/test/integrations/load_contexts_integrations_test.dart b/packages/flutter/test/integrations/load_contexts_integrations_test.dart index 4022d7e8cf..b00a96ed88 100644 --- a/packages/flutter/test/integrations/load_contexts_integrations_test.dart +++ b/packages/flutter/test/integrations/load_contexts_integrations_test.dart @@ -1,10 +1,13 @@ @TestOn('vm') library; +// ignore_for_file: invalid_use_of_internal_member + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/load_contexts_integration.dart'; +import 'package:sentry/src/telemetry/metric/metric.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; @@ -422,6 +425,43 @@ void main() { expect(event?.level, SentryLevel.fatal); }); + + test('with metrics enabled adds native attributes to metric', () async { + fixture.options.enableMetrics = true; + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + expect(fixture.options.lifecycleRegistry.lifecycleCallbacks.length, 1); + + final metric = SentryCounterMetric( + timestamp: DateTime.now(), + name: 'random', + value: 1, + traceId: SentryId.newId()); + + await fixture.options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); + + verify(fixture.binding.loadContexts()).called(1); + final attributes = metric.attributes; + expect(attributes[SemanticAttributesConstants.osName]?.value, 'os1'); + expect(attributes[SemanticAttributesConstants.osVersion]?.value, + 'fixture-os-version'); + expect(attributes[SemanticAttributesConstants.deviceBrand]?.value, + 'fixture-brand'); + expect(attributes[SemanticAttributesConstants.deviceModel]?.value, + 'fixture-model'); + expect(attributes[SemanticAttributesConstants.deviceFamily]?.value, + 'fixture-family'); + }); + + test('with metrics disabled does not register callback', () async { + fixture.options.enableMetrics = false; + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + expect(fixture.options.lifecycleRegistry.lifecycleCallbacks.length, 0); + }); } class Fixture { @@ -434,9 +474,14 @@ class Fixture { 'integrations': ['NativeIntegration'], 'package': {'sdk_name': 'native-package', 'version': '1.0'}, 'contexts': { - 'device': {'name': 'Device1'}, + 'device': { + 'name': 'Device1', + 'brand': 'fixture-brand', + 'model': 'fixture-model', + 'family': 'fixture-family', + }, 'app': {'app_name': 'test-app'}, - 'os': {'name': 'os1'}, + 'os': {'name': 'os1', 'version': 'fixture-os-version'}, 'gpu': {'name': 'gpu1'}, 'browser': {'name': 'browser1'}, 'runtime': {'name': 'RT1'}, From c4221aa2866c38476482bf22ccb412fdbb092ddb Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 11:57:10 +0100 Subject: [PATCH 30/42] Update --- .../metric/metric_capture_pipeline.dart | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart index bf8de7dc33..e8ace638cf 100644 --- a/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart +++ b/packages/dart/lib/src/telemetry/metric/metric_capture_pipeline.dart @@ -20,42 +20,54 @@ class MetricCapturePipeline { return; } - if (scope != null) { - metric.attributes.addAllIfAbsent(scope.attributes); - } + try { + if (scope != null) { + metric.attributes.addAllIfAbsent(scope.attributes); + } + + await _options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); - await _options.lifecycleRegistry - .dispatchCallback(OnProcessMetric(metric)); - - metric.attributes.addAllIfAbsent(defaultAttributes(_options, scope: scope)); - - final beforeSendMetric = _options.beforeSendMetric; - SentryMetric? processedMetric = metric; - if (beforeSendMetric != null) { - try { - processedMetric = await beforeSendMetric(metric); - } catch (exception, stackTrace) { - _options.log( - SentryLevel.error, - 'The beforeSendMetric callback threw an exception', - exception: exception, - stackTrace: stackTrace, - ); - if (_options.automatedTestMode) { - rethrow; + metric.attributes + .addAllIfAbsent(defaultAttributes(_options, scope: scope)); + + final beforeSendMetric = _options.beforeSendMetric; + SentryMetric? processedMetric = metric; + if (beforeSendMetric != null) { + try { + processedMetric = await beforeSendMetric(metric); + } catch (exception, stackTrace) { + _options.log( + SentryLevel.error, + 'The beforeSendMetric callback threw an exception', + exception: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } } } - } - if (processedMetric == null) { - _options.recorder - .recordLostEvent(DiscardReason.beforeSend, DataCategory.metric); + if (processedMetric == null) { + _options.recorder + .recordLostEvent(DiscardReason.beforeSend, DataCategory.metric); + internalLogger.debug( + '$MetricCapturePipeline: Metric ${metric.name} dropped by beforeSendMetric'); + return; + } + + _options.telemetryProcessor.addMetric(processedMetric); internalLogger.debug( - '$MetricCapturePipeline: Metric ${metric.name} dropped by beforeSendMetric'); - return; + '$MetricCapturePipeline: Metric ${processedMetric.name} (${processedMetric.type}) captured'); + } catch (exception, stackTrace) { + internalLogger.error( + 'Error capturing metric ${metric.name}', + error: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } } - - _options.telemetryProcessor.addMetric(processedMetric); - internalLogger.debug( - '$MetricCapturePipeline: Metric ${processedMetric.name} (${processedMetric.type}) captured'); } } From 2a553a2006a5b252670184eb0313c66b009db92b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 12:02:27 +0100 Subject: [PATCH 31/42] Update docs --- packages/dart/lib/src/telemetry/metric/metric.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/dart/lib/src/telemetry/metric/metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart index 03381e753e..6e247eec38 100644 --- a/packages/dart/lib/src/telemetry/metric/metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -2,7 +2,10 @@ import 'package:meta/meta.dart'; import '../../../sentry.dart'; -/// The metrics telemetry. +/// Base class for metric data points sent to Sentry. +/// +/// See [SentryCounterMetric], [SentryGaugeMetric], and [SentryDistributionMetric] +/// for concrete metric types. abstract class SentryMetric { final String type; @@ -41,7 +44,7 @@ abstract class SentryMetric { } } -/// Counter metric - increments counts +/// A metric that tracks the number of times an event occurs. final class SentryCounterMetric extends SentryMetric { SentryCounterMetric({ required super.timestamp, @@ -54,7 +57,7 @@ final class SentryCounterMetric extends SentryMetric { }) : super(type: 'counter'); } -/// Gauge metric - tracks values that can go up or down +/// A metric that tracks a value which can increase or decrease over time. final class SentryGaugeMetric extends SentryMetric { SentryGaugeMetric({ required super.timestamp, @@ -67,7 +70,7 @@ final class SentryGaugeMetric extends SentryMetric { }) : super(type: 'gauge'); } -/// Distribution metric - tracks statistical distribution of values +/// A metric that tracks the statistical distribution of a set of values. final class SentryDistributionMetric extends SentryMetric { SentryDistributionMetric({ required super.timestamp, From ba9930cfa9f1138f014c49d235a88d0a80fd66dc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 12:36:02 +0100 Subject: [PATCH 32/42] Add support for 'trace_metric' category in RateLimitParser and corresponding test case --- packages/dart/lib/src/transport/rate_limit_parser.dart | 2 ++ packages/dart/test/protocol/rate_limit_parser_test.dart | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/dart/lib/src/transport/rate_limit_parser.dart b/packages/dart/lib/src/transport/rate_limit_parser.dart index f0fea1dfde..642f53815a 100644 --- a/packages/dart/lib/src/transport/rate_limit_parser.dart +++ b/packages/dart/lib/src/transport/rate_limit_parser.dart @@ -89,6 +89,8 @@ extension _DataCategoryExtension on DataCategory { return DataCategory.metricBucket; case 'log_item': return DataCategory.logItem; + case 'trace_metric': + return DataCategory.metric; } return DataCategory.unknown; } diff --git a/packages/dart/test/protocol/rate_limit_parser_test.dart b/packages/dart/test/protocol/rate_limit_parser_test.dart index 567dec34f0..cf62c03d5c 100644 --- a/packages/dart/test/protocol/rate_limit_parser_test.dart +++ b/packages/dart/test/protocol/rate_limit_parser_test.dart @@ -114,6 +114,14 @@ void main() { expect(sut[0].category, DataCategory.metricBucket); expect(sut[0].namespaces, isEmpty); }); + + test('parse trace_metric category', () { + final sut = RateLimitParser('60:trace_metric').parseRateLimitHeader(); + + expect(sut.length, 1); + expect(sut[0].category, DataCategory.metric); + expect(sut[0].duration.inMilliseconds, 60000); + }); }); group('parseRetryAfterHeader', () { From e3e55a5e4fb9e1b069eb6952fda32c0ff9e554f6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 12:40:54 +0100 Subject: [PATCH 33/42] Update comments --- .../dart/lib/src/telemetry/metric/metric.dart | 16 ++++++++++++++++ .../dart/lib/src/telemetry/metric/metrics.dart | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/dart/lib/src/telemetry/metric/metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart index 6e247eec38..b902bba828 100644 --- a/packages/dart/lib/src/telemetry/metric/metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -7,14 +7,30 @@ import '../../../sentry.dart'; /// See [SentryCounterMetric], [SentryGaugeMetric], and [SentryDistributionMetric] /// for concrete metric types. abstract class SentryMetric { + /// The metric type identifier (e.g., 'counter', 'gauge', 'distribution'). final String type; + /// The time when the metric was recorded. DateTime timestamp; + + /// The metric name, typically using dot notation (e.g., 'app.memory_usage'). String name; + + /// The numeric value of the metric. num value; + + /// The trace ID from the current propagation context. SentryId traceId; + + /// The span ID of the active span when the metric was recorded. SpanId? spanId; + + /// The unit of measurement (e.g., 'millisecond', 'byte'). + /// + /// For a list of supported units, see https://develop.sentry.dev/sdk/telemetry/attributes/#units. String? unit; + + /// Custom key-value pairs attached to the metric. Map attributes; SentryMetric({ diff --git a/packages/dart/lib/src/telemetry/metric/metrics.dart b/packages/dart/lib/src/telemetry/metric/metrics.dart index ae9a826ff9..032b2f0db4 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics.dart @@ -1,10 +1,26 @@ import '../../../sentry.dart'; +/// Interface for emitting custom metrics to Sentry. +/// +/// Access via [Sentry.metrics]. abstract interface class SentryMetrics { + /// Increments a counter metric by the given [value]. + /// + /// Use counters to track the number of times an event occurs. void count(String name, int value, {Map? attributes, Scope? scope}); + + /// Records a value in a distribution metric. + /// + /// Use distributions to track the statistical distribution of values, + /// such as response times or file sizes. void distribution(String name, num value, {String? unit, Map? attributes, Scope? scope}); + + /// Sets the current value of a gauge metric. + /// + /// Use gauges to track values that can increase or decrease over time, + /// such as memory usage or queue depth. void gauge(String name, num value, {String? unit, Map? attributes, Scope? scope}); } From e92efaf202014569e232e9f9f1a87fad527a6430 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 14:32:52 +0100 Subject: [PATCH 34/42] Add SentryMetricUnit constants --- packages/dart/lib/src/sentry_envelope.dart | 32 ++----- packages/dart/lib/src/sentry_options.dart | 2 +- .../dart/lib/src/telemetry/metric/metric.dart | 84 ++++++++++++++++++- .../lib/src/telemetry/metric/metrics.dart | 4 + .../src/telemetry/metric/noop_metrics.dart | 2 - .../dart/lib/src/transport/data_category.dart | 5 -- .../mocks/mock_metric_capture_pipeline.dart | 16 ++-- packages/dart/test/sentry_client_test.dart | 6 +- 8 files changed, 107 insertions(+), 44 deletions(-) diff --git a/packages/dart/lib/src/sentry_envelope.dart b/packages/dart/lib/src/sentry_envelope.dart index 7b2539d1ba..7be52b243a 100644 --- a/packages/dart/lib/src/sentry_envelope.dart +++ b/packages/dart/lib/src/sentry_envelope.dart @@ -104,30 +104,14 @@ class SentryEnvelope { factory SentryEnvelope.fromLogsData( List> encodedLogs, SdkVersion sdkVersion, - ) { - // Create the payload in the format expected by Sentry - // Format: {"items": [log1, log2, ...]} - final builder = BytesBuilder(copy: false); - builder.add(utf8.encode('{"items":[')); - for (int i = 0; i < encodedLogs.length; i++) { - if (i > 0) { - builder.add(utf8.encode(',')); - } - builder.add(encodedLogs[i]); - } - builder.add(utf8.encode(']}')); - - return SentryEnvelope( - SentryEnvelopeHeader( - null, - sdkVersion, - ), - [ - SentryEnvelopeItem.fromLogsData( - builder.takeBytes(), encodedLogs.length), - ], - ); - } + ) => + SentryEnvelope( + SentryEnvelopeHeader(null, sdkVersion), + [ + SentryEnvelopeItem.fromLogsData( + _buildItemsPayload(encodedLogs), encodedLogs.length) + ], + ); /// Create a [SentryEnvelope] containing raw metric data payload. /// This is used by the log batcher to send pre-encoded metric batches. diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 9c202caf07..7ec9b4e424 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -564,7 +564,7 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); @internal - SentryMetrics metrics = NoOpSentryMetrics.instance; + late SentryMetrics metrics = NoOpSentryMetrics(); @internal TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); diff --git a/packages/dart/lib/src/telemetry/metric/metric.dart b/packages/dart/lib/src/telemetry/metric/metric.dart index b902bba828..da3ed4c90c 100644 --- a/packages/dart/lib/src/telemetry/metric/metric.dart +++ b/packages/dart/lib/src/telemetry/metric/metric.dart @@ -27,7 +27,7 @@ abstract class SentryMetric { /// The unit of measurement (e.g., 'millisecond', 'byte'). /// - /// For a list of supported units, see https://develop.sentry.dev/sdk/telemetry/attributes/#units. + /// See [SentryMetricUnit] for predefined unit constants. String? unit; /// Custom key-value pairs attached to the metric. @@ -74,6 +74,8 @@ final class SentryCounterMetric extends SentryMetric { } /// A metric that tracks a value which can increase or decrease over time. +/// +/// See [SentryMetricUnit] for predefined unit constants. final class SentryGaugeMetric extends SentryMetric { SentryGaugeMetric({ required super.timestamp, @@ -87,6 +89,8 @@ final class SentryGaugeMetric extends SentryMetric { } /// A metric that tracks the statistical distribution of a set of values. +/// +/// See [SentryMetricUnit] for predefined unit constants. final class SentryDistributionMetric extends SentryMetric { SentryDistributionMetric({ required super.timestamp, @@ -98,3 +102,81 @@ final class SentryDistributionMetric extends SentryMetric { super.attributes, }) : super(type: 'distribution'); } + +/// String constants for metric units. +/// +/// These constants represent the API names of measurement units that can be +/// used with metrics. +abstract final class SentryMetricUnit { + /// Nanosecond, 10^-9 seconds. + static const String nanosecond = 'nanosecond'; + + /// Microsecond, 10^-6 seconds. + static const String microsecond = 'microsecond'; + + /// Millisecond, 10^-3 seconds. + static const String millisecond = 'millisecond'; + + /// Full second. + static const String second = 'second'; + + /// Minute, 60 seconds. + static const String minute = 'minute'; + + /// Hour, 3600 seconds. + static const String hour = 'hour'; + + /// Day, 86,400 seconds. + static const String day = 'day'; + + /// Week, 604,800 seconds. + static const String week = 'week'; + + /// Bit, corresponding to 1/8 of a byte. + static const String bit = 'bit'; + + /// Byte. + static const String byte = 'byte'; + + /// Kilobyte, 10^3 bytes. + static const String kilobyte = 'kilobyte'; + + /// Kibibyte, 2^10 bytes. + static const String kibibyte = 'kibibyte'; + + /// Megabyte, 10^6 bytes. + static const String megabyte = 'megabyte'; + + /// Mebibyte, 2^20 bytes. + static const String mebibyte = 'mebibyte'; + + /// Gigabyte, 10^9 bytes. + static const String gigabyte = 'gigabyte'; + + /// Gibibyte, 2^30 bytes. + static const String gibibyte = 'gibibyte'; + + /// Terabyte, 10^12 bytes. + static const String terabyte = 'terabyte'; + + /// Tebibyte, 2^40 bytes. + static const String tebibyte = 'tebibyte'; + + /// Petabyte, 10^15 bytes. + static const String petabyte = 'petabyte'; + + /// Pebibyte, 2^50 bytes. + static const String pebibyte = 'pebibyte'; + + /// Exabyte, 10^18 bytes. + static const String exabyte = 'exabyte'; + + /// Exbibyte, 2^60 bytes. + static const String exbibyte = 'exbibyte'; + + /// Floating point fraction of `1`. + static const String ratio = 'ratio'; + + /// Ratio expressed as a fraction of `100`. `100%` equals a ratio of `1.0`. + static const String percent = 'percent'; +} diff --git a/packages/dart/lib/src/telemetry/metric/metrics.dart b/packages/dart/lib/src/telemetry/metric/metrics.dart index 032b2f0db4..10b3a37901 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics.dart @@ -14,6 +14,8 @@ abstract interface class SentryMetrics { /// /// Use distributions to track the statistical distribution of values, /// such as response times or file sizes. + /// + /// See [SentryMetricUnit] for predefined unit constants. void distribution(String name, num value, {String? unit, Map? attributes, Scope? scope}); @@ -21,6 +23,8 @@ abstract interface class SentryMetrics { /// /// Use gauges to track values that can increase or decrease over time, /// such as memory usage or queue depth. + /// + /// See [SentryMetricUnit] for predefined unit constants. void gauge(String name, num value, {String? unit, Map? attributes, Scope? scope}); } diff --git a/packages/dart/lib/src/telemetry/metric/noop_metrics.dart b/packages/dart/lib/src/telemetry/metric/noop_metrics.dart index 125c8b274b..de71339af8 100644 --- a/packages/dart/lib/src/telemetry/metric/noop_metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/noop_metrics.dart @@ -3,8 +3,6 @@ import '../../../sentry.dart'; final class NoOpSentryMetrics implements SentryMetrics { const NoOpSentryMetrics(); - static const instance = NoOpSentryMetrics(); - @override void count(String name, int value, {Map? attributes, Scope? scope}) {} diff --git a/packages/dart/lib/src/transport/data_category.dart b/packages/dart/lib/src/transport/data_category.dart index 5dbba0f392..f271733c6a 100644 --- a/packages/dart/lib/src/transport/data_category.dart +++ b/packages/dart/lib/src/transport/data_category.dart @@ -8,7 +8,6 @@ enum DataCategory { span, attachment, security, - metricBucket, logItem, feedback, metric, @@ -24,10 +23,6 @@ enum DataCategory { return DataCategory.attachment; case 'transaction': return DataCategory.transaction; - // The envelope item type used for metrics is statsd, - // whereas the client report category is metric_bucket - case 'statsd': - return DataCategory.metricBucket; case 'log': return DataCategory.logItem; case 'trace_metric': diff --git a/packages/dart/test/mocks/mock_metric_capture_pipeline.dart b/packages/dart/test/mocks/mock_metric_capture_pipeline.dart index 8f9a5146d7..79de9b5d8a 100644 --- a/packages/dart/test/mocks/mock_metric_capture_pipeline.dart +++ b/packages/dart/test/mocks/mock_metric_capture_pipeline.dart @@ -2,17 +2,17 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/telemetry/metric/metric.dart'; import 'package:sentry/src/telemetry/metric/metric_capture_pipeline.dart'; -class FakeMetricCapturePipeline extends MetricCapturePipeline { - FakeMetricCapturePipeline(super.options); +import 'mock_sentry_client.dart'; - int callCount = 0; - SentryMetric? capturedMetric; - Scope? capturedScope; +class MockMetricCapturePipeline extends MetricCapturePipeline { + MockMetricCapturePipeline(super.options); + + final List captureMetricCalls = []; + + int get callCount => captureMetricCalls.length; @override Future captureMetric(SentryMetric metric, {Scope? scope}) async { - callCount++; - capturedMetric = metric; - capturedScope = scope; + captureMetricCalls.add(CaptureMetricCall(metric, scope)); } } diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index b0b5923ce6..7016e59be0 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -2070,7 +2070,7 @@ void main() { }); test('delegates to metric pipeline', () async { - final pipeline = FakeMetricCapturePipeline(fixture.options); + final pipeline = MockMetricCapturePipeline(fixture.options); final client = SentryClient(fixture.options, metricCapturePipeline: pipeline); final scope = Scope(fixture.options); @@ -2085,8 +2085,8 @@ void main() { await client.captureMetric(metric, scope: scope); expect(pipeline.callCount, 1); - expect(pipeline.capturedMetric, same(metric)); - expect(pipeline.capturedScope, same(scope)); + expect(pipeline.captureMetricCalls.first.metric, same(metric)); + expect(pipeline.captureMetricCalls.first.scope, same(scope)); }); }); From d530afc55addd8dac82d2d1253dfb3b73d5757ea Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 14:36:36 +0100 Subject: [PATCH 35/42] Fix compilation --- packages/dart/lib/src/transport/data_category.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/dart/lib/src/transport/data_category.dart b/packages/dart/lib/src/transport/data_category.dart index f271733c6a..5dbba0f392 100644 --- a/packages/dart/lib/src/transport/data_category.dart +++ b/packages/dart/lib/src/transport/data_category.dart @@ -8,6 +8,7 @@ enum DataCategory { span, attachment, security, + metricBucket, logItem, feedback, metric, @@ -23,6 +24,10 @@ enum DataCategory { return DataCategory.attachment; case 'transaction': return DataCategory.transaction; + // The envelope item type used for metrics is statsd, + // whereas the client report category is metric_bucket + case 'statsd': + return DataCategory.metricBucket; case 'log': return DataCategory.logItem; case 'trace_metric': From eb2cad901c3d99558bd7d160332d897122fba5aa Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 14:41:37 +0100 Subject: [PATCH 36/42] Fix analyze --- packages/dart/lib/src/telemetry/metric/metrics.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dart/lib/src/telemetry/metric/metrics.dart b/packages/dart/lib/src/telemetry/metric/metrics.dart index 10b3a37901..82f0d8f644 100644 --- a/packages/dart/lib/src/telemetry/metric/metrics.dart +++ b/packages/dart/lib/src/telemetry/metric/metrics.dart @@ -1,4 +1,5 @@ import '../../../sentry.dart'; +import 'metric.dart'; /// Interface for emitting custom metrics to Sentry. /// From 3282f7a1b162ed8482e97d5e60c1cad4db0ef8e5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 14:42:39 +0100 Subject: [PATCH 37/42] Update --- packages/dart/lib/src/sentry_options.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 7ec9b4e424..f64b2dafc8 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -563,7 +563,6 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); - @internal late SentryMetrics metrics = NoOpSentryMetrics(); @internal From 71e67b734c29b4e11586e51fd1c26ff3aeebd5fc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 14:45:23 +0100 Subject: [PATCH 38/42] Refactor SentryMetrics handling in SentryOptions to use a private variable with getter and setter --- packages/dart/lib/src/sentry_options.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index f64b2dafc8..6816124023 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -563,7 +563,12 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); - late SentryMetrics metrics = NoOpSentryMetrics(); + late SentryMetrics _metrics = NoOpSentryMetrics(); + + SentryMetrics get metrics => _metrics; + + @internal + set metrics(SentryMetrics value) => _metrics = value; @internal TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); From cd836b524d0c54de8d6a3eb4f062a71ec28d4dd7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 14:50:01 +0100 Subject: [PATCH 39/42] Refactor SentryMetrics in SentryOptions to use a late variable for improved initialization --- packages/dart/lib/src/sentry_options.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 6816124023..7ec9b4e424 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -563,12 +563,8 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); - late SentryMetrics _metrics = NoOpSentryMetrics(); - - SentryMetrics get metrics => _metrics; - @internal - set metrics(SentryMetrics value) => _metrics = value; + late SentryMetrics metrics = NoOpSentryMetrics(); @internal TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); From 26482138a18f2881d5ee4a70ec974ddd23720974 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 15:20:32 +0100 Subject: [PATCH 40/42] Add close --- .../load_contexts_integration.dart | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/flutter/lib/src/integrations/load_contexts_integration.dart b/packages/flutter/lib/src/integrations/load_contexts_integration.dart index 822a1d6182..3d4791d5b1 100644 --- a/packages/flutter/lib/src/integrations/load_contexts_integration.dart +++ b/packages/flutter/lib/src/integrations/load_contexts_integration.dart @@ -20,14 +20,19 @@ import '../utils/internal_logger.dart'; /// App, Device and OS. /// /// This integration is only executed on iOS, macOS & Android Apps. -class LoadContextsIntegration extends Integration { +class LoadContextsIntegration implements Integration { final SentryNativeBinding _native; Map? _cachedAttributes; + SentryFlutterOptions? _options; + SdkLifecycleCallback? _logCallback; + SdkLifecycleCallback? _metricCallback; LoadContextsIntegration(this._native); @override void call(Hub hub, SentryFlutterOptions options) { + _options = options; + options.addEventProcessor( _LoadContextsIntegrationEventProcessor(_native, options), ); @@ -55,25 +60,25 @@ class LoadContextsIntegration extends Integration { options.removeIntegration(logsEnricherIntegration); } + _logCallback = (event) async { + try { + final attributes = await _nativeContextAttributes(); + event.log.attributes.addAllIfAbsent(attributes); + } catch (exception, stackTrace) { + internalLogger.error( + 'LoadContextsIntegration failed to load contexts for $OnBeforeCaptureLog', + error: exception, + stackTrace: stackTrace, + ); + } + }; options.lifecycleRegistry.registerCallback( - (event) async { - try { - final attributes = await _nativeContextAttributes(); - event.log.attributes.addAllIfAbsent(attributes); - } catch (exception, stackTrace) { - internalLogger.error( - 'LoadContextsIntegration failed to load contexts for $OnBeforeCaptureLog', - error: exception, - stackTrace: stackTrace, - ); - } - }, + _logCallback!, ); } if (options.enableMetrics) { - options.lifecycleRegistry - .registerCallback((event) async { + _metricCallback = (event) async { try { final attributes = await _nativeContextAttributes(); event.metric.attributes.addAllIfAbsent(attributes); @@ -84,12 +89,33 @@ class LoadContextsIntegration extends Integration { stackTrace: stackTrace, ); } - }); + }; + options.lifecycleRegistry.registerCallback( + _metricCallback!, + ); } options.sdk.addIntegration('loadContextsIntegration'); } + @override + void close() { + final options = _options; + if (options == null) return; + + if (_logCallback != null) { + options.lifecycleRegistry + .removeCallback(_logCallback!); + _logCallback = null; + } + if (_metricCallback != null) { + options.lifecycleRegistry + .removeCallback(_metricCallback!); + _metricCallback = null; + } + _cachedAttributes = null; + } + Future> _nativeContextAttributes() async { if (_cachedAttributes != null) { return _cachedAttributes!; From 98f48fd5aada4921bedc07d44174e81f85c6606b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 15:25:04 +0100 Subject: [PATCH 41/42] Add close test --- .../load_contexts_integrations_test.dart | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/flutter/test/integrations/load_contexts_integrations_test.dart b/packages/flutter/test/integrations/load_contexts_integrations_test.dart index b00a96ed88..ada1f5bfa9 100644 --- a/packages/flutter/test/integrations/load_contexts_integrations_test.dart +++ b/packages/flutter/test/integrations/load_contexts_integrations_test.dart @@ -462,6 +462,78 @@ void main() { expect(fixture.options.lifecycleRegistry.lifecycleCallbacks.length, 0); }); + + test('close removes metric callback from lifecycle registry', () async { + fixture.options.enableMetrics = true; + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + expect(fixture.options.lifecycleRegistry.lifecycleCallbacks.length, 1); + + integration.close(); + + expect( + fixture.options.lifecycleRegistry.lifecycleCallbacks[OnProcessMetric], + isEmpty); + }); + + test('close removes log callback from lifecycle registry', () async { + fixture.options.enableLogs = true; + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + expect( + fixture + .options.lifecycleRegistry.lifecycleCallbacks[OnBeforeCaptureLog], + isNotEmpty); + + integration.close(); + + expect( + fixture + .options.lifecycleRegistry.lifecycleCallbacks[OnBeforeCaptureLog], + isEmpty); + }); + + test('close removes both callbacks when both features enabled', () async { + fixture.options.enableMetrics = true; + fixture.options.enableLogs = true; + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + expect(fixture.options.lifecycleRegistry.lifecycleCallbacks.length, 2); + + integration.close(); + + expect( + fixture.options.lifecycleRegistry.lifecycleCallbacks[OnProcessMetric], + isEmpty); + expect( + fixture + .options.lifecycleRegistry.lifecycleCallbacks[OnBeforeCaptureLog], + isEmpty); + }); + + test('callback is not invoked after close', () async { + fixture.options.enableMetrics = true; + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + integration.close(); + + final metric = SentryCounterMetric( + timestamp: DateTime.now(), + name: 'random', + value: 1, + traceId: SentryId.newId()); + + await fixture.options.lifecycleRegistry + .dispatchCallback(OnProcessMetric(metric)); + + // loadContexts should not be called since callback was removed + verifyNever(fixture.binding.loadContexts()); + expect(metric.attributes, isEmpty); + }); } class Fixture { From 747259d236bd2ff42dd217e4757f2b70e8c8f650 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 20 Jan 2026 18:10:13 +0100 Subject: [PATCH 42/42] Update SentryMetrics initialization in SentryOptions to use a constant instance of NoOpSentryMetrics --- packages/dart/lib/src/sentry_options.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 7ec9b4e424..0db9f3b527 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -564,7 +564,7 @@ class SentryOptions { late final SentryLogger logger = SentryLogger(clock); @internal - late SentryMetrics metrics = NoOpSentryMetrics(); + late SentryMetrics metrics = const NoOpSentryMetrics(); @internal TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor();