diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index be2d4ddd5f..b8a6eecd67 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -11,6 +11,7 @@ import 'protocol/unset_span.dart'; import 'sentry_tracer.dart'; import 'sentry_traces_sampler.dart'; import 'protocol/noop_span.dart'; +import 'protocol/simple_span.dart'; import 'transport/data_category.dart'; /// Configures the scope through the callback. @@ -586,12 +587,48 @@ class Hub { SentryLevel.warning, "Instance is disabled and this 'startSpan' call is a no-op.", ); - } else if (_options.isTracingEnabled()) { - // TODO: implementation of span api behaviour according to https://develop.sentry.dev/sdk/telemetry/spans/span-api/ return NoOpSpan(); } - return NoOpSpan(); + if (!_options.isTracingEnabled()) { + return NoOpSpan(); + } + + // Determine the parent span based on the parentSpan parameter: + // - If parentSpan is UnsetSpan (default), use the currently active span + // - If parentSpan is a specific Span, use that as the parent + // - If parentSpan is null, create a root/segment span (no parent) + final Span? resolvedParentSpan; + if (parentSpan is UnsetSpan) { + resolvedParentSpan = scope.getActiveSpan(); + } else { + resolvedParentSpan = parentSpan; + } + + final span = + SimpleSpan(name: name, parentSpan: resolvedParentSpan, hub: this); + if (attributes != null) { + span.setAttributes(attributes); + } + if (active) { + scope.setActiveSpan(span); + } + + return span; + } + + void captureSpan(Span span) { + if (!_isEnabled) { + _options.log( + SentryLevel.warning, + "Instance is disabled and this 'captureSpan' call is a no-op.", + ); + return; + } + + scope.removeActiveSpan(span); + + // TODO: run this span through span specific pipeline and then forward to span buffer } @internal diff --git a/packages/dart/lib/src/hub_adapter.dart b/packages/dart/lib/src/hub_adapter.dart index 1203bcc1f3..1e9ddf4425 100644 --- a/packages/dart/lib/src/hub_adapter.dart +++ b/packages/dart/lib/src/hub_adapter.dart @@ -221,4 +221,7 @@ class HubAdapter implements Hub { @override void removeAttribute(String key) => Sentry.currentHub.removeAttribute(key); + + @override + void captureSpan(Span span) => Sentry.currentHub.captureSpan(span); } diff --git a/packages/dart/lib/src/noop_hub.dart b/packages/dart/lib/src/noop_hub.dart index 6e8845a84f..44b1c82f87 100644 --- a/packages/dart/lib/src/noop_hub.dart +++ b/packages/dart/lib/src/noop_hub.dart @@ -161,4 +161,7 @@ class NoOpHub implements Hub { Map? attributes, }) => NoOpSpan(); + + @override + void captureSpan(Span span) {} } diff --git a/packages/dart/lib/src/protocol/noop_span.dart b/packages/dart/lib/src/protocol/noop_span.dart index 80b8b611a1..c0980fe4e8 100644 --- a/packages/dart/lib/src/protocol/noop_span.dart +++ b/packages/dart/lib/src/protocol/noop_span.dart @@ -3,9 +3,18 @@ import '../../sentry.dart'; class NoOpSpan implements Span { const NoOpSpan(); + @override + final String name = 'NoOpSpan'; + + @override + final SpanV2Status status = SpanV2Status.ok; + @override void end({DateTime? endTimestamp}) {} + @override + Span? get parentSpan => null; + @override void setAttribute(String key, SentryAttribute value) {} @@ -13,11 +22,20 @@ class NoOpSpan implements Span { void setAttributes(Map attributes) {} @override - void setName(String name) {} + Map toJson() => {}; + + @override + set name(String name) {} @override - void setStatus(SpanV2Status status) {} + set status(SpanV2Status status) {} @override - Map toJson() => {}; + Map get attributes => {}; + + @override + DateTime? get endTimestamp => null; + + @override + bool get isFinished => false; } diff --git a/packages/dart/lib/src/protocol/simple_span.dart b/packages/dart/lib/src/protocol/simple_span.dart index dc31156916..1e00257975 100644 --- a/packages/dart/lib/src/protocol/simple_span.dart +++ b/packages/dart/lib/src/protocol/simple_span.dart @@ -1,31 +1,66 @@ import '../../sentry.dart'; class SimpleSpan implements Span { + final Hub hub; + final Map _attributes = {}; + @override - void end({DateTime? endTimestamp}) { - // TODO: implement end + final Span? parentSpan; + + String _name; + SpanV2Status _status = SpanV2Status.ok; + DateTime? _endTimestamp; + bool _isFinished = false; + + SimpleSpan({ + required String name, + this.parentSpan, + Hub? hub, + }) : hub = hub ?? HubAdapter(), + _name = name; + + @override + DateTime? get endTimestamp => _endTimestamp; + + @override + Map get attributes => Map.unmodifiable(_attributes); + + @override + String get name => _name; + + @override + set name(String value) { + _name = value; } @override - void setAttribute(String key, SentryAttribute value) { - // TODO: implement setAttribute + SpanV2Status get status => _status; + + @override + set status(SpanV2Status value) { + _status = value; } @override - void setAttributes(Map attributes) { - // TODO: implement setAttributes + void end({DateTime? endTimestamp}) { + _endTimestamp = endTimestamp ?? DateTime.now().toUtc(); + hub.captureSpan(this); + _isFinished = true; } @override - void setName(String name) { - // TODO: implement setName + void setAttribute(String key, SentryAttribute value) { + _attributes[key] = value; } @override - void setStatus(SpanV2Status status) { - // TODO: implement setStatus + void setAttributes(Map attributes) { + _attributes.addAll(attributes); } + @override + bool get isFinished => _isFinished; + @override Map toJson() { // TODO: implement toJson diff --git a/packages/dart/lib/src/protocol/span.dart b/packages/dart/lib/src/protocol/span.dart index 6759ada6ea..28efff7ef9 100644 --- a/packages/dart/lib/src/protocol/span.dart +++ b/packages/dart/lib/src/protocol/span.dart @@ -2,15 +2,41 @@ import 'package:meta/meta.dart'; import '../../sentry.dart'; -/// Represents the Span model based on https://develop.sentry.dev/sdk/telemetry/spans/span-api/ +// Span specs: https://develop.sentry.dev/sdk/telemetry/spans/span-api/ + +/// Represents a basic telemetry span. abstract class Span { @internal const Span(); + /// Gets the name of the span. + String get name; + + /// Sets the name of the span. + set name(String name); + + /// Gets the parent span. + /// If null this span has no parent. + Span? get parentSpan; + + /// Gets the status of the span. + SpanV2Status get status; + + /// Sets the status of the span. + set status(SpanV2Status status); + + /// Gets the end timestamp of the span. + DateTime? get endTimestamp; + + /// Gets a read-only view of the attributes of the span. + /// + /// The returned map must not be mutated by callers. + Map get attributes; + /// Ends the span. /// /// [endTimestamp] can be used to override the end time. - /// If omitted, the span ends using the current time. + /// If omitted, the span ends using the current time when end is executed. void end({DateTime? endTimestamp}); /// Sets a single attribute. @@ -23,11 +49,8 @@ abstract class Span { /// Overrides if the attributes already exist. void setAttributes(Map attributes); - /// Sets the status of the span. - void setStatus(SpanV2Status status); - - /// Sets the name of the span. - void setName(String name); + @internal + bool get isFinished; @internal Map toJson(); diff --git a/packages/dart/lib/src/protocol/unset_span.dart b/packages/dart/lib/src/protocol/unset_span.dart index 2dfbae74ce..ed7303e6c0 100644 --- a/packages/dart/lib/src/protocol/unset_span.dart +++ b/packages/dart/lib/src/protocol/unset_span.dart @@ -8,6 +8,14 @@ import '../../sentry.dart'; class UnsetSpan extends Span { const UnsetSpan(); + @override + String get name => + throw UnimplementedError('$UnsetSpan methods should not be used'); + + @override + Span? get parentSpan => + throw UnimplementedError('$UnsetSpan methods should not be used'); + @override void end({DateTime? endTimestamp}) { throw UnimplementedError('$UnsetSpan methods should not be used'); @@ -24,17 +32,29 @@ class UnsetSpan extends Span { } @override - void setName(String name) { + Map toJson() { throw UnimplementedError('$UnsetSpan methods should not be used'); } @override - void setStatus(SpanV2Status status) { - throw UnimplementedError('$UnsetSpan methods should not be used'); + set name(String name) { + throw UnimplementedError(); } @override - Map toJson() { - throw UnimplementedError('$UnsetSpan methods should not be used'); + set status(SpanV2Status status) { + throw UnimplementedError(); } + + @override + SpanV2Status get status => throw UnimplementedError(); + + @override + Map get attributes => throw UnimplementedError(); + + @override + DateTime? get endTimestamp => throw UnimplementedError(); + + @override + bool get isFinished => throw UnimplementedError(); } diff --git a/packages/dart/lib/src/scope.dart b/packages/dart/lib/src/scope.dart index 6024022210..f1382d9ede 100644 --- a/packages/dart/lib/src/scope.dart +++ b/packages/dart/lib/src/scope.dart @@ -43,6 +43,42 @@ class Scope { /// Returns active transaction or null if there is no active transaction. ISentrySpan? span; + /// List of active spans. + /// The last span in the list is the current active span. + final List _activeSpans = []; + + @visibleForTesting + List get activeSpans => List.unmodifiable(_activeSpans); + + /// Returns the currently active span, or `null` if no span is active. + /// + /// The active span is the most recently set span via [setActiveSpan]. + /// When starting a new span with `active: true` (the default), the new span + /// becomes a child of this active span. + @internal + Span? getActiveSpan() { + return _activeSpans.lastOrNull; + } + + /// Sets the given [span] as the currently active span. + /// + /// Active spans are used to automatically parent new spans. + /// When a new span is started with `active: true` (the default), it becomes + /// a child of the currently active span. + @internal + void setActiveSpan(Span span) { + _activeSpans.add(span); + } + + /// Removes the given [span] from the active spans list. + /// + /// This should be called when a span ends to remove it from the active + /// span list. + @internal + void removeActiveSpan(Span span) { + _activeSpans.remove(span); + } + /// The propagation context for connecting errors and spans to traces. /// There should always be a propagation context available at all times. /// @@ -273,6 +309,7 @@ class Scope { _replayId = null; propagationContext = PropagationContext(); _attributes.clear(); + _activeSpans.clear(); _clearBreadcrumbsSync(); _setUserSync(null); @@ -480,6 +517,10 @@ class Scope { clone.setAttributes(Map.from(_attributes)); } + if (_activeSpans.isNotEmpty) { + clone._activeSpans.addAll(_activeSpans); + } + return clone; } diff --git a/packages/dart/test/hub_span_test.dart b/packages/dart/test/hub_span_test.dart new file mode 100644 index 0000000000..5f78608aff --- /dev/null +++ b/packages/dart/test/hub_span_test.dart @@ -0,0 +1,286 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/protocol/noop_span.dart'; +import 'package:sentry/src/protocol/simple_span.dart'; +import 'package:test/test.dart'; + +import 'mocks/mock_client_report_recorder.dart'; +import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; + +void main() { + group('Hub', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('startSpan', () { + group('span creation', () { + test('returns SimpleSpan when tracing is enabled', () { + final hub = fixture.getSut(); + + final span = hub.startSpan('test-span'); + + expect(span, isA()); + }); + + test('returns NoOpSpan when tracing is disabled', () { + final hub = fixture.getSut(tracesSampleRate: null); + + final span = hub.startSpan('test-span'); + + expect(span, isA()); + }); + + test('returns NoOpSpan when hub is closed', () async { + final hub = fixture.getSut(); + await hub.close(); + + final span = hub.startSpan('test-span'); + + expect(span, isA()); + }); + + test('sets span name from parameter', () { + final hub = fixture.getSut(); + + final span = hub.startSpan('my-span-name'); + + expect(span.name, equals('my-span-name')); + }); + + test('sets attributes on span when provided', () { + final hub = fixture.getSut(); + final attributes = { + 'attr1': SentryAttribute.string('value1'), + 'attr2': SentryAttribute.int(42), + }; + + final span = hub.startSpan('test-span', attributes: attributes); + + expect(span.attributes, equals(attributes)); + }); + }); + + group('active span handling', () { + test('sets span as active on scope when active is true', () { + final hub = fixture.getSut(); + + final span = hub.startSpan('test-span', active: true); + + expect(hub.scope.getActiveSpan(), equals(span)); + }); + + test('does not set span as active on scope when active is false', () { + final hub = fixture.getSut(); + + hub.startSpan('test-span', active: false); + + expect(hub.scope.getActiveSpan(), isNull); + }); + }); + + group('parent span resolution', () { + test('creates root span when no active span exists', () { + final hub = fixture.getSut(); + + final span = hub.startSpan('test-span'); + + expect(span.parentSpan, isNull); + }); + + test('uses active span as parent when parentSpan is not provided', () { + final hub = fixture.getSut(); + final parentSpan = hub.startSpan('parent-span'); + + final childSpan = hub.startSpan('child-span'); + + expect(childSpan.parentSpan, equals(parentSpan)); + }); + + test('uses explicit parentSpan instead of active span when provided', + () { + final hub = fixture.getSut(); + final explicitParent = hub.startSpan('explicit-parent'); + hub.startSpan('other-span'); // Changes active span + + final childSpan = hub.startSpan( + 'child-span', + parentSpan: explicitParent, + active: false, + ); + + expect(childSpan.parentSpan, equals(explicitParent)); + }); + + test('creates root span when parentSpan is explicitly set to null', () { + final hub = fixture.getSut(); + hub.startSpan('active-span'); + + final rootSpan = hub.startSpan('root-span', parentSpan: null); + + expect(rootSpan.parentSpan, isNull); + }); + + test('should not allow finished span to be use as parent', () { + // TODO: this test case needs more clarification + }); + }); + + group('span hierarchy', () { + test('builds 3-level hierarchy with automatic parenting', () { + final hub = fixture.getSut(); + + final grandparent = hub.startSpan('grandparent'); + final parent = hub.startSpan('parent'); + final child = hub.startSpan('child'); + + expect(grandparent.parentSpan, isNull); + expect(parent.parentSpan, equals(grandparent)); + expect(child.parentSpan, equals(parent)); + }); + + test('ending middle span allows new span to parent to grandparent', () { + final hub = fixture.getSut(); + + final grandparent = hub.startSpan('grandparent'); + final parent = hub.startSpan('parent'); + parent.end(); + + final sibling = hub.startSpan('sibling'); + + expect(sibling.parentSpan, equals(grandparent)); + }); + + test('sibling spans share same parent when created as inactive', () { + final hub = fixture.getSut(); + + final parent = hub.startSpan('parent'); + final child1 = hub.startSpan('child1', active: false); + final child2 = hub.startSpan('child2', active: false); + + expect(child1.parentSpan, equals(parent)); + expect(child2.parentSpan, equals(parent)); + }); + + test('ending spans in reverse order cleans up active spans correctly', + () { + final hub = fixture.getSut(); + + final span1 = hub.startSpan('span1'); + final span2 = hub.startSpan('span2'); + final span3 = hub.startSpan('span3'); + + expect(hub.scope.activeSpans.length, 3); + + span3.end(); + expect(hub.scope.activeSpans.length, 2); + expect(hub.scope.getActiveSpan(), equals(span2)); + + span2.end(); + expect(hub.scope.getActiveSpan(), equals(span1)); + + span1.end(); + expect(hub.scope.getActiveSpan(), isNull); + }); + + test('ending spans out of order removes them from active spans', () { + final hub = fixture.getSut(); + + final parent = hub.startSpan('parent'); + final child = hub.startSpan('child'); + + parent.end(); + expect(hub.scope.activeSpans, contains(child)); + expect(hub.scope.activeSpans, isNot(contains(parent))); + + child.end(); + expect(hub.scope.activeSpans, isEmpty); + }); + + test( + 'new span parents to active root span when multiple root spans exist', + () { + final hub = fixture.getSut(); + + final activeRoot = + hub.startSpan('active-root', parentSpan: null, active: true); + final inactiveRoot = + hub.startSpan('inactive-root', parentSpan: null, active: false); + + final childToActiveSpan = hub.startSpan('child'); + expect(childToActiveSpan.parentSpan, equals(activeRoot)); + expect(childToActiveSpan.parentSpan, isNot(equals(inactiveRoot))); + + final childToInactiveSpan = + hub.startSpan('child', parentSpan: inactiveRoot); + expect(childToInactiveSpan.parentSpan, equals(inactiveRoot)); + expect(childToInactiveSpan.parentSpan, isNot(equals(activeRoot))); + }); + + test('deep hierarchy maintains correct parent chain', () { + final hub = fixture.getSut(); + + final spans = []; + for (var i = 0; i < 5; i++) { + spans.add(hub.startSpan('span-$i')); + } + + // Verify chain: span0 <- span1 <- span2 <- span3 <- span4 + expect(spans[0].parentSpan, isNull); + for (var i = 1; i < spans.length; i++) { + expect(spans[i].parentSpan, equals(spans[i - 1])); + } + }); + }); + }); + + group('captureSpan', () { + // TODO: add test that it was added to buffer + + test('removes span from active spans on scope', () { + final hub = fixture.getSut(); + final span = hub.startSpan('test-span'); + expect(hub.scope.activeSpans, contains(span)); + + hub.captureSpan(span); + + expect(hub.scope.activeSpans, isNot(contains(span))); + }); + + test('does nothing when hub is closed', () async { + final hub = fixture.getSut(); + final span = hub.startSpan('test-span'); + await hub.close(); + + // Should not throw + hub.captureSpan(span); + }); + }); + }); +} + +class Fixture { + final client = MockSentryClient(); + final recorder = MockClientReportRecorder(); + + final options = defaultTestOptions(); + + Hub getSut({ + double? tracesSampleRate = 1.0, + TracesSamplerCallback? tracesSampler, + bool debug = false, + }) { + options.tracesSampleRate = tracesSampleRate; + options.tracesSampler = tracesSampler; + options.debug = debug; + + final hub = Hub(options); + + hub.bindClient(client); + options.recorder = recorder; + + return hub; + } +} diff --git a/packages/dart/test/scope_test.dart b/packages/dart/test/scope_test.dart index 44683f1c50..a57bde0f84 100644 --- a/packages/dart/test/scope_test.dart +++ b/packages/dart/test/scope_test.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/protocol/simple_span.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; @@ -352,6 +353,46 @@ void main() { expect(sut.extra['test'], null); }); + test('setActiveSpan adds span to active span list', () { + final sut = fixture.getSut(); + + final span = SimpleSpan(name: 'span1', parentSpan: null); + final span2 = SimpleSpan(name: 'span2', parentSpan: null); + sut.setActiveSpan(span); + sut.setActiveSpan(span2); + + expect(sut.activeSpans.length, 2); + expect(sut.activeSpans.last, span2); + }); + + test('getActiveSpan returns the last active span in the list', () { + final sut = fixture.getSut(); + + final span = SimpleSpan(name: 'span1', parentSpan: null); + final span2 = SimpleSpan(name: 'span2', parentSpan: null); + sut.setActiveSpan(span); + sut.setActiveSpan(span2); + + final activeSpan = sut.getActiveSpan(); + expect(activeSpan, span2); + }); + + test( + 'removeActiveSpan removes the active span in the list regardless of order', + () { + final sut = fixture.getSut(); + + final span = SimpleSpan(name: 'span1', parentSpan: null); + final span2 = SimpleSpan(name: 'span2', parentSpan: null); + sut.setActiveSpan(span); + sut.setActiveSpan(span2); + + sut.removeActiveSpan(span); + + expect(sut.activeSpans.length, 1); + expect(sut.activeSpans.first, span2); + }); + test('clears $Scope', () { final sut = fixture.getSut(); @@ -390,6 +431,7 @@ void main() { expect(sut.eventProcessors.length, 0); expect(sut.replayId, isNull); expect(sut.attributes, isEmpty); + expect(sut.activeSpans, isEmpty); }); test('clones', () async { diff --git a/packages/dart/test/span_test.dart b/packages/dart/test/span_test.dart new file mode 100644 index 0000000000..073bd00229 --- /dev/null +++ b/packages/dart/test/span_test.dart @@ -0,0 +1,159 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/protocol/noop_span.dart'; +import 'package:sentry/src/protocol/simple_span.dart'; +import 'package:test/test.dart'; + +import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; + +void main() { + group('SimpleSpan', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('end finishes the span', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + span.end(); + + expect(span.endTimestamp, isNotNull); + expect(span.isFinished, isTrue); + }); + + test('end sets current time by default', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + 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 hub = fixture.getSut(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + final endTime = DateTime.now().add(Duration(seconds: 5)); + + span.end(endTimestamp: endTime); + + expect(span.endTimestamp, equals(endTime)); + }); + + test('end removes active span from scope', () { + final hub = fixture.getSut(); + final span = hub.startSpan('test-span'); + expect(hub.scope.activeSpans.length, 1); + expect(hub.scope.activeSpans.first, equals(span)); + + span.end(); + + expect(hub.scope.activeSpans, isEmpty); + }); + + test('setAttribute sets single attribute', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + final attributeValue = SentryAttribute.string('value'); + span.setAttribute('key', attributeValue); + + expect(span.attributes, equals({'key': attributeValue})); + }); + + test('setAttributes sets multiple attributes', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + final attributes = { + 'key1': SentryAttribute.string('value1'), + 'key2': SentryAttribute.int(42), + }; + span.setAttributes(attributes); + + expect(span.attributes, equals(attributes)); + }); + + test('setName sets span name', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'initial-name', parentSpan: null, hub: hub); + + span.name = 'updated-name'; + expect(span.name, equals('updated-name')); + }); + + test('can set span status', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'test-span', parentSpan: null, hub: hub); + + span.status = SpanV2Status.ok; + expect(span.status, equals(SpanV2Status.ok)); + + span.status = SpanV2Status.error; + expect(span.status, equals(SpanV2Status.error)); + }); + + test('parentSpan returns the parent span', () { + final hub = fixture.getSut(); + final parent = SimpleSpan(name: 'parent', parentSpan: null, hub: hub); + final child = SimpleSpan(name: 'child', parentSpan: parent, hub: hub); + + expect(child.parentSpan, equals(parent)); + }); + + test('parentSpan returns null for root span', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'root', parentSpan: null, hub: hub); + + expect(span.parentSpan, isNull); + }); + + test('name returns the span name', () { + final hub = fixture.getSut(); + final span = SimpleSpan(name: 'my-span-name', parentSpan: null, hub: hub); + + expect(span.name, equals('my-span-name')); + }); + }); + + group('NoOpSpan', () { + test('NoOpSpan operations do not throw', () { + const span = NoOpSpan(); + + // 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.name = 'name'; + span.status = SpanV2Status.ok; + span.status = SpanV2Status.error; + expect(span.toJson(), isEmpty); + }); + }); +} + +class Fixture { + final client = MockSentryClient(); + + final options = defaultTestOptions(); + + Hub getSut({ + double? tracesSampleRate = 1.0, + }) { + options.tracesSampleRate = tracesSampleRate; + final hub = Hub(options)..bindClient(client); + return hub; + } +}