Skip to content

Commit d90032b

Browse files
committed
add functions for computing tracestate and combined tracing headers
1 parent 2a78ae4 commit d90032b

File tree

3 files changed

+199
-5
lines changed

3 files changed

+199
-5
lines changed

packages/tracing/src/span.ts

Lines changed: 87 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, Transaction } from '@sentry/types';
3-
import { dropUndefinedKeys, timestampWithMs, uuid4 } from '@sentry/utils';
2+
import { getCurrentHub, Hub } from '@sentry/hub';
3+
import { Primitive, Span as SpanInterface, SpanContext, TraceHeaders, Transaction } from '@sentry/types';
4+
import { dropUndefinedKeys, logger, 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
@@ -284,6 +286,26 @@ export class Span implements SpanInterface {
284286
return this;
285287
}
286288

289+
/**
290+
* @inheritDoc
291+
*/
292+
public getTraceHeaders(): TraceHeaders {
293+
// if this span is part of a transaction, but that transaction doesn't yet have a tracestate value, create one
294+
if (this.transaction && !this.transaction?.metadata.tracestate?.sentry) {
295+
this.transaction.metadata.tracestate = {
296+
...this.transaction.metadata.tracestate,
297+
sentry: this._getNewTracestate(),
298+
};
299+
}
300+
301+
const tracestate = this._toTracestate();
302+
303+
return {
304+
'sentry-trace': this._toSentrytrace(),
305+
...(tracestate && { tracestate }),
306+
};
307+
}
308+
287309
/**
288310
* @inheritDoc
289311
*/
@@ -339,4 +361,67 @@ export class Span implements SpanInterface {
339361
trace_id: this.traceId,
340362
});
341363
}
364+
365+
/**
366+
* Create a new Sentry tracestate header entry (i.e. `sentry=xxxxxx`)
367+
*
368+
* @returns The new Sentry tracestate entry, or undefined if there's no client or no dsn
369+
*/
370+
protected _getNewTracestate(): string | undefined {
371+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
372+
const hub = ((this.transaction as any)?._hub as Hub) || getCurrentHub();
373+
const client = hub.getClient();
374+
const { id: userId, segment: userSegment } = hub.getScope()?.getUser() || {};
375+
const dsn = client?.getDsn();
376+
377+
if (!client || !dsn) {
378+
return;
379+
}
380+
381+
const { environment, release } = client.getOptions() || {};
382+
383+
// only define a `user` object if there's going to be something in it (note: prettier insists on removing the
384+
// parentheses, but since it's easy to misinterpret this, imagine `()` around `userId || userSegment`)
385+
const user = userId || userSegment ? { id: userId, segment: userSegment } : undefined;
386+
387+
// TODO - the only reason we need the non-null assertion on `dsn.publicKey` (below) is because `dsn.publicKey` has
388+
// to be optional while we transition from `dsn.user` -> `dsn.publicKey`. Once `dsn.user` is removed, we can make
389+
// `dsn.publicKey` required and remove the `!`.
390+
391+
return `sentry=${computeTracestateValue({
392+
trace_id: this.traceId,
393+
environment,
394+
release,
395+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
396+
public_key: dsn.publicKey!,
397+
user,
398+
})}`;
399+
}
400+
401+
/**
402+
* Return a traceparent-compatible header string.
403+
*/
404+
private _toSentrytrace(): string {
405+
let sampledString = '';
406+
if (this.sampled !== undefined) {
407+
sampledString = this.sampled ? '-1' : '-0';
408+
}
409+
return `${this.traceId}-${this.spanId}${sampledString}`;
410+
}
411+
412+
/**
413+
* Return a tracestate-compatible header string, including both sentry and third-party data (if any). Returns
414+
* undefined if there is no client or no DSN.
415+
*/
416+
private _toTracestate(): string | undefined {
417+
// if this is an orphan span, create a new tracestate value
418+
const sentryTracestate = this.transaction?.metadata?.tracestate?.sentry || this._getNewTracestate();
419+
let thirdpartyTracestate = this.transaction?.metadata?.tracestate?.thirdparty;
420+
421+
// if there's third-party data, add a leading comma; otherwise, convert from `undefined` to the empty string, so the
422+
// end result doesn’t come out as `sentry=xxxxxundefined`
423+
thirdpartyTracestate = thirdpartyTracestate ? `,${thirdpartyTracestate}` : '';
424+
425+
return `${sentryTracestate}${thirdpartyTracestate}`;
426+
}
342427
}

packages/tracing/src/utils.ts

Lines changed: 34 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 { dropUndefinedKeys, SentryError, unicodeToBase64 } from '@sentry/utils';
34

45
export const SENTRY_TRACE_REGEX = new RegExp(
56
'^[ \\t]*' + // whitespace
@@ -75,3 +76,36 @@ export function secToMs(time: number): number {
7576

7677
// so it can be used in manual instrumentation without necessitating a hard dependency on @sentry/utils
7778
export { stripUrlQueryAndFragment } from '@sentry/utils';
79+
80+
type SentryTracestateData = {
81+
trace_id: string;
82+
environment?: string;
83+
release?: string;
84+
public_key: string;
85+
user?: { id?: string; segment?: string };
86+
};
87+
88+
/**
89+
* Compute the value of a Sentry tracestate header.
90+
*
91+
* @throws SentryError (because using the logger creates a circular dependency)
92+
* @returns the base64-encoded header value
93+
*/
94+
export function computeTracestateValue(data: SentryTracestateData): string {
95+
// `JSON.stringify` will drop keys with undefined values, but not ones with null values, so this prevents
96+
// these values from being dropped if they haven't been set by `Sentry.init`
97+
98+
// See https://www.w3.org/TR/trace-context/#tracestate-header-field-values
99+
// The spec for tracestate header values calls for a string of the form
100+
//
101+
// identifier1=value1,identifier2=value2,...
102+
//
103+
// which means the value can't include any equals signs, since they already have meaning. Equals signs are commonly
104+
// used to pad the end of base64 values though, so to avoid confusion, we strip them off. (Most languages' base64
105+
// decoding functions (including those in JS) are able to function without the padding.)
106+
try {
107+
return unicodeToBase64(JSON.stringify(dropUndefinedKeys(data))).replace(/={1,2}$/, '');
108+
} catch (err) {
109+
throw new SentryError(`[Tracing] Error computing tracestate value from data: ${err}\nData: ${data}`);
110+
}
111+
}

packages/tracing/test/span.test.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { BrowserClient } from '@sentry/browser';
22
import { Hub, makeMain, Scope } from '@sentry/hub';
3+
import * as hubPackage from '@sentry/hub';
34

45
import { Span, SpanStatus, Transaction } from '../src';
5-
import { SENTRY_TRACE_REGEX } from '../src/utils';
6+
import { computeTracestateValue, SENTRY_TRACE_REGEX } from '../src/utils';
67

78
describe('Span', () => {
89
let hub: Hub;
@@ -93,12 +94,86 @@ describe('Span', () => {
9394
});
9495
});
9596

97+
// TODO Once toTraceparent is removed, we obv don't need these tests anymore
9698
describe('toTraceparent', () => {
9799
test('simple', () => {
98-
expect(new Span().toTraceparent()).toMatch(SENTRY_TRACE_REGEX);
100+
expect(new Span().getTraceHeaders()['sentry-trace']).toMatch(SENTRY_TRACE_REGEX);
99101
});
100102
test('with sample', () => {
101-
expect(new Span({ sampled: true }).toTraceparent()).toMatch(SENTRY_TRACE_REGEX);
103+
expect(new Span({ sampled: true }).getTraceHeaders()['sentry-trace']).toMatch(SENTRY_TRACE_REGEX);
104+
});
105+
});
106+
107+
describe('toTracestate', () => {
108+
const publicKey = 'dogsarebadatkeepingsecrets';
109+
const release = 'off.leash.trail';
110+
const environment = 'dogpark';
111+
const traceId = '12312012123120121231201212312012';
112+
const user = { id: '1121', segment: 'bigs' };
113+
114+
const computedTracestate = `sentry=${computeTracestateValue({
115+
trace_id: traceId,
116+
environment,
117+
release,
118+
public_key: publicKey,
119+
user,
120+
})}`;
121+
const thirdpartyData = 'maisey=silly,charlie=goofy';
122+
123+
const hub = new Hub(
124+
new BrowserClient({
125+
dsn: 'https://[email protected]/12312012',
126+
tracesSampleRate: 1,
127+
release,
128+
environment,
129+
}),
130+
);
131+
132+
hub.configureScope(scope => {
133+
scope.setUser(user);
134+
});
135+
136+
test('no third-party data', () => {
137+
const transaction = new Transaction({ name: 'FETCH /ball', traceId }, hub);
138+
const span = transaction.startChild({ op: 'dig.hole' });
139+
140+
expect(span.getTraceHeaders().tracestate).toEqual(computedTracestate);
141+
});
142+
143+
test('third-party data', () => {
144+
const transaction = new Transaction({ name: 'FETCH /ball' }, hub);
145+
transaction.setMetadata({ tracestate: { sentry: computedTracestate, thirdparty: thirdpartyData } });
146+
const span = transaction.startChild({ op: 'dig.hole' });
147+
148+
expect(span.getTraceHeaders().tracestate).toEqual(`${computedTracestate},${thirdpartyData}`);
149+
});
150+
151+
test('orphan span', () => {
152+
jest.spyOn(hubPackage, 'getCurrentHub').mockReturnValueOnce(hub);
153+
const span = new Span({ op: 'dig.hole' });
154+
span.traceId = traceId;
155+
156+
expect(span.getTraceHeaders().tracestate).toEqual(computedTracestate);
157+
});
158+
});
159+
160+
describe('getTraceHeaders', () => {
161+
it('returns correct headers', () => {
162+
const hub = new Hub(
163+
new BrowserClient({
164+
dsn: 'https://[email protected]/12312012',
165+
tracesSampleRate: 1,
166+
release: 'off.leash.park',
167+
environment: 'dogpark',
168+
}),
169+
);
170+
const transaction = hub.startTransaction({ name: 'FETCH /ball' });
171+
const span = transaction.startChild({ op: 'dig.hole' });
172+
173+
const headers = span.getTraceHeaders();
174+
175+
expect(headers['sentry-trace']).toEqual(`${span.traceId}-${span.spanId}-1`);
176+
expect(headers.tracestate).toEqual(transaction.metadata?.tracestate?.sentry);
102177
});
103178
});
104179

0 commit comments

Comments
 (0)