Skip to content

Commit b5a0cb4

Browse files
committed
ref(tracing): Rework tracestate internals to allow for third-party data (#3266)
1 parent 192e4ca commit b5a0cb4

File tree

11 files changed

+106
-73
lines changed

11 files changed

+106
-73
lines changed

packages/core/src/request.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest {
6767
export function transactionToSentryRequest(event: Event, api: API): SentryRequest {
6868
const sdkInfo = getSdkMetadataForEnvelopeHeader(api);
6969

70-
const { transactionSampling, tracestate: encodedTracestate, ...metadata } = event.debug_meta || {};
70+
const { transactionSampling, tracestate, ...metadata } = event.debug_meta || {};
7171
const { method: samplingMethod, rate: sampleRate } = transactionSampling || {};
7272
if (Object.keys(metadata).length === 0) {
7373
delete event.debug_meta;
@@ -77,9 +77,14 @@ export function transactionToSentryRequest(event: Event, api: API): SentryReques
7777

7878
// the tracestate is stored in bas64-encoded JSON, but envelope header values are expected to be full JS values,
7979
// so we have to decode and reinflate it
80-
let tracestate;
80+
let reinflatedTracestate;
8181
try {
82-
tracestate = JSON.parse(base64ToUnicode(encodedTracestate as string));
82+
// Because transaction metadata passes through a number of locations (transactionContext, transaction, event during
83+
// processing, event as sent), each with different requirements, all of the parts are typed as optional. That said,
84+
// if we get to this point and either `tracestate` or `tracestate.sentry` are undefined, something's gone very wrong.
85+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
86+
const encodedSentryValue = tracestate!.sentry!.replace('sentry=', '');
87+
reinflatedTracestate = JSON.parse(base64ToUnicode(encodedSentryValue));
8388
} catch (err) {
8489
logger.warn(err);
8590
}
@@ -88,7 +93,7 @@ export function transactionToSentryRequest(event: Event, api: API): SentryReques
8893
event_id: event.event_id,
8994
sent_at: new Date().toISOString(),
9095
...(sdkInfo && { sdk: sdkInfo }),
91-
...(tracestate && { trace: tracestate }), // trace context for dynamic sampling on relay
96+
...(reinflatedTracestate && { trace: reinflatedTracestate }), // trace context for dynamic sampling on relay
9297
});
9398

9499
const itemHeaders = JSON.stringify({

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ describe('eventToSentryRequest', () => {
6868
// release: 'off.leash.park',
6969
// public_key: 'dogsarebadatkeepingsecrets',
7070
// }),
71-
tracestate:
72-
'eyJ0cmFjZV9pZCI6IjEyMzEyMDEyMTEyMTIwMTIiLCJlbnZpcm9ubWVudCI6ImRvZ3BhcmsiLCJyZWxlYXNlIjoib2ZmLmxlYXNo' +
73-
'LnBhcmsiLCJwdWJsaWNfa2V5IjoiZG9nc2FyZWJhZGF0a2VlcGluZ3NlY3JldHMifQ',
71+
tracestate: {
72+
sentry:
73+
'sentry=eyJ0cmFjZV9pZCI6IjEyMzEyMDEyMTEyMTIwMTIiLCJlbnZpcm9ubWVudCI6ImRvZ3BhcmsiLCJyZWxlYXNlIjoib2ZmLmxlYXNo' +
74+
'LnBhcmsiLCJwdWJsaWNfa2V5IjoiZG9nc2FyZWJhZGF0a2VlcGluZ3NlY3JldHMifQ',
75+
},
7476
},
7577
spans: [],
7678
transaction: '/dogs/are/great/',

packages/tracing/src/browser/request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getCurrentHub } from '@sentry/hub';
2+
import { Span } from '@sentry/types';
23
import { addInstrumentationHandler, isInstanceOf, isMatchingPattern } from '@sentry/utils';
34

4-
import { Span } from '../span';
55
import { getActiveTransaction, hasTracingEnabled } from '../utils';
66

77
export const DEFAULT_TRACING_ORIGINS = ['localhost', /^\//];

packages/tracing/src/span.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/* eslint-disable max-lines */
2-
import { Primitive, Span as SpanInterface, SpanContext, TraceHeaders, Transaction } from '@sentry/types';
2+
import { getCurrentHub } from '@sentry/hub';
3+
import { Hub, Primitive, Span as SpanInterface, SpanContext, TraceHeaders, Transaction } from '@sentry/types';
34
import { dropUndefinedKeys, timestampWithMs, uuid4 } from '@sentry/utils';
45

56
import { SpanStatus } from './spanstatus';
7+
import { computeTracestateValue } from './utils';
68

79
/**
810
* Keeps track of finished spans for a given transaction
@@ -289,7 +291,12 @@ export class Span implements SpanInterface {
289291
*/
290292
public getTraceHeaders(): TraceHeaders {
291293
// 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
294+
let tracestate;
295+
if (this.transaction) {
296+
tracestate = this.transaction.metadata?.tracestate?.sentry;
297+
} else {
298+
tracestate = this._getNewTracestate();
299+
}
293300

294301
return {
295302
'sentry-trace': this.toTraceparent(),
@@ -352,4 +359,32 @@ export class Span implements SpanInterface {
352359
trace_id: this.traceId,
353360
});
354361
}
362+
363+
/**
364+
* Create a new Sentry tracestate header entry (i.e. `sentry=xxxxxx`)
365+
*
366+
* @returns The new Sentry tracestate entry, or undefined if there's no client or no dsn
367+
*/
368+
protected _getNewTracestate(hub: Hub = getCurrentHub()): string | undefined {
369+
const client = hub.getClient();
370+
const dsn = client?.getDsn();
371+
372+
if (!client || !dsn) {
373+
return;
374+
}
375+
376+
const { environment, release } = client.getOptions() || {};
377+
378+
// TODO - the only reason we need the non-null assertion on `dsn.publicKey` (below) is because `dsn.publicKey` has
379+
// to be optional while we transition from `dsn.user` -> `dsn.publicKey`. Once `dsn.user` is removed, we can make
380+
// `dsn.publicKey` required and remove the `!`.
381+
382+
return `sentry=${computeTracestateValue({
383+
trace_id: this.traceId,
384+
environment,
385+
release,
386+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
387+
public_key: dsn.publicKey!,
388+
})}`;
389+
}
355390
}

packages/tracing/src/transaction.ts

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { getCurrentHub, Hub } from '@sentry/hub';
2-
import { DebugMeta, Event, Measurements, Transaction as TransactionInterface, TransactionContext } from '@sentry/types';
2+
import {
3+
Event,
4+
Measurements,
5+
Transaction as TransactionInterface,
6+
TransactionContext,
7+
TransactionMetadata,
8+
} from '@sentry/types';
39
import { dropUndefinedKeys, isInstanceOf, logger } from '@sentry/utils';
410

511
import { Span as SpanClass, SpanRecorder } from './span';
6-
import { computeTracestateValue } from './utils';
7-
8-
type TransactionMetadata = Pick<DebugMeta, 'transactionSampling' | 'tracestate'>;
912

1013
/** JSDoc */
1114
export class Transaction extends SpanClass implements TransactionInterface {
1215
public name: string;
1316

14-
public readonly tracestate: string;
15-
16-
private _metadata: TransactionMetadata = {};
17+
public metadata: TransactionMetadata;
1718

1819
private _measurements: Measurements = {};
1920

@@ -42,9 +43,9 @@ export class Transaction extends SpanClass implements TransactionInterface {
4243

4344
this._trimEnd = transactionContext.trimEnd;
4445

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';
46+
this.metadata = transactionContext.metadata || {};
47+
this.metadata.tracestate = this.metadata.tracestate || {};
48+
this.metadata.tracestate.sentry = this.metadata.tracestate.sentry || this._getNewTracestate(this._hub);
4849

4950
// this is because transactions are also spans, and spans have a transaction pointer
5051
this.transaction = this;
@@ -81,7 +82,7 @@ export class Transaction extends SpanClass implements TransactionInterface {
8182
* @hidden
8283
*/
8384
public setMetadata(newMetadata: TransactionMetadata): void {
84-
this._metadata = { ...this._metadata, ...newMetadata };
85+
this.metadata = { ...this.metadata, ...newMetadata };
8586
}
8687

8788
/**
@@ -118,8 +119,6 @@ export class Transaction extends SpanClass implements TransactionInterface {
118119
}).endTimestamp;
119120
}
120121

121-
this._metadata.tracestate = this.tracestate;
122-
123122
const transaction: Event = {
124123
contexts: {
125124
trace: this.getTraceContext(),
@@ -130,7 +129,7 @@ export class Transaction extends SpanClass implements TransactionInterface {
130129
timestamp: this.endTimestamp,
131130
transaction: this.name,
132131
type: 'transaction',
133-
debug_meta: this._metadata,
132+
debug_meta: this.metadata,
134133
};
135134

136135
const hasMeasurements = Object.keys(this._measurements).length > 0;
@@ -170,27 +169,4 @@ export class Transaction extends SpanClass implements TransactionInterface {
170169

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

packages/tracing/src/utils.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,22 +68,23 @@ export function secToMs(time: number): number {
6868
// so it can be used in manual instrumentation without necessitating a hard dependency on @sentry/utils
6969
export { stripUrlQueryAndFragment } from '@sentry/utils';
7070

71+
type TracestateData = {
72+
trace_id: string;
73+
environment: string | undefined | null;
74+
release: string | undefined | null;
75+
public_key: string;
76+
};
77+
7178
/**
72-
* Compute the value of a tracestate header.
79+
* Compute the value of a Sentry tracestate header.
7380
*
7481
* @throws SentryError (because using the logger creates a circular dependency)
7582
* @returns the base64-encoded header value
7683
*/
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+
export function computeTracestateValue(data: TracestateData): string {
8485
// `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;
86+
data.environment = data.environment || null;
87+
data.release = data.release || null;
8788

8889
// See https://www.w3.org/TR/trace-context/#tracestate-header-field-values
8990
// The spec for tracestate header values calls for a string of the form
@@ -94,8 +95,8 @@ export function computeTracestateValue(tracestateData: {
9495
// used to pad the end of base64 values though, so to avoid confusion, we strip them off. (Most languages' base64
9596
// decoding functions (including those in JS) are able to function without the padding.)
9697
try {
97-
return unicodeToBase64(JSON.stringify(tracestateData)).replace(/={1,2}$/, '');
98+
return unicodeToBase64(JSON.stringify(data)).replace(/={1,2}$/, '');
9899
} catch (err) {
99-
throw new SentryError(`[Tracing] Error creating tracestate header: ${err}`);
100+
throw new SentryError(`[Tracing] Error computing tracestate value from data: ${err}\nData: ${data}`);
100101
}
101102
}

packages/tracing/test/hub.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,19 @@ describe('Hub', () => {
3535
name: 'dogpark',
3636
traceId: '12312012123120121231201212312012',
3737
parentSpanId: '1121201211212012',
38-
tracestate: 'doGsaREgReaT',
38+
metadata: { tracestate: { sentry: 'sentry=doGsaREgReaT' } },
3939
};
4040
const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 }));
4141
const transaction = hub.startTransaction(transactionContext);
4242

43-
expect(transaction).toEqual(expect.objectContaining(transactionContext));
43+
expect(transaction).toEqual(
44+
expect.objectContaining({
45+
name: 'dogpark',
46+
traceId: '12312012123120121231201212312012',
47+
parentSpanId: '1121201211212012',
48+
metadata: expect.objectContaining({ tracestate: { sentry: 'sentry=doGsaREgReaT' } }),
49+
}),
50+
);
4451
});
4552

4653
it('creates a new tracestate value if not given one in transaction context', () => {
@@ -62,7 +69,7 @@ describe('Hub', () => {
6269
public_key: 'dogsarebadatkeepingsecrets',
6370
});
6471

65-
expect(transaction.tracestate).toEqual(b64Value);
72+
expect(transaction.metadata?.tracestate?.sentry).toEqual(`sentry=${b64Value}`);
6673
});
6774

6875
it('uses default environment if none given', () => {
@@ -80,7 +87,7 @@ describe('Hub', () => {
8087
public_key: 'dogsarebadatkeepingsecrets',
8188
});
8289

83-
expect(transaction.tracestate).toEqual(b64Value);
90+
expect(transaction.metadata?.tracestate?.sentry).toEqual(`sentry=${b64Value}`);
8491
});
8592
});
8693

packages/tracing/test/span.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe('Span', () => {
117117
const headers = span.getTraceHeaders();
118118

119119
expect(headers['sentry-trace']).toEqual(span.toTraceparent());
120-
expect(headers.tracestate).toEqual(`sentry=${transaction.tracestate}`);
120+
expect(headers.tracestate).toEqual(transaction.metadata?.tracestate?.sentry);
121121
});
122122
});
123123

packages/types/src/debugMeta.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1+
import { TransactionMetadata } from './transaction';
2+
13
/**
24
* Holds meta information to customize the behavior of sentry's event processing.
35
**/
4-
export interface DebugMeta {
6+
export type DebugMeta = {
57
images?: Array<DebugImage>;
6-
transactionSampling?: { rate?: number; method?: string };
7-
8-
/** The Sentry portion of a transaction's tracestate header, used for dynamic sampling */
9-
tracestate?: string;
10-
}
8+
} & TransactionMetadata;
119

1210
/**
1311
* Possible choices for debug images.

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export {
3434
TraceparentData,
3535
Transaction,
3636
TransactionContext,
37+
TransactionMetadata,
3738
TransactionSamplingMethod,
3839
} from './transaction';
3940
export { Thread } from './thread';

packages/types/src/transaction.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ export interface TransactionContext extends SpanContext {
2424
parentSampled?: boolean;
2525

2626
/**
27-
* The tracestate header value associated with this transaction, potentially inherited from a parent transaction,
28-
* which will be propagated across services to all child transactions
27+
* Metadata associated with the transaction, for internal SDK use.
2928
*/
30-
tracestate?: string;
29+
metadata?: TransactionMetadata;
3130
}
3231

3332
/**
@@ -119,3 +118,12 @@ export enum TransactionSamplingMethod {
119118
Rate = 'client_rate',
120119
Inheritance = 'inheritance',
121120
}
121+
122+
export interface TransactionMetadata {
123+
transactionSampling?: { rate?: number; method?: string };
124+
125+
/** The sentry half of a transaction's tracestate header, used for dynamic sampling */
126+
tracestate?: {
127+
sentry?: string;
128+
};
129+
}

0 commit comments

Comments
 (0)