Skip to content

Commit aa1ab44

Browse files
committed
feat(node): Add addLink(s) to span
1 parent 6db209c commit aa1ab44

File tree

7 files changed

+283
-3
lines changed

7 files changed

+283
-3
lines changed
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+
});
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+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { createRunner } from '../../../utils/runner';
2+
3+
// A general note regarding this test:
4+
// The fact that the trace_id and span_id are correctly linked is tested in a unit test
5+
6+
describe('span links', () => {
7+
test('should link spans with addLink()', done => {
8+
createRunner(__dirname, 'scenario-addLink.ts')
9+
.expect({
10+
transaction: {
11+
transaction: 'parent1',
12+
spans: [
13+
expect.objectContaining({
14+
description: 'child1.1',
15+
links: [
16+
expect.objectContaining({
17+
trace_id: expect.any(String),
18+
span_id: expect.any(String),
19+
attributes: expect.objectContaining({
20+
'sentry.link.type': 'previous_trace',
21+
}),
22+
}),
23+
],
24+
}),
25+
expect.objectContaining({
26+
description: 'child1.2',
27+
links: [
28+
expect.objectContaining({
29+
trace_id: expect.any(String),
30+
span_id: expect.any(String),
31+
attributes: expect.objectContaining({
32+
'sentry.link.type': 'previous_trace',
33+
}),
34+
}),
35+
],
36+
}),
37+
],
38+
},
39+
})
40+
.start(done);
41+
});
42+
43+
test('should link spans with addLinks()', done => {
44+
createRunner(__dirname, 'scenario-addLinks.ts')
45+
.expect({
46+
transaction: {
47+
transaction: 'parent1',
48+
spans: [
49+
expect.objectContaining({
50+
description: 'child1.1',
51+
links: [],
52+
}),
53+
expect.objectContaining({
54+
description: 'child2.1',
55+
links: [
56+
expect.not.objectContaining({ attributes: expect.anything() }) &&
57+
expect.objectContaining({
58+
trace_id: expect.any(String),
59+
span_id: expect.any(String),
60+
}),
61+
expect.objectContaining({
62+
trace_id: expect.any(String),
63+
span_id: expect.any(String),
64+
attributes: expect.objectContaining({
65+
'sentry.link.type': 'previous_trace',
66+
}),
67+
}),
68+
],
69+
}),
70+
],
71+
},
72+
})
73+
.start(done);
74+
});
75+
});

packages/core/src/utils/spanUtils.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function spanToJSON(span: Span): SpanJSON {
139139

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

144144
return dropUndefinedKeys({
145145
span_id,
@@ -153,6 +153,7 @@ export function spanToJSON(span: Span): SpanJSON {
153153
status: getStatusMessage(status),
154154
op: attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP],
155155
origin: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined,
156+
links: links ? convertSpanLinksForEnvelope(links) : undefined,
156157
});
157158
}
158159

@@ -179,6 +180,7 @@ export interface OpenTelemetrySdkTraceBaseSpan extends Span {
179180
status: SpanStatus;
180181
endTime: SpanTimeInput;
181182
parentSpanId?: string;
183+
links?: SpanLink[];
182184
}
183185

184186
/**

packages/core/test/lib/tracing/trace.test.ts

+69
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SpanLink } from '@sentry/core';
12
import {
23
SEMANTIC_ATTRIBUTE_SENTRY_OP,
34
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -399,6 +400,40 @@ describe('startSpan', () => {
399400
});
400401
});
401402

403+
it('allows to pass span links', () => {
404+
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });
405+
406+
// @ts-expect-error links exists on span
407+
expect(rawSpan1?.links).toEqual([]);
408+
409+
const span1JSON = spanToJSON(rawSpan1);
410+
411+
startSpan({ name: '/users/:id' }, rawSpan2 => {
412+
rawSpan2.addLink({
413+
context: rawSpan1.spanContext(),
414+
attributes: {
415+
'sentry.link.type': 'previous_trace',
416+
},
417+
});
418+
419+
const span2LinkJSON = spanToJSON(rawSpan2).links?.[0];
420+
421+
expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');
422+
423+
// @ts-expect-error links and _spanContext exist on span
424+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
425+
// @ts-expect-error links and _spanContext exist on span
426+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(span1JSON.trace_id);
427+
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);
428+
429+
// @ts-expect-error links and _spanContext exist on span
430+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
431+
// @ts-expect-error links and _spanContext exist on span
432+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(span1JSON.span_id);
433+
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);
434+
});
435+
});
436+
402437
it('allows to force a transaction with forceTransaction=true', async () => {
403438
const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
404439
client = new TestClient(options);
@@ -900,6 +935,40 @@ describe('startSpanManual', () => {
900935
});
901936
});
902937

938+
it('allows to pass span links', () => {
939+
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });
940+
941+
// @ts-expect-error links exists on span
942+
expect(rawSpan1?.links).toEqual([]);
943+
944+
const span1JSON = spanToJSON(rawSpan1);
945+
946+
startSpanManual({ name: '/users/:id' }, rawSpan2 => {
947+
rawSpan2.addLink({
948+
context: rawSpan1.spanContext(),
949+
attributes: {
950+
'sentry.link.type': 'previous_trace',
951+
},
952+
});
953+
954+
const span2LinkJSON = spanToJSON(rawSpan2).links?.[0];
955+
956+
expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');
957+
958+
// @ts-expect-error links and _spanContext exist on span
959+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
960+
// @ts-expect-error links and _spanContext exist on span
961+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(span1JSON.trace_id);
962+
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);
963+
964+
// @ts-expect-error links and _spanContext exist on span
965+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
966+
// @ts-expect-error links and _spanContext exist on span
967+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(span1JSON.span_id);
968+
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);
969+
});
970+
});
971+
903972
it('allows to force a transaction with forceTransaction=true', async () => {
904973
const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
905974
client = new TestClient(options);

packages/opentelemetry/src/spanExporter.ts

+3-1
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,
@@ -322,7 +323,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS
322323
const span_id = span.spanContext().spanId;
323324
const trace_id = span.spanContext().traceId;
324325

325-
const { attributes, startTime, endTime, parentSpanId } = span;
326+
const { attributes, startTime, endTime, parentSpanId, links } = span;
326327

327328
const { op, description, data, origin = 'manual' } = getSpanData(span);
328329
const allData = dropUndefinedKeys({
@@ -347,6 +348,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS
347348
op,
348349
origin,
349350
measurements: timedEventsToMeasurements(span.events),
351+
links: links ? convertSpanLinksForEnvelope(links) : undefined,
350352
});
351353

352354
spans.push(spanJSON);

packages/opentelemetry/test/trace.test.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
suppressTracing,
2121
withScope,
2222
} from '@sentry/core';
23-
import type { Event, Scope } from '@sentry/core';
23+
import type { Event, Scope, SpanLink } from '@sentry/core';
2424

2525
import { SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions';
2626
import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace';
@@ -356,6 +356,40 @@ describe('trace', () => {
356356
});
357357
});
358358

359+
it('allows to pass span links', () => {
360+
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });
361+
362+
// @ts-expect-error links exists on span
363+
expect(rawSpan1?.links).toEqual([]);
364+
365+
const span1JSON = spanToJSON(rawSpan1);
366+
367+
startSpan({ name: '/users/:id' }, rawSpan2 => {
368+
rawSpan2.addLink({
369+
context: rawSpan1.spanContext(),
370+
attributes: {
371+
'sentry.link.type': 'previous_trace',
372+
},
373+
});
374+
375+
const span2LinkJSON = spanToJSON(rawSpan2).links?.[0];
376+
377+
expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');
378+
379+
// @ts-expect-error links and _spanContext exist on span
380+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
381+
// @ts-expect-error links and _spanContext exist on span
382+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(span1JSON.trace_id);
383+
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);
384+
385+
// @ts-expect-error links and _spanContext exist on span
386+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
387+
// @ts-expect-error links and _spanContext exist on span
388+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(span1JSON.span_id);
389+
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);
390+
});
391+
});
392+
359393
it('allows to force a transaction with forceTransaction=true', async () => {
360394
const client = getClient()!;
361395
const transactionEvents: Event[] = [];
@@ -906,6 +940,40 @@ describe('trace', () => {
906940
});
907941
});
908942

943+
it('allows to pass span links', () => {
944+
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });
945+
946+
// @ts-expect-error links exists on span
947+
expect(rawSpan1?.links).toEqual([]);
948+
949+
const span1JSON = spanToJSON(rawSpan1);
950+
951+
startSpanManual({ name: '/users/:id' }, rawSpan2 => {
952+
rawSpan2.addLink({
953+
context: rawSpan1.spanContext(),
954+
attributes: {
955+
'sentry.link.type': 'previous_trace',
956+
},
957+
});
958+
959+
const span2LinkJSON = spanToJSON(rawSpan2).links?.[0];
960+
961+
expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');
962+
963+
// @ts-expect-error links and _spanContext exist on span
964+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
965+
// @ts-expect-error links and _spanContext exist on span
966+
expect((rawSpan2?.links as SpanLink[])?.[0].context.traceId).toEqual(span1JSON.trace_id);
967+
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);
968+
969+
// @ts-expect-error links and _spanContext exist on span
970+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
971+
// @ts-expect-error links and _spanContext exist on span
972+
expect((rawSpan2?.links as SpanLink[])?.[0].context.spanId).toEqual(span1JSON.span_id);
973+
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);
974+
});
975+
});
976+
909977
it('allows to force a transaction with forceTransaction=true', async () => {
910978
const client = getClient()!;
911979
const transactionEvents: Event[] = [];

0 commit comments

Comments
 (0)