Skip to content

Commit afabf4e

Browse files
committed
feat(tracing): Add tracestate header to outgoing requests (#3092)
1 parent 685eade commit afabf4e

File tree

15 files changed

+268
-49
lines changed

15 files changed

+268
-49
lines changed

packages/core/src/request.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Event, SdkInfo, SentryRequest, Session } from '@sentry/types';
2+
import { base64ToUnicode, logger } from '@sentry/utils';
23

34
import { API } from './api';
45

@@ -66,26 +67,28 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest {
6667
export function transactionToSentryRequest(event: Event, api: API): SentryRequest {
6768
const sdkInfo = getSdkMetadataForEnvelopeHeader(api);
6869

69-
const { transactionSampling, ...metadata } = event.debug_meta || {};
70+
const { transactionSampling, tracestate: encodedTracestate, ...metadata } = event.debug_meta || {};
7071
const { method: samplingMethod, rate: sampleRate } = transactionSampling || {};
7172
if (Object.keys(metadata).length === 0) {
7273
delete event.debug_meta;
7374
} else {
7475
event.debug_meta = metadata;
7576
}
7677

78+
// the tracestate is stored in bas64-encoded JSON, but envelope header values are expected to be full JS values,
79+
// so we have to decode and reinflate it
80+
let tracestate;
81+
try {
82+
tracestate = JSON.parse(base64ToUnicode(encodedTracestate as string));
83+
} catch (err) {
84+
logger.warn(err);
85+
}
86+
7787
const envelopeHeaders = JSON.stringify({
7888
event_id: event.event_id,
7989
sent_at: new Date().toISOString(),
8090
...(sdkInfo && { sdk: sdkInfo }),
81-
82-
// trace context for dynamic sampling on relay
83-
trace: {
84-
trace_id: event.contexts?.trace?.trace_id,
85-
public_key: api.getDsn().publicKey,
86-
environment: event.environment || null,
87-
release: event.release || null,
88-
},
91+
...(tracestate && { trace: tracestate }), // trace context for dynamic sampling on relay
8992
});
9093

9194
const itemHeaders = JSON.stringify({

packages/core/test/lib/request.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,20 @@ describe('eventToSentryRequest', () => {
5858
beforeEach(() => {
5959
transactionEvent = {
6060
...eventBase,
61-
debug_meta: { transactionSampling: { method: TransactionSamplingMethod.Rate, rate: 0.1121 } },
61+
debug_meta: {
62+
transactionSampling: { method: TransactionSamplingMethod.Rate, rate: 0.1121 },
63+
// This value is hardcoded in its base64 form to avoid a dependency on @sentry/tracing, where the method to
64+
// compute the value lives. It's equivalent to
65+
// computeTracestateValue({
66+
// trace_id: '1231201211212012',
67+
// environment: 'dogpark',
68+
// release: 'off.leash.park',
69+
// public_key: 'dogsarebadatkeepingsecrets',
70+
// }),
71+
tracestate:
72+
'eyJ0cmFjZV9pZCI6IjEyMzEyMDEyMTEyMTIwMTIiLCJlbnZpcm9ubWVudCI6ImRvZ3BhcmsiLCJyZWxlYXNlIjoib2ZmLmxlYXNo' +
73+
'LnBhcmsiLCJwdWJsaWNfa2V5IjoiZG9nc2FyZWJhZGF0a2VlcGluZ3NlY3JldHMifQ',
74+
},
6275
spans: [],
6376
transaction: '/dogs/are/great/',
6477
type: 'transaction',
@@ -77,7 +90,7 @@ describe('eventToSentryRequest', () => {
7790
});
7891

7992
describe('envelope header', () => {
80-
it('adds correct data to envelope header', () => {
93+
it('adds correct entries to envelope header', () => {
8194
jest.spyOn(Date.prototype, 'toISOString').mockReturnValueOnce('2012-12-31T09:08:13.000Z');
8295

8396
const result = eventToSentryRequest(transactionEvent, api);
@@ -155,7 +168,7 @@ describe('eventToSentryRequest', () => {
155168
});
156169

157170
describe('item header', () => {
158-
it('adds correct data to item header', () => {
171+
it('adds correct entries to item header', () => {
159172
const result = eventToSentryRequest(transactionEvent, api);
160173
const envelope = parseEnvelopeRequest(result);
161174

packages/node/src/integrations/http.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getCurrentHub } from '@sentry/core';
2-
import { Integration, Span } from '@sentry/types';
2+
import { Integration, Span, TraceHeaders } from '@sentry/types';
33
import { fill, logger, parseSemver } from '@sentry/utils';
44
import * as http from 'http';
55
import * as https from 'https';
@@ -115,9 +115,9 @@ function _createWrappedRequestMethodFactory(
115115
op: 'request',
116116
});
117117

118-
const sentryTraceHeader = span.toTraceparent();
119-
logger.log(`[Tracing] Adding sentry-trace header to outgoing request: ${sentryTraceHeader}`);
120-
requestOptions.headers = { ...requestOptions.headers, 'sentry-trace': sentryTraceHeader };
118+
const traceHeaders = span.getTraceHeaders();
119+
logger.log(`[Tracing] Adding sentry-trace and tracestate headers to outgoing request.`);
120+
requestOptions.headers = { ...requestOptions.headers, ...(traceHeaders as TraceHeaders) };
121121
}
122122
}
123123

packages/node/test/integrations/http.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,23 @@ describe('tracing', () => {
6464
expect((spans[0] as Transaction).name).toEqual('dogpark');
6565
});
6666

67-
it('attaches the sentry-trace header to outgoing non-sentry requests', async () => {
67+
it('attaches tracing headers to outgoing non-sentry requests', async () => {
6868
nock('http://dogs.are.great')
6969
.get('/')
7070
.reply(200);
7171

7272
createTransactionOnScope();
7373

7474
const request = http.get('http://dogs.are.great/');
75-
const sentryTraceHeader = request.getHeader('sentry-trace') as string;
75+
const sentryTraceHeader = request.getHeader('sentry-trace');
76+
const tracestateHeader = request.getHeader('tracestate');
7677

7778
expect(sentryTraceHeader).toBeDefined();
78-
expect(TRACEPARENT_REGEXP.test(sentryTraceHeader)).toBe(true);
79+
expect(tracestateHeader).toBeDefined();
80+
expect(TRACEPARENT_REGEXP.test(sentryTraceHeader as string)).toBe(true);
7981
});
8082

81-
it("doesn't attach the sentry-trace header to outgoing sentry requests", () => {
83+
it("doesn't attach tracing headers to outgoing sentry requests", () => {
8284
nock('http://squirrelchasers.ingest.sentry.io')
8385
.get('/api/12312012/store/')
8486
.reply(200);
@@ -87,7 +89,9 @@ describe('tracing', () => {
8789

8890
const request = http.get('http://squirrelchasers.ingest.sentry.io/api/12312012/store/');
8991
const sentryTraceHeader = request.getHeader('sentry-trace');
92+
const tracestateHeader = request.getHeader('tracestate');
9093

9194
expect(sentryTraceHeader).not.toBeDefined();
95+
expect(tracestateHeader).not.toBeDefined();
9296
});
9397
});

packages/tracing/src/browser/request.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,14 @@ export function fetchCallback(
195195
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
196196
if (typeof headers.append === 'function') {
197197
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
198-
headers.append('sentry-trace', span.toTraceparent());
198+
headers.append(Object.entries(span.getTraceHeaders()));
199199
} else if (Array.isArray(headers)) {
200-
headers = [...headers, ['sentry-trace', span.toTraceparent()]];
200+
headers = [...headers, ...Object.entries(span.getTraceHeaders())];
201201
} else {
202-
headers = { ...headers, 'sentry-trace': span.toTraceparent() };
202+
headers = { ...headers, ...span.getTraceHeaders() };
203203
}
204204
} else {
205-
headers = { 'sentry-trace': span.toTraceparent() };
205+
headers = span.getTraceHeaders();
206206
}
207207
options.headers = headers;
208208
}
@@ -261,7 +261,11 @@ export function xhrCallback(
261261

262262
if (handlerData.xhr.setRequestHeader) {
263263
try {
264-
handlerData.xhr.setRequestHeader('sentry-trace', span.toTraceparent());
264+
const sentryHeaders = span.getTraceHeaders();
265+
handlerData.xhr.setRequestHeader('sentry-trace', sentryHeaders['sentry-trace']);
266+
if (sentryHeaders.tracestate) {
267+
handlerData.xhr.setRequestHeader('tracestate', sentryHeaders.tracestate);
268+
}
265269
} catch (_) {
266270
// Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.
267271
}

packages/tracing/src/span.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable max-lines */
2-
import { Primitive, Span as SpanInterface, SpanContext, Transaction } from '@sentry/types';
2+
import { Primitive, Span as SpanInterface, SpanContext, TraceHeaders, Transaction } from '@sentry/types';
33
import { dropUndefinedKeys, timestampWithMs, uuid4 } from '@sentry/utils';
44

55
import { SpanStatus } from './spanstatus';
@@ -284,6 +284,19 @@ export class Span implements SpanInterface {
284284
return this;
285285
}
286286

287+
/**
288+
* @inheritDoc
289+
*/
290+
public getTraceHeaders(): TraceHeaders {
291+
// tracestates live on the transaction, so if this is a free-floating span, there won't be one
292+
const tracestate = this.transaction && `sentry=${this.transaction.tracestate}`; // TODO kmclb
293+
294+
return {
295+
'sentry-trace': this.toTraceparent(),
296+
...(tracestate && { tracestate }),
297+
};
298+
}
299+
287300
/**
288301
* @inheritDoc
289302
*/

packages/tracing/src/transaction.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { getCurrentHub, Hub } from '@sentry/hub';
2-
import { Event, Measurements, Transaction as TransactionInterface, TransactionContext } from '@sentry/types';
2+
import { DebugMeta, Event, Measurements, Transaction as TransactionInterface, TransactionContext } from '@sentry/types';
33
import { dropUndefinedKeys, isInstanceOf, logger } from '@sentry/utils';
44

55
import { Span as SpanClass, SpanRecorder } from './span';
6+
import { computeTracestateValue } from './utils';
67

7-
interface TransactionMetadata {
8-
transactionSampling?: { [key: string]: string | number };
9-
}
8+
type TransactionMetadata = Pick<DebugMeta, 'transactionSampling' | 'tracestate'>;
109

1110
/** JSDoc */
1211
export class Transaction extends SpanClass implements TransactionInterface {
1312
public name: string;
1413

14+
public readonly tracestate: string;
15+
1516
private _metadata: TransactionMetadata = {};
1617

1718
private _measurements: Measurements = {};
@@ -41,6 +42,10 @@ export class Transaction extends SpanClass implements TransactionInterface {
4142

4243
this._trimEnd = transactionContext.trimEnd;
4344

45+
// _getNewTracestate only returns undefined in the absence of a client or dsn, in which case it doesn't matter what
46+
// the header values are - nothing can be sent anyway - so the third alternative here is just to make TS happy
47+
this.tracestate = transactionContext.tracestate || this._getNewTracestate() || 'things are broken';
48+
4449
// this is because transactions are also spans, and spans have a transaction pointer
4550
this.transaction = this;
4651
}
@@ -113,6 +118,8 @@ export class Transaction extends SpanClass implements TransactionInterface {
113118
}).endTimestamp;
114119
}
115120

121+
this._metadata.tracestate = this.tracestate;
122+
116123
const transaction: Event = {
117124
contexts: {
118125
trace: this.getTraceContext(),
@@ -161,4 +168,27 @@ export class Transaction extends SpanClass implements TransactionInterface {
161168

162169
return this;
163170
}
171+
172+
/**
173+
* Create a new tracestate header value
174+
*
175+
* @returns The new tracestate value, or undefined if there's no client or no dsn
176+
*/
177+
private _getNewTracestate(): string | undefined {
178+
const client = this._hub.getClient();
179+
const dsn = client?.getDsn();
180+
181+
if (!client || !dsn) {
182+
return;
183+
}
184+
185+
const { environment, release } = client.getOptions() || {};
186+
187+
// TODO - the only reason we need the non-null assertion on `dsn.publicKey` (below) is because `dsn.publicKey` has
188+
// to be optional while we transition from `dsn.user` -> `dsn.publicKey`. Once `dsn.user` is removed, we can make
189+
// `dsn.publicKey` required and remove the `!`.
190+
191+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
192+
return computeTracestateValue({ trace_id: this.traceId, environment, release, public_key: dsn.publicKey! });
193+
}
164194
}

packages/tracing/src/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getCurrentHub, Hub } from '@sentry/hub';
22
import { Options, TraceparentData, Transaction } from '@sentry/types';
3+
import { SentryError, unicodeToBase64 } from '@sentry/utils';
34

45
export const TRACEPARENT_REGEXP = new RegExp(
56
'^[ \\t]*' + // whitespace
@@ -66,3 +67,35 @@ export function secToMs(time: number): number {
6667

6768
// so it can be used in manual instrumentation without necessitating a hard dependency on @sentry/utils
6869
export { stripUrlQueryAndFragment } from '@sentry/utils';
70+
71+
/**
72+
* Compute the value of a tracestate header.
73+
*
74+
* @throws SentryError (because using the logger creates a circular dependency)
75+
* @returns the base64-encoded header value
76+
*/
77+
// Note: this is here instead of in the tracing package since @sentry/core tests rely on it
78+
export function computeTracestateValue(tracestateData: {
79+
trace_id: string;
80+
environment: string | undefined | null;
81+
release: string | undefined | null;
82+
public_key: string;
83+
}): string {
84+
// `JSON.stringify` will drop keys with undefined values, but not ones with null values
85+
tracestateData.environment = tracestateData.environment || null;
86+
tracestateData.release = tracestateData.release || null;
87+
88+
// See https://www.w3.org/TR/trace-context/#tracestate-header-field-values
89+
// The spec for tracestate header values calls for a string of the form
90+
//
91+
// identifier1=value1,identifier2=value2,...
92+
//
93+
// which means the value can't include any equals signs, since they already have meaning. Equals signs are commonly
94+
// used to pad the end of base64 values though, so to avoid confusion, we strip them off. (Most languages' base64
95+
// decoding functions (including those in JS) are able to function without the padding.)
96+
try {
97+
return unicodeToBase64(JSON.stringify(tracestateData)).replace(/={1,2}$/, '');
98+
} catch (err) {
99+
throw new SentryError(`[Tracing] Error creating tracestate header: ${err}`);
100+
}
101+
}

0 commit comments

Comments
 (0)