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://public@dsn.ingest.sentry.io/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://public@dsn.ingest.sentry.io/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://public@dsn.ingest.sentry.io/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://public@dsn.ingest.sentry.io/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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy