Skip to content

Commit 5e6b852

Browse files
authored
feat(opentelemetry): Add addLink(s) to span (#15387)
Link spans which are related. Example: ```javascript const span1 = startInactiveSpan({ name: 'span1' }); startSpan({ name: 'span2' }, span2 => { span2.addLink({ context: span1.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' }, }); ```
1 parent f92f39b commit 5e6b852

File tree

11 files changed

+374
-4
lines changed

11 files changed

+374
-4
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ module.exports = [
5454
path: 'packages/browser/build/npm/esm/index.js',
5555
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
5656
gzip: true,
57-
limit: '68 KB',
57+
limit: '70 KB',
5858
modifyWebpackConfig: function (config) {
5959
const webpack = require('webpack');
6060
const TerserPlugin = require('terser-webpack-plugin');
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
integrations: [],
9+
transport: loggingTransport,
10+
});
11+
12+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
13+
Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => {
14+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
15+
Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => {
16+
childSpan1.addLink({
17+
context: parentSpan1.spanContext(),
18+
attributes: { 'sentry.link.type': 'previous_trace' },
19+
});
20+
21+
childSpan1.end();
22+
});
23+
24+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
25+
Sentry.startSpan({ name: 'child1.2' }, async childSpan2 => {
26+
childSpan2.addLink({
27+
context: parentSpan1.spanContext(),
28+
attributes: { 'sentry.link.type': 'previous_trace' },
29+
});
30+
31+
childSpan2.end();
32+
});
33+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
integrations: [],
9+
transport: loggingTransport,
10+
});
11+
12+
const span1 = Sentry.startInactiveSpan({ name: 'span1' });
13+
span1.end();
14+
15+
Sentry.startSpan({ name: 'rootSpan' }, rootSpan => {
16+
rootSpan.addLink({
17+
context: span1.spanContext(),
18+
attributes: { 'sentry.link.type': 'previous_trace' },
19+
});
20+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
integrations: [],
9+
transport: loggingTransport,
10+
});
11+
12+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
13+
Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => {
14+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
15+
Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => {
16+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
17+
Sentry.startSpan({ name: 'child2.1' }, async childSpan2 => {
18+
childSpan2.addLinks([
19+
{ context: parentSpan1.spanContext() },
20+
{
21+
context: childSpan1.spanContext(),
22+
attributes: { 'sentry.link.type': 'previous_trace' },
23+
},
24+
]);
25+
26+
childSpan2.end();
27+
});
28+
29+
childSpan1.end();
30+
});
31+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
integrations: [],
9+
transport: loggingTransport,
10+
});
11+
12+
const span1 = Sentry.startInactiveSpan({ name: 'span1' });
13+
span1.end();
14+
15+
const span2 = Sentry.startInactiveSpan({ name: 'span2' });
16+
span2.end();
17+
18+
Sentry.startSpan({ name: 'rootSpan' }, rootSpan => {
19+
rootSpan.addLinks([
20+
{ context: span1.spanContext() },
21+
{
22+
context: span2.spanContext(),
23+
attributes: { 'sentry.link.type': 'previous_trace' },
24+
},
25+
]);
26+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { createRunner } from '../../../utils/runner';
2+
3+
describe('span links', () => {
4+
test('should link spans with addLink() in trace context', done => {
5+
let span1_traceId: string, span1_spanId: string;
6+
7+
createRunner(__dirname, 'scenario-addLink.ts')
8+
.expect({
9+
transaction: event => {
10+
expect(event.transaction).toBe('span1');
11+
12+
span1_traceId = event.contexts?.trace?.trace_id as string;
13+
span1_spanId = event.contexts?.trace?.span_id as string;
14+
15+
expect(event.spans).toEqual([]);
16+
},
17+
})
18+
.expect({
19+
transaction: event => {
20+
expect(event.transaction).toBe('rootSpan');
21+
22+
expect(event.contexts?.trace?.links).toEqual([
23+
expect.objectContaining({
24+
trace_id: expect.stringMatching(span1_traceId),
25+
span_id: expect.stringMatching(span1_spanId),
26+
attributes: expect.objectContaining({
27+
'sentry.link.type': 'previous_trace',
28+
}),
29+
}),
30+
]);
31+
},
32+
})
33+
.start(done);
34+
});
35+
36+
test('should link spans with addLinks() in trace context', done => {
37+
let span1_traceId: string, span1_spanId: string, span2_traceId: string, span2_spanId: string;
38+
39+
createRunner(__dirname, 'scenario-addLinks.ts')
40+
.expect({
41+
transaction: event => {
42+
expect(event.transaction).toBe('span1');
43+
44+
span1_traceId = event.contexts?.trace?.trace_id as string;
45+
span1_spanId = event.contexts?.trace?.span_id as string;
46+
47+
expect(event.spans).toEqual([]);
48+
},
49+
})
50+
.expect({
51+
transaction: event => {
52+
expect(event.transaction).toBe('span2');
53+
54+
span2_traceId = event.contexts?.trace?.trace_id as string;
55+
span2_spanId = event.contexts?.trace?.span_id as string;
56+
57+
expect(event.spans).toEqual([]);
58+
},
59+
})
60+
.expect({
61+
transaction: event => {
62+
expect(event.transaction).toBe('rootSpan');
63+
64+
expect(event.contexts?.trace?.links).toEqual([
65+
expect.not.objectContaining({ attributes: expect.anything() }) &&
66+
expect.objectContaining({
67+
trace_id: expect.stringMatching(span1_traceId),
68+
span_id: expect.stringMatching(span1_spanId),
69+
}),
70+
expect.objectContaining({
71+
trace_id: expect.stringMatching(span2_traceId),
72+
span_id: expect.stringMatching(span2_spanId),
73+
attributes: expect.objectContaining({
74+
'sentry.link.type': 'previous_trace',
75+
}),
76+
}),
77+
]);
78+
},
79+
})
80+
.start(done);
81+
});
82+
83+
test('should link spans with addLink() in nested startSpan() calls', done => {
84+
createRunner(__dirname, 'scenario-addLink-nested.ts')
85+
.expect({
86+
transaction: event => {
87+
expect(event.transaction).toBe('parent1');
88+
89+
const parent1_traceId = event.contexts?.trace?.trace_id as string;
90+
const parent1_spanId = event.contexts?.trace?.span_id as string;
91+
92+
const spans = event.spans || [];
93+
const child1_1 = spans.find(span => span.description === 'child1.1');
94+
const child1_2 = spans.find(span => span.description === 'child1.2');
95+
96+
expect(child1_1).toBeDefined();
97+
expect(child1_1?.links).toEqual([
98+
expect.objectContaining({
99+
trace_id: expect.stringMatching(parent1_traceId),
100+
span_id: expect.stringMatching(parent1_spanId),
101+
attributes: expect.objectContaining({
102+
'sentry.link.type': 'previous_trace',
103+
}),
104+
}),
105+
]);
106+
107+
expect(child1_2).toBeDefined();
108+
expect(child1_2?.links).toEqual([
109+
expect.objectContaining({
110+
trace_id: expect.stringMatching(parent1_traceId),
111+
span_id: expect.stringMatching(parent1_spanId),
112+
attributes: expect.objectContaining({
113+
'sentry.link.type': 'previous_trace',
114+
}),
115+
}),
116+
]);
117+
},
118+
})
119+
.start(done);
120+
});
121+
122+
test('should link spans with addLinks() in nested startSpan() calls', done => {
123+
createRunner(__dirname, 'scenario-addLinks-nested.ts')
124+
.expect({
125+
transaction: event => {
126+
expect(event.transaction).toBe('parent1');
127+
128+
const parent1_traceId = event.contexts?.trace?.trace_id as string;
129+
const parent1_spanId = event.contexts?.trace?.span_id as string;
130+
131+
const spans = event.spans || [];
132+
const child1_1 = spans.find(span => span.description === 'child1.1');
133+
const child2_1 = spans.find(span => span.description === 'child2.1');
134+
135+
expect(child1_1).toBeDefined();
136+
137+
expect(child2_1).toBeDefined();
138+
139+
expect(child2_1?.links).toEqual([
140+
expect.not.objectContaining({ attributes: expect.anything() }) &&
141+
expect.objectContaining({
142+
trace_id: expect.stringMatching(parent1_traceId),
143+
span_id: expect.stringMatching(parent1_spanId),
144+
}),
145+
expect.objectContaining({
146+
trace_id: expect.stringMatching(child1_1?.trace_id || 'non-existent-id-fallback'),
147+
span_id: expect.stringMatching(child1_1?.span_id || 'non-existent-id-fallback'),
148+
attributes: expect.objectContaining({
149+
'sentry.link.type': 'previous_trace',
150+
}),
151+
}),
152+
]);
153+
},
154+
})
155+
.start(done);
156+
});
157+
});

packages/core/src/types-hoist/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FeatureFlag } from '../featureFlags';
2+
import type { SpanLinkJSON } from './link';
23
import type { Primitive } from './misc';
34
import type { SpanOrigin } from './span';
45

@@ -106,6 +107,7 @@ export interface TraceContext extends Record<string, unknown> {
106107
tags?: { [key: string]: Primitive };
107108
trace_id: string;
108109
origin?: SpanOrigin;
110+
links?: SpanLinkJSON[];
109111
}
110112

111113
export interface CloudResourceContext extends Record<string, unknown> {

packages/core/src/utils/spanUtils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export function spanToJSON(span: Span): SpanJSON {
144144

145145
// Handle a span from @opentelemetry/sdk-base-trace's `Span` class
146146
if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) {
147-
const { attributes, startTime, name, endTime, parentSpanId, status } = span;
147+
const { attributes, startTime, name, endTime, parentSpanId, status, links } = span;
148148

149149
return dropUndefinedKeys({
150150
span_id,
@@ -158,6 +158,7 @@ export function spanToJSON(span: Span): SpanJSON {
158158
status: getStatusMessage(status),
159159
op: attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP],
160160
origin: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined,
161+
links: convertSpanLinksForEnvelope(links),
161162
});
162163
}
163164

@@ -184,6 +185,7 @@ export interface OpenTelemetrySdkTraceBaseSpan extends Span {
184185
status: SpanStatus;
185186
endTime: SpanTimeInput;
186187
parentSpanId?: string;
188+
links?: SpanLink[];
187189
}
188190

189191
/**

packages/opentelemetry/src/spanExporter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
TransactionEvent,
1212
TransactionSource,
1313
} from '@sentry/core';
14+
import { convertSpanLinksForEnvelope } from '@sentry/core';
1415
import {
1516
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
1617
SEMANTIC_ATTRIBUTE_SENTRY_OP,
@@ -247,6 +248,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve
247248
...removeSentryAttributes(span.attributes),
248249
});
249250

251+
const { links } = span;
250252
const { traceId: trace_id, spanId: span_id } = span.spanContext();
251253

252254
// If parentSpanIdFromTraceState is defined at all, we want it to take precedence
@@ -266,6 +268,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve
266268
origin,
267269
op,
268270
status: getStatusMessage(status), // As per protocol, span status is allowed to be undefined
271+
links: convertSpanLinksForEnvelope(links),
269272
});
270273

271274
const statusCode = attributes[ATTR_HTTP_RESPONSE_STATUS_CODE];
@@ -322,7 +325,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS
322325
const span_id = span.spanContext().spanId;
323326
const trace_id = span.spanContext().traceId;
324327

325-
const { attributes, startTime, endTime, parentSpanId } = span;
328+
const { attributes, startTime, endTime, parentSpanId, links } = span;
326329

327330
const { op, description, data, origin = 'manual' } = getSpanData(span);
328331
const allData = dropUndefinedKeys({
@@ -347,6 +350,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS
347350
op,
348351
origin,
349352
measurements: timedEventsToMeasurements(span.events),
353+
links: convertSpanLinksForEnvelope(links),
350354
});
351355

352356
spans.push(spanJSON);

packages/opentelemetry/test/spanExporter.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions';
2-
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan } from '@sentry/core';
2+
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan, startSpanManual } from '@sentry/core';
33
import { createTransactionForOtelSpan } from '../src/spanExporter';
44
import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit';
55

@@ -108,4 +108,31 @@ describe('createTransactionForOtelSpan', () => {
108108
transaction_info: { source: 'custom' },
109109
});
110110
});
111+
112+
it('adds span link to the trace context when adding with addLink()', () => {
113+
const span = startInactiveSpan({ name: 'parent1' });
114+
span.end();
115+
116+
startSpanManual({ name: 'rootSpan' }, rootSpan => {
117+
rootSpan.addLink({ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } });
118+
rootSpan.end();
119+
120+
const prevTraceId = span.spanContext().traceId;
121+
const prevSpanId = span.spanContext().spanId;
122+
const event = createTransactionForOtelSpan(rootSpan as any);
123+
124+
expect(event.contexts?.trace).toEqual(
125+
expect.objectContaining({
126+
links: [
127+
expect.objectContaining({
128+
attributes: { 'sentry.link.type': 'previous_trace' },
129+
sampled: true,
130+
trace_id: expect.stringMatching(prevTraceId),
131+
span_id: expect.stringMatching(prevSpanId),
132+
}),
133+
],
134+
}),
135+
);
136+
});
137+
});
111138
});

0 commit comments

Comments
 (0)