diff --git a/CHANGELOG.md b/CHANGELOG.md index eb90d84cd1..2474c7e423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Add W3C `traceparent` header support ([#3246](https://github.com/getsentry/sentry-dart/pull/3246)) + - Enable the option `propagateTraceparent` to allow the propagation of the W3C Trace Context HTTP header `traceparent` on outgoing HTTP requests. + ### Enhancements - Prefix firebase remote config feature flags with `firebase:` ([#3258](https://github.com/getsentry/sentry-dart/pull/3258)) diff --git a/packages/dart/lib/src/sentry_options.dart b/packages/dart/lib/src/sentry_options.dart index 32090e0bc1..ba92e6df89 100644 --- a/packages/dart/lib/src/sentry_options.dart +++ b/packages/dart/lib/src/sentry_options.dart @@ -424,6 +424,13 @@ class SentryOptions { /// array, and only attach tracing headers if a match was found. final List tracePropagationTargets = ['.*']; + /// This option is used to enable the propagation of the + /// W3C Trace Context HTTP header traceparent on outgoing HTTP requests. + /// This is useful when the receiving services only support OTel/W3C propagation + /// + /// The default is `false`. + bool propagateTraceparent = false; + /// The idle time to wait until the transaction will be finished. /// The transaction will use the end timestamp of the last finished span as /// the endtime for the transaction. diff --git a/packages/dart/lib/src/utils/tracing_utils.dart b/packages/dart/lib/src/utils/tracing_utils.dart index bf283404ef..30595fc4f6 100644 --- a/packages/dart/lib/src/utils/tracing_utils.dart +++ b/packages/dart/lib/src/utils/tracing_utils.dart @@ -10,6 +10,9 @@ SentryTraceHeader generateSentryTraceHeader( void addTracingHeadersToHttpHeader(Map headers, Hub hub, {ISentrySpan? span}) { if (span != null) { + if (hub.options.propagateTraceparent) { + addW3CHeaderFromSpan(span, headers); + } addSentryTraceHeaderFromSpan(span, headers); addBaggageHeaderFromSpan( span, @@ -17,6 +20,9 @@ void addTracingHeadersToHttpHeader(Map headers, Hub hub, log: hub.options.log, ); } else { + if (hub.options.propagateTraceparent) { + addW3CHeaderFromScope(hub.scope, headers); + } addSentryTraceHeaderFromScope(hub.scope, headers); addBaggageHeaderFromScope(hub.scope, headers, log: hub.options.log); } @@ -39,6 +45,28 @@ void addSentryTraceHeader( headers[traceHeader.name] = traceHeader.value; } +void addW3CHeaderFromSpan(ISentrySpan span, Map headers) { + final traceHeader = span.toSentryTrace(); + _addW3CHeaderFromSentryTrace(traceHeader, headers); +} + +void addW3CHeaderFromScope(Scope scope, Map headers) { + final propagationContext = scope.propagationContext; + final traceHeader = propagationContext.toSentryTrace(); + _addW3CHeaderFromSentryTrace(traceHeader, headers); +} + +void _addW3CHeaderFromSentryTrace( + SentryTraceHeader traceHeader, Map headers) { + headers['traceparent'] = formatAsW3CHeader(traceHeader); +} + +String formatAsW3CHeader(SentryTraceHeader traceHeader) { + final sampled = traceHeader.sampled; + final sampledBit = sampled != null && sampled ? '01' : '00'; + return '00-${traceHeader.traceId}-${traceHeader.spanId}-$sampledBit'; +} + void addBaggageHeaderFromScope( Scope scope, Map headers, { diff --git a/packages/dart/test/http_client/tracing_client_test.dart b/packages/dart/test/http_client/tracing_client_test.dart index 2e31e076d7..8625dc65e2 100644 --- a/packages/dart/test/http_client/tracing_client_test.dart +++ b/packages/dart/test/http_client/tracing_client_test.dart @@ -120,57 +120,87 @@ void main() { expect(span.throwable, exception); }); - test('should add tracing headers from span when tracing enabled', () async { - final sut = fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); - final tr = fixture._hub.startTransaction( - 'name', - 'op', - bindToScope: true, - ); - - final response = await sut.get(requestUri); - - await tr.finish(); - - final tracer = (tr as SentryTracer); - expect(tracer.children.length, 1); - final span = tracer.children.first; - final baggageHeader = span.toBaggageHeader(); - final sentryTraceHeader = span.toSentryTrace(); - - expect( - response.request!.headers[baggageHeader!.name], baggageHeader.value); - expect(response.request!.headers[sentryTraceHeader.name], - sentryTraceHeader.value); - }); - - test( - 'should add tracing headers from propagation context when tracing disabled', - () async { - fixture._hub.options.tracesSampleRate = null; - fixture._hub.options.tracesSampler = null; - final sut = fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); - final propagationContext = fixture._hub.scope.propagationContext; - propagationContext.baggage = SentryBaggage({'foo': 'bar'}); - - final response = await sut.get(requestUri); - - final baggageHeader = propagationContext.toBaggageHeader(); - - expect(propagationContext.toBaggageHeader(), isNotNull); - expect( - response.request!.headers[baggageHeader!.name], baggageHeader.value); + for (final propagate in [true, false]) { + test( + 'should add tracing headers from span when tracing enabled (propagateTraceparent: $propagate)', + () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + fixture._hub.options.propagateTraceparent = propagate; + + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + final response = await sut.get(requestUri); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + final baggageHeader = span.toBaggageHeader(); + final sentryTraceHeader = span.toSentryTrace(); + + expect(response.request!.headers[baggageHeader!.name], + baggageHeader.value); + expect(response.request!.headers[sentryTraceHeader.name], + sentryTraceHeader.value); + + final traceHeader = span.toSentryTrace(); + final expected = + '00-${traceHeader.traceId}-${traceHeader.spanId}-${traceHeader.sampled == true ? '01' : '00'}'; + + if (propagate) { + expect(response.request!.headers['traceparent'], expected); + } else { + expect(response.request!.headers['traceparent'], isNull); + } + }); + } - final traceHeader = SentryTraceHeader.fromTraceHeader( - response.request!.headers['sentry-trace'] as String, - ); - expect(traceHeader.traceId, propagationContext.traceId); - // can't check span id as it is always generated new - }); + for (final propagate in [true, false]) { + test( + 'should add tracing headers from propagation context when tracing disabled (propagateTraceparent: $propagate)', + () async { + fixture._hub.options.tracesSampleRate = null; + fixture._hub.options.tracesSampler = null; + fixture._hub.options.propagateTraceparent = propagate; + + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + final propagationContext = fixture._hub.scope.propagationContext; + propagationContext.baggage = SentryBaggage({'foo': 'bar'}); + + final response = await sut.get(requestUri); + + final baggageHeader = propagationContext.toBaggageHeader(); + + expect(propagationContext.toBaggageHeader(), isNotNull); + expect(response.request!.headers[baggageHeader!.name], + baggageHeader.value); + + final traceHeader = SentryTraceHeader.fromTraceHeader( + response.request!.headers['sentry-trace'] as String, + ); + expect(traceHeader.traceId, propagationContext.traceId); + + if (propagate) { + final headerValue = response.request!.headers['traceparent']!; + final parts = headerValue.split('-'); + expect(parts.length, 4); + expect(parts[0], '00'); + expect(parts[1], propagationContext.traceId.toString()); + expect(parts[2].length, 16); + expect(parts[3], '00'); + } else { + expect(response.request!.headers['traceparent'], isNull); + } + }); + } test( 'tracing header from propagation context should generate new span ids for new events', @@ -221,6 +251,7 @@ void main() { expect(response.request!.headers[baggageHeader!.name], isNull); expect(response.request!.headers[sentryTraceHeader.name], isNull); + expect(response.request!.headers['traceparent'], isNull); }); test( @@ -242,6 +273,7 @@ void main() { expect(response.request!.headers[baggageHeader!.name], isNull); expect(response.request!.headers[sentryTraceHeader.name], isNull); + expect(response.request!.headers['traceparent'], isNull); }); test('do not throw if no span bound to the scope', () async { diff --git a/packages/dart/test/utils/tracing_utils_test.dart b/packages/dart/test/utils/tracing_utils_test.dart index b5e7b70b52..36587ea904 100644 --- a/packages/dart/test/utils/tracing_utils_test.dart +++ b/packages/dart/test/utils/tracing_utils_test.dart @@ -74,6 +74,48 @@ void main() { }); }); + group('W3C traceparent header', () { + final fixture = Fixture(); + final headerName = 'traceparent'; + + test('converts SentryTraceHeader to W3C format correctly', () { + final sut = fixture.getSut(); + final sentryHeader = sut.toSentryTrace(); + + final w3cHeader = formatAsW3CHeader(sentryHeader); + + expect(w3cHeader, + '00-${fixture._context.traceId}-${fixture._context.spanId}-01'); + }); + + test('added when given a span', () { + final headers = {}; + final sut = fixture.getSut(); + + addW3CHeaderFromSpan(sut, headers); + + expect(headers[headerName], + '00-${fixture._context.traceId}-${fixture._context.spanId}-01'); + }); + + test('added when given a scope', () { + final headers = {}; + final hub = fixture._hub; + final scope = hub.scope; + + addW3CHeaderFromScope(scope, headers); + + final headerValue = headers[headerName] as String; + final parts = headerValue.split('-'); + + expect(parts.length, 4); + expect(parts[0], '00'); + expect(parts[1], scope.propagationContext.traceId.toString()); + expect(parts[2], hasLength(16)); // just check length since it's random + expect(parts[3], '00'); + }); + }); + group('$addBaggageHeader', () { final fixture = Fixture(); @@ -205,8 +247,69 @@ void main() { }); }); - group('$addTracingHeadersToHttpHeader', () { - final fixture = Fixture(); + group(addTracingHeadersToHttpHeader, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test( + 'adds W3C traceparent header from span when propagateTraceparent is true', + () { + final headers = {}; + final hub = fixture._hub; + final span = fixture.getSut(); + hub.options.propagateTraceparent = true; + + addTracingHeadersToHttpHeader(headers, hub, span: span); + + expect(headers['traceparent'], + '00-${fixture._context.traceId}-${fixture._context.spanId}-01'); + }); + + test( + 'does not add W3C traceparent header from span when propagateTraceparent is false', + () { + final headers = {}; + final hub = fixture._hub; + // propagateTraceparent is false by default + + addTracingHeadersToHttpHeader(headers, hub); + + expect(headers['traceparent'], isNull); + }); + + test( + 'adds W3C traceparent header from scope when propagateTraceparent is true', + () { + final headers = {}; + final hub = fixture._hub; + hub.options.propagateTraceparent = true; + + addTracingHeadersToHttpHeader(headers, hub); + + final headerValue = headers['traceparent'] as String; + final parts = headerValue.split('-'); + + expect(parts.length, 4); + expect(parts[0], '00'); + expect(parts[1], hub.scope.propagationContext.traceId.toString()); + expect(parts[2], hasLength(16)); // just check length since it's random + expect(parts[3], '00'); // not sampled for scope context + }); + + test( + 'does not add W3C traceparent header from scope when propagateTraceparent is false', + () { + final headers = {}; + final hub = fixture._hub; + // propagateTraceparent is false by default + + addTracingHeadersToHttpHeader(headers, hub); + + expect(headers['traceparent'], isNull); + }); test('adds headers from span when span is provided', () { final headers = {}; diff --git a/packages/dio/test/tracing_client_adapter_test.dart b/packages/dio/test/tracing_client_adapter_test.dart index a991d6c7f1..563a5b2759 100644 --- a/packages/dio/test/tracing_client_adapter_test.dart +++ b/packages/dio/test/tracing_client_adapter_test.dart @@ -22,43 +22,46 @@ void main() { fixture = Fixture(); }); - test('should add sdk integration on init when tracing is enabled', - () async { - fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); - - expect(fixture._hub.options.isTracingEnabled(), isTrue); - expect( - fixture._hub.options.sdk.integrations, - contains(TracingClientAdapter.integrationName), - ); - }); - - test('should not add sdk integration on init when tracing is disabled', - () async { - fixture._hub.options.tracesSampleRate = null; - fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); + test( + 'should add sdk integration on init when tracing is enabled', + () async { + fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + + expect(fixture._hub.options.isTracingEnabled(), isTrue); + expect( + fixture._hub.options.sdk.integrations, + contains(TracingClientAdapter.integrationName), + ); + }, + ); - expect(fixture._hub.options.isTracingEnabled(), isFalse); - expect( - fixture._hub.options.sdk.integrations, - isNot(contains(TracingClientAdapter.integrationName)), - ); - }); + test( + 'should not add sdk integration on init when tracing is disabled', + () async { + fixture._hub.options.tracesSampleRate = null; + fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + + expect(fixture._hub.options.isTracingEnabled(), isFalse); + expect( + fixture._hub.options.sdk.integrations, + isNot(contains(TracingClientAdapter.integrationName)), + ); + }, + ); test('captured span if successful request', () async { final sut = fixture.getSut( - client: - fixture.getClient(statusCode: 200, reason: 'OK', contentLength: 2), - ); - final tr = fixture._hub.startTransaction( - 'name', - 'op', - bindToScope: true, + client: fixture.getClient( + statusCode: 200, + reason: 'OK', + contentLength: 2, + ), ); + final tr = fixture._hub.startTransaction('name', 'op', bindToScope: true); await sut.get(requestOptions); @@ -80,14 +83,8 @@ void main() { }); test('finish span if errored request', () async { - final sut = fixture.getSut( - client: createThrowingClient(), - ); - final tr = fixture._hub.startTransaction( - 'name', - 'op', - bindToScope: true, - ); + final sut = fixture.getSut(client: createThrowingClient()); + final tr = fixture._hub.startTransaction('name', 'op', bindToScope: true); try { await sut.get(requestOptions); @@ -104,14 +101,8 @@ void main() { }); test('associate exception to span if errored request', () async { - final sut = fixture.getSut( - client: createThrowingClient(), - ); - final tr = fixture._hub.startTransaction( - 'name', - 'op', - bindToScope: true, - ); + final sut = fixture.getSut(client: createThrowingClient()); + final tr = fixture._hub.startTransaction('name', 'op', bindToScope: true); dynamic exception; try { @@ -131,135 +122,164 @@ void main() { expect((exception as DioError).error, isA()); }); - test('should add tracing headers from span when tracing is enabled', + for (final propagate in [true, false]) { + test( + 'should add tracing headers from span when tracing is enabled (propagateTraceparent: $propagate)', () async { - final sut = fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); - final tr = fixture._hub.startTransaction( - 'name', - 'op', - bindToScope: true, + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + fixture._hub.options.propagateTraceparent = propagate; + + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + final response = await sut.get(requestOptions); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + final baggageHeader = span.toBaggageHeader(); + final sentryTraceHeader = span.toSentryTrace(); + + expect(response.headers[baggageHeader!.name], [ + baggageHeader.value, + ]); + expect(response.headers[sentryTraceHeader.name], [ + sentryTraceHeader.value, + ]); + + final traceHeader = span.toSentryTrace(); + final expected = + '00-${traceHeader.traceId}-${traceHeader.spanId}-${traceHeader.sampled == true ? '01' : '00'}'; + + if (propagate) { + expect(response.headers['traceparent'], [expected]); + } else { + expect(response.headers['traceparent'], isNull); + } + }, ); - final response = await sut.get(requestOptions); - - await tr.finish(); - - final tracer = (tr as SentryTracer); - final span = tracer.children.first; - final baggageHeader = span.toBaggageHeader(); - final sentryTraceHeader = span.toSentryTrace(); - - expect( - response.headers[baggageHeader!.name], - [baggageHeader.value], - ); - expect( - response.headers[sentryTraceHeader.name], - [sentryTraceHeader.value], - ); - }); - - test( - 'should add tracing headers from propagation context when tracing is disabled', + test( + 'should add tracing headers from propagation context when tracing is disabled (propagateTraceparent: $propagate)', () async { - fixture._options.tracesSampleRate = null; - fixture._options.tracesSampler = null; - final sut = fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); - final propagationContext = fixture._hub.scope.propagationContext; - propagationContext.baggage = SentryBaggage({'foo': 'bar'}); - - final response = await sut.get(requestOptions); - - final baggageHeader = propagationContext.toBaggageHeader(); - - expect(propagationContext.toBaggageHeader(), isNotNull); - expect( - response.headers[baggageHeader!.name], - [baggageHeader.value], + fixture._options.tracesSampleRate = null; + fixture._options.tracesSampler = null; + fixture._hub.options.propagateTraceparent = propagate; + + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + final propagationContext = fixture._hub.scope.propagationContext; + propagationContext.baggage = SentryBaggage({'foo': 'bar'}); + + final response = await sut.get(requestOptions); + + final baggageHeader = propagationContext.toBaggageHeader(); + + expect(propagationContext.toBaggageHeader(), isNotNull); + expect(response.headers[baggageHeader!.name], [ + baggageHeader.value, + ]); + + final traceHeader = SentryTraceHeader.fromTraceHeader( + response.headers['sentry-trace']?.first as String, + ); + expect(traceHeader.traceId, propagationContext.traceId); + + if (propagate) { + final headerValue = response.headers['traceparent']!.first; + final parts = headerValue.split('-'); + expect(parts.length, 4); + expect(parts[0], '00'); + expect( + parts[1], + fixture._hub.scope.propagationContext.traceId.toString(), + ); + expect(parts[2].length, 16); + expect(parts[3], '00'); + } else { + expect(response.headers['traceparent'], isNull); + } + }, ); - - final traceHeader = SentryTraceHeader.fromTraceHeader( - response.headers['sentry-trace']?.first as String, - ); - expect(traceHeader.traceId, propagationContext.traceId); - // can't check span id as it is always generated new - }); + } test( - 'should create header with new generated span id for request when tracing is disabled', - () async { - fixture._options.tracesSampleRate = null; - fixture._options.tracesSampler = null; - final sut = fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), - ); - - final response1 = await sut.get(requestOptions); - final response2 = await sut.get(requestOptions); - - final header1 = SentryTraceHeader.fromTraceHeader( - response1.headers['sentry-trace']?.first as String, - ); - final header2 = SentryTraceHeader.fromTraceHeader( - response2.headers['sentry-trace']?.first as String, - ); - expect(header1.spanId, isNot(header2.spanId)); - }); + 'should create header with new generated span id for request when tracing is disabled', + () async { + fixture._options.tracesSampleRate = null; + fixture._options.tracesSampler = null; + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + + final response1 = await sut.get(requestOptions); + final response2 = await sut.get(requestOptions); + + final header1 = SentryTraceHeader.fromTraceHeader( + response1.headers['sentry-trace']?.first as String, + ); + final header2 = SentryTraceHeader.fromTraceHeader( + response2.headers['sentry-trace']?.first as String, + ); + expect(header1.spanId, isNot(header2.spanId)); + }, + ); test( - 'should not add tracing headers when URL does not match tracePropagationTargets with tracing enabled', - () async { - final sut = fixture.getSut( - client: fixture.getClient( - statusCode: 200, - reason: 'OK', - ), - tracePropagationTargets: ['nope'], - ); - final tr = fixture._hub.startTransaction( - 'name', - 'op', - bindToScope: true, - ); - - final response = await sut.get(requestOptions); - - await tr.finish(); - - final tracer = (tr as SentryTracer); - final span = tracer.children.first; - final baggageHeader = span.toBaggageHeader(); - final sentryTraceHeader = span.toSentryTrace(); - - expect(response.headers[baggageHeader!.name], isNull); - expect(response.headers[sentryTraceHeader.name], isNull); - }); + 'should not add tracing headers when URL does not match tracePropagationTargets with tracing enabled', + () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + tracePropagationTargets: ['nope'], + ); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + final response = await sut.get(requestOptions); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + final baggageHeader = span.toBaggageHeader(); + final sentryTraceHeader = span.toSentryTrace(); + + expect(response.headers[baggageHeader!.name], isNull); + expect(response.headers[sentryTraceHeader.name], isNull); + expect(response.headers['traceparent'], isNull); + }, + ); test( - 'should not add tracing headers when URL does not match tracePropagationTargets with tracing disabled', - () async { - final sut = fixture.getSut( - client: fixture.getClient( - statusCode: 200, - reason: 'OK', - ), - tracePropagationTargets: ['nope'], - ); - final propagationContext = fixture._hub.scope.propagationContext; - propagationContext.baggage = SentryBaggage({'foo': 'bar'}); - - final response = await sut.get(requestOptions); - - final baggageHeader = propagationContext.toBaggageHeader(); - final sentryTraceHeader = propagationContext.toSentryTrace(); - - expect(response.headers[baggageHeader!.name], isNull); - expect(response.headers[sentryTraceHeader.name], isNull); - }); + 'should not add tracing headers when URL does not match tracePropagationTargets with tracing disabled', + () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + tracePropagationTargets: ['nope'], + ); + final propagationContext = fixture._hub.scope.propagationContext; + propagationContext.baggage = SentryBaggage({'foo': 'bar'}); + + final response = await sut.get(requestOptions); + + final baggageHeader = propagationContext.toBaggageHeader(); + final sentryTraceHeader = propagationContext.toSentryTrace(); + + expect(response.headers[baggageHeader!.name], isNull); + expect(response.headers[sentryTraceHeader.name], isNull); + expect(response.headers['traceparent'], isNull); + }, + ); test('do not throw if no span bound to the scope', () async { final sut = fixture.getSut( @@ -272,12 +292,10 @@ void main() { } MockHttpClientAdapter createThrowingClient() { - return MockHttpClientAdapter( - (options, _, __) async { - expect(options.uri, requestUri); - throw TestException(); - }, - ); + return MockHttpClientAdapter((options, _, __) async { + expect(options.uri, requestUri); + throw TestException(); + }); } class Fixture { @@ -300,10 +318,7 @@ class Fixture { _hub.options.tracePropagationTargets.clear(); _hub.options.tracePropagationTargets.addAll(tracePropagationTargets); } - dio.httpClientAdapter = TracingClientAdapter( - client: mc, - hub: _hub, - ); + dio.httpClientAdapter = TracingClientAdapter(client: mc, hub: _hub); return dio; } @@ -324,11 +339,7 @@ class Fixture { headers['Content-Length'] = [contentLength.toString()]; } - return ResponseBody.fromString( - '{}', - statusCode, - headers: headers, - ); + return ResponseBody.fromString('{}', statusCode, headers: headers); }); } }