diff --git a/packages/dart/lib/src/protocol/noop_span.dart b/packages/dart/lib/src/protocol/noop_span.dart index 09284532c0..72dd188ff8 100644 --- a/packages/dart/lib/src/protocol/noop_span.dart +++ b/packages/dart/lib/src/protocol/noop_span.dart @@ -41,4 +41,10 @@ class NoOpSpan implements Span { @override Map toJson() => {}; + + @override + Span get segmentSpan => this; + + @override + SentryId get traceId => SentryId.empty(); } diff --git a/packages/dart/lib/src/protocol/sentry_log.dart b/packages/dart/lib/src/protocol/sentry_log.dart index 6612b8d700..eb33430c78 100644 --- a/packages/dart/lib/src/protocol/sentry_log.dart +++ b/packages/dart/lib/src/protocol/sentry_log.dart @@ -1,8 +1,9 @@ +import '../telemetry_processing/json_encodable.dart'; import 'sentry_attribute.dart'; import 'sentry_id.dart'; import 'sentry_log_level.dart'; -class SentryLog { +class SentryLog implements JsonEncodable { DateTime timestamp; SentryId traceId; SentryLogLevel level; @@ -21,6 +22,7 @@ class SentryLog { this.severityNumber, }) : traceId = traceId ?? SentryId.empty(); + @override Map toJson() { return { 'timestamp': timestamp.toIso8601String(), diff --git a/packages/dart/lib/src/protocol/simple_span.dart b/packages/dart/lib/src/protocol/simple_span.dart index 8c9d556ee4..3d7d86c202 100644 --- a/packages/dart/lib/src/protocol/simple_span.dart +++ b/packages/dart/lib/src/protocol/simple_span.dart @@ -6,6 +6,9 @@ class SimpleSpan implements Span { @override final Span? parentSpan; final Map _attributes = {}; + late final DateTime _startTimestamp; + late final Span _segmentSpan; + late final SentryId _traceId; String _name; SpanV2Status _status = SpanV2Status.ok; @@ -18,7 +21,16 @@ class SimpleSpan implements Span { Hub? hub, }) : _spanId = SpanId.newId(), _hub = hub ?? HubAdapter(), - _name = name; + _name = name { + _segmentSpan = parentSpan?.segmentSpan ?? this; + _startTimestamp = _hub.options.clock(); + _traceId = parentSpan != null + ? parentSpan!.traceId + : _hub.scope.propagationContext.traceId; + } + + @override + SentryId get traceId => _traceId; @override SpanId get spanId => _spanId; @@ -44,6 +56,9 @@ class SimpleSpan implements Span { @override bool get isFinished => _isFinished; + @override + Span get segmentSpan => _segmentSpan; + @override void setAttribute(String key, SentryAttribute value) { _attributes[key] = value; @@ -59,14 +74,29 @@ class SimpleSpan implements Span { if (_isFinished) { return; } - _endTimestamp = (endTimestamp ?? DateTime.now()).toUtc(); + _endTimestamp = (endTimestamp?.toUtc() ?? _hub.options.clock()); _isFinished = true; _hub.captureSpan(this); } @override Map toJson() { - // TODO: implement toJson - throw UnimplementedError(); + double toUnixSeconds(DateTime timestamp) => + timestamp.microsecondsSinceEpoch / 1000000; + + return { + 'trace_id': _traceId.toString(), + 'span_id': _spanId.toString(), + 'is_segment': parentSpan == null, + 'name': _name, + 'status': _status.name, + 'end_timestamp': + _endTimestamp == null ? null : toUnixSeconds(_endTimestamp!), + 'start_timestamp': toUnixSeconds(_startTimestamp), + if (parentSpan != null) 'parent_span_id': parentSpan?.spanId.toString(), + if (_attributes.isNotEmpty) + 'attributes': + _attributes.map((key, value) => MapEntry(key, value.toJson())), + }; } } diff --git a/packages/dart/lib/src/protocol/span.dart b/packages/dart/lib/src/protocol/span.dart index a5f4ba71ca..b7b3be4480 100644 --- a/packages/dart/lib/src/protocol/span.dart +++ b/packages/dart/lib/src/protocol/span.dart @@ -1,14 +1,18 @@ import 'package:meta/meta.dart'; import '../../sentry.dart'; +import '../telemetry_processing/json_encodable.dart'; // Span specs: https://develop.sentry.dev/sdk/telemetry/spans/span-api/ /// Represents a basic telemetry span. -abstract class Span { +abstract class Span implements JsonEncodable { @internal const Span(); + /// Gets the id of the trace this span belongs to. + SentryId get traceId; + /// Gets the id of the span. SpanId get spanId; @@ -51,9 +55,13 @@ abstract class Span { /// Overrides if the attributes already exist. void setAttributes(Map attributes); + @internal + Span get segmentSpan; + @internal bool get isFinished; @internal + @override Map toJson(); } diff --git a/packages/dart/lib/src/protocol/unset_span.dart b/packages/dart/lib/src/protocol/unset_span.dart index f1cc8ea17e..9f961238e3 100644 --- a/packages/dart/lib/src/protocol/unset_span.dart +++ b/packages/dart/lib/src/protocol/unset_span.dart @@ -8,59 +8,51 @@ import '../../sentry.dart'; class UnsetSpan extends Span { const UnsetSpan(); + static Never _throw() => + throw UnimplementedError('$UnsetSpan APIs should not be used'); + + @override + SpanId get spanId => _throw(); + + @override + String get name => _throw(); + @override - SpanId get spanId => - throw UnimplementedError('$UnsetSpan apis should not be used'); + set name(String name) => _throw(); @override - String get name => - throw UnimplementedError('$UnsetSpan apis should not be used'); + SpanV2Status get status => _throw(); @override - set name(String name) => - throw UnimplementedError('$UnsetSpan apis should not be used'); + set status(SpanV2Status status) => _throw(); @override - SpanV2Status get status => - throw UnimplementedError('$UnsetSpan apis should not be used'); + Span? get parentSpan => _throw(); @override - set status(SpanV2Status status) => - throw UnimplementedError('$UnsetSpan apis should not be used'); + DateTime? get endTimestamp => _throw(); @override - Span? get parentSpan => - throw UnimplementedError('$UnsetSpan apis should not be used'); + Map get attributes => _throw(); @override - DateTime? get endTimestamp => - throw UnimplementedError('$UnsetSpan apis should not be used'); + bool get isFinished => _throw(); @override - Map get attributes => - throw UnimplementedError('$UnsetSpan apis should not be used'); + void setAttribute(String key, SentryAttribute value) => _throw(); @override - bool get isFinished => - throw UnimplementedError('$UnsetSpan apis should not be used'); + void setAttributes(Map attributes) => _throw(); @override - void setAttribute(String key, SentryAttribute value) { - throw UnimplementedError('$UnsetSpan apis should not be used'); - } + void end({DateTime? endTimestamp}) => _throw(); @override - void setAttributes(Map attributes) { - throw UnimplementedError('$UnsetSpan apis should not be used'); - } + Map toJson() => _throw(); @override - void end({DateTime? endTimestamp}) { - throw UnimplementedError('$UnsetSpan apis should not be used'); - } + Span get segmentSpan => _throw(); @override - Map toJson() { - throw UnimplementedError('$UnsetSpan apis should not be used'); - } + SentryId get traceId => _throw(); } diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index b53eb998ac..3d9cc8b136 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -18,6 +18,8 @@ import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'sentry_trace_context_header.dart'; +import 'telemetry_processing/telemetry_buffer.dart'; +import 'telemetry_processing/telemetry_processor.dart'; import 'transport/client_report_transport.dart'; import 'transport/data_category.dart'; import 'transport/http_transport.dart'; @@ -78,6 +80,10 @@ class SentryClient { if (options.enableLogs) { options.logBatcher = SentryLogBatcher(options); } + options.telemetryProcessor = DefaultTelemetryProcessor(options.log, + logBuffer: InMemoryTelemetryBuffer(), + spanBuffer: InMemoryTelemetryBuffer()); + // TODO(next-pr): remove log batcher return SentryClient._(options); } @@ -588,6 +594,7 @@ class SentryClient { } FutureOr close() { + // TODO(next-pr): replace with telemetry processor final flush = _options.logBatcher.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 ba92e6df89..9cff6a0488 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -12,6 +12,7 @@ import 'noop_client.dart'; import 'platform/platform.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; +import 'telemetry_processing/telemetry_processor.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; import 'sentry_log_batcher.dart'; @@ -543,6 +544,9 @@ class SentryOptions { @internal SentryLogBatcher logBatcher = NoopLogBatcher(); + @internal + TelemetryProcessor telemetryProcessor = NoOpTelemetryProcessor(); + SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) { this.dsn = dsn; if (platform != null) { diff --git a/packages/dart/lib/src/telemetry_processing/json_encodable.dart b/packages/dart/lib/src/telemetry_processing/json_encodable.dart new file mode 100644 index 0000000000..3e85e51e8d --- /dev/null +++ b/packages/dart/lib/src/telemetry_processing/json_encodable.dart @@ -0,0 +1,3 @@ +abstract interface class JsonEncodable { + Map toJson(); +} diff --git a/packages/dart/lib/src/telemetry_processing/telemetry_buffer.dart b/packages/dart/lib/src/telemetry_processing/telemetry_buffer.dart new file mode 100644 index 0000000000..66faafbf9a --- /dev/null +++ b/packages/dart/lib/src/telemetry_processing/telemetry_buffer.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import '../../sentry.dart'; +import 'json_encodable.dart'; + +/// 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 clear(); +} + +/// Pairs an item with its encoded bytes for size tracking and transmission. +class BufferedItem { + final T item; + final List encoded; + + BufferedItem(this.item, this.encoded); +} + +/// In-memory buffer with time and size-based flushing. +class InMemoryTelemetryBuffer + extends TelemetryBuffer { + InMemoryTelemetryBuffer(); + + @override + void add(T item) { + final encoded = utf8JsonEncoder.convert(item.toJson()); + final _ = BufferedItem(item, encoded); + // TODO(next-pr): finish this impl + } + + @override + FutureOr clear() { + // TODO(next-pr): finish this impl + } +} diff --git a/packages/dart/lib/src/telemetry_processing/telemetry_processor.dart b/packages/dart/lib/src/telemetry_processing/telemetry_processor.dart new file mode 100644 index 0000000000..378349bc33 --- /dev/null +++ b/packages/dart/lib/src/telemetry_processing/telemetry_processor.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../sentry.dart'; +import '../protocol/noop_span.dart'; +import '../protocol/unset_span.dart'; +import 'telemetry_buffer.dart'; + +/// Manages buffering and sending of telemetry data to Sentry. +abstract class TelemetryProcessor { + /// Adds a span to the buffer. + void addSpan(Span span); + + /// Adds a log to the buffer. + void addLog(SentryLog log); + + /// Clears all buffers which sends any pending telemetry data. + FutureOr clear(); +} + +/// Manages buffering and sending of telemetry data to Sentry. +class DefaultTelemetryProcessor implements TelemetryProcessor { + final SdkLogCallback _logger; + + /// Buffer for span telemetry data. + @visibleForTesting + TelemetryBuffer? spanBuffer; + + /// Buffer for log telemetry data. + @visibleForTesting + TelemetryBuffer? logBuffer; + + DefaultTelemetryProcessor( + this._logger, { + this.spanBuffer, + this.logBuffer, + }); + + @override + void addSpan(Span span) => + (span is NoOpSpan || span is UnsetSpan) ? null : _add(span); + + @override + void addLog(SentryLog log) => _add(log); + + void _add(dynamic item) { + final buffer = switch (item) { + Span _ => spanBuffer, + SentryLog _ => logBuffer, + _ => null, + }; + + if (buffer == null) { + _logger( + SentryLevel.warning, + 'TelemetryProcessor: No buffer registered for ${item.runtimeType} - item was dropped', + ); + return; + } + + buffer.add(item); + } + + @override + FutureOr clear() { + _logger(SentryLevel.debug, 'TelemetryProcessor: Clearing buffers'); + + final results = >[ + spanBuffer?.clear(), + logBuffer?.clear(), + ]; + + final futures = results.whereType().toList(); + if (futures.isEmpty) { + return null; + } + + return Future.wait(futures).then((_) {}); + } +} + +class NoOpTelemetryProcessor implements TelemetryProcessor { + @override + void addSpan(Span span) {} + + @override + void addLog(SentryLog log) {} + + @override + FutureOr clear() {} +} 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..9ff63de2be --- /dev/null +++ b/packages/dart/test/mocks/mock_telemetry_buffer.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:sentry/src/telemetry_processing/telemetry_buffer.dart'; + +class MockTelemetryBuffer extends TelemetryBuffer { + final List addedItems = []; + int clearCallCount = 0; + final bool asyncClear; + + MockTelemetryBuffer({this.asyncClear = false}); + + @override + void add(T item) => addedItems.add(item); + + @override + FutureOr clear() { + clearCallCount++; + if (asyncClear) { + return Future.value(); + } + return null; + } +} diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index 534f665774..d4654f8c70 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -11,6 +11,7 @@ import 'package:sentry/src/platform/mock_platform.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/telemetry_processing/telemetry_processor.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'; @@ -1715,6 +1716,23 @@ void main() { }); }); + group('SentryClient telemetryProcessor', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('sets default telemetry processor when client is initialized', () { + expect(fixture.options.telemetryProcessor, isA()); + + fixture.getSut(); + + expect( + fixture.options.telemetryProcessor, isA()); + }); + }); + group('SentryClient captureLog', () { late Fixture fixture; diff --git a/packages/dart/test/span_test.dart b/packages/dart/test/span_test.dart index b85112b278..7e31c38919 100644 --- a/packages/dart/test/span_test.dart +++ b/packages/dart/test/span_test.dart @@ -158,6 +158,200 @@ void main() { expect(span.spanId.toString(), isNot(SpanId.empty().toString())); }); + + group('segmentSpan', () { + test('returns itself when parentSpan is null', () { + final hub = fixture.getHub(); + final span = SimpleSpan(name: 'root-span', parentSpan: null, hub: hub); + + expect(span.segmentSpan, same(span)); + }); + + test('returns parent segmentSpan when parentSpan is set', () { + final hub = fixture.getHub(); + final root = SimpleSpan(name: 'root', parentSpan: null, hub: hub); + final child = SimpleSpan(name: 'child', parentSpan: root, hub: hub); + + expect(child.segmentSpan, same(root)); + }); + + test('returns root segmentSpan for deeply nested spans', () { + final hub = fixture.getHub(); + final root = SimpleSpan(name: 'root', parentSpan: null, hub: hub); + final child = SimpleSpan(name: 'child', parentSpan: root, hub: hub); + final grandchild = + SimpleSpan(name: 'grandchild', parentSpan: child, hub: hub); + final greatGrandchild = SimpleSpan( + name: 'great-grandchild', parentSpan: grandchild, hub: hub); + + expect(grandchild.segmentSpan, same(root)); + expect(greatGrandchild.segmentSpan, same(root)); + }); + }); + + group('traceId from scope', () { + test('uses traceId from hub scope propagationContext', () { + final hub = fixture.getHub(); + final expectedTraceId = hub.scope.propagationContext.traceId; + + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + expect(span.traceId, equals(expectedTraceId)); + }); + + test('child span has same traceId as parent', () { + final hub = fixture.getHub(); + final parent = SimpleSpan(name: 'parent', parentSpan: null, hub: hub); + final child = SimpleSpan(name: 'child', parentSpan: parent, hub: hub); + + expect(child.traceId, equals(parent.traceId)); + }); + + test( + 'child span inherits traceId from parent even after propagation context reset', + () { + final hub = fixture.getHub(); + final parent = SimpleSpan(name: 'parent', parentSpan: null, hub: hub); + final parentTraceId = parent.traceId; + + // Reset the propagation context - this would change the scope's traceId + hub.scope.propagationContext.resetTrace(); + final newPropagationTraceId = hub.scope.propagationContext.traceId; + + // Create child span after reset + final child = SimpleSpan(name: 'child', parentSpan: parent, hub: hub); + + // Child should inherit from parent, not from the new propagation context + expect(child.traceId, equals(parentTraceId)); + expect(child.traceId, isNot(equals(newPropagationTraceId))); + }); + + test('traceId is set at construction time', () { + final hub = fixture.getHub(); + final originalTraceId = hub.scope.propagationContext.traceId; + + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + // Change the propagation context after span creation + hub.scope.propagationContext.resetTrace(); + final newTraceId = hub.scope.propagationContext.traceId; + + // Span should still have the original traceId + expect(span.traceId, equals(originalTraceId)); + expect(span.traceId, isNot(equals(newTraceId))); + }); + }); + + group('toJson', () { + test('serializes basic span without parent', () { + final hub = fixture.getMockHub(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + 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 hub = fixture.getMockHub(); + final parent = SimpleSpan(name: 'parent', parentSpan: null, hub: hub); + final child = SimpleSpan(name: 'child', parentSpan: parent, hub: hub); + 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 hub = fixture.getMockHub(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + span.status = SpanV2Status.error; + span.end(); + + final json = span.toJson(); + + expect(json['status'], equals('error')); + }); + + test('serializes span with attributes', () { + final hub = fixture.getMockHub(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + 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 = json['attributes'] as Map; + + 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('does not include attributes key when no attributes set', () { + final hub = fixture.getMockHub(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + span.end(); + + final json = span.toJson(); + + expect(json.containsKey('attributes'), isFalse); + }); + + test('end_timestamp is null when span is not finished', () { + final hub = fixture.getMockHub(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + final json = span.toJson(); + + expect(json['end_timestamp'], isNull); + }); + + test( + 'timestamps are serialized as unix seconds with microsecond precision', + () { + final hub = fixture.getMockHub(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + 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; + // 2024-06-15 12:30:45.123456 UTC in microseconds since epoch + final expectedMicros = customEndTime.microsecondsSinceEpoch; + final expectedSeconds = expectedMicros / 1000000; + + expect(endTimestamp, closeTo(expectedSeconds, 0.000001)); + }); + + test('serializes updated name', () { + final hub = fixture.getMockHub(); + final span = + SimpleSpan(name: 'original-name', parentSpan: null, hub: hub); + span.name = 'updated-name'; + span.end(); + + final json = span.toJson(); + + expect(json['name'], equals('updated-name')); + }); + }); }); group('NoOpSpan', () { diff --git a/packages/dart/test/telemetry_processing/telemetry_processor_test.dart b/packages/dart/test/telemetry_processing/telemetry_processor_test.dart new file mode 100644 index 0000000000..5e62c14a82 --- /dev/null +++ b/packages/dart/test/telemetry_processing/telemetry_processor_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/protocol/noop_span.dart'; +import 'package:sentry/src/protocol/simple_span.dart'; +import 'package:sentry/src/protocol/unset_span.dart'; +import 'package:sentry/src/telemetry_processing/telemetry_processor.dart'; +import 'package:test/test.dart'; + +import '../mocks/mock_hub.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 + }); + + test('NoOpSpan cannot be added to buffer', () { + final mockSpanBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + + const noOpSpan = NoOpSpan(); + processor.addSpan(noOpSpan); + + expect(mockSpanBuffer.addedItems, isEmpty); + }); + + test('UnsetSpan cannot be added to buffer', () { + final mockSpanBuffer = MockTelemetryBuffer(); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + + const noOpSpan = UnsetSpan(); + processor.addSpan(noOpSpan); + + expect(mockSpanBuffer.addedItems, isEmpty); + }); + }); + + 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.clear(); + + expect(mockSpanBuffer.clearCallCount, 1); + expect(mockLogBuffer.clearCallCount, 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.clear(); + + expect(mockSpanBuffer.clearCallCount, 1); + }); + + test('returns sync (null) when all buffers flush synchronously', () { + final mockSpanBuffer = MockTelemetryBuffer(asyncClear: false); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + processor.logBuffer = null; + + final result = processor.clear(); + + expect(result, isNull); + }); + + test('returns Future when at least one buffer flushes asynchronously', + () async { + final mockSpanBuffer = MockTelemetryBuffer(asyncClear: true); + final processor = fixture.getSut(spanBuffer: mockSpanBuffer); + processor.logBuffer = null; + + final result = processor.clear(); + + expect(result, isA()); + await result; + }); + }); + }); +} + +class Fixture { + final hub = MockHub(); + + 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, + ); + } + + SimpleSpan createSpan({String name = 'test-span'}) { + return SimpleSpan(name: name, hub: hub); + } + + SimpleSpan createChildSpan( + {required Span parent, String name = 'child-span'}) { + return SimpleSpan(name: name, parentSpan: parent, hub: hub); + } + + SentryLog createLog({String body = 'test log'}) { + return SentryLog( + timestamp: DateTime.now().toUtc(), + level: SentryLogLevel.info, + body: body, + attributes: {}, + ); + } +}