Skip to content

Commit

Permalink
feat(opentelemetry): Add addLink(s) to span (#15387)
Browse files Browse the repository at this point in the history
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' },
      });
```
  • Loading branch information
s1gr1d authored Feb 17, 2025
1 parent f92f39b commit 5e6b852
Show file tree
Hide file tree
Showing 11 changed files with 374 additions and 4 deletions.
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '68 KB',
limit: '70 KB',
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [],
transport: loggingTransport,
});

// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => {
childSpan1.addLink({
context: parentSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
});

childSpan1.end();
});

// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'child1.2' }, async childSpan2 => {
childSpan2.addLink({
context: parentSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
});

childSpan2.end();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [],
transport: loggingTransport,
});

const span1 = Sentry.startInactiveSpan({ name: 'span1' });
span1.end();

Sentry.startSpan({ name: 'rootSpan' }, rootSpan => {
rootSpan.addLink({
context: span1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [],
transport: loggingTransport,
});

// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'child2.1' }, async childSpan2 => {
childSpan2.addLinks([
{ context: parentSpan1.spanContext() },
{
context: childSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
]);

childSpan2.end();
});

childSpan1.end();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [],
transport: loggingTransport,
});

const span1 = Sentry.startInactiveSpan({ name: 'span1' });
span1.end();

const span2 = Sentry.startInactiveSpan({ name: 'span2' });
span2.end();

Sentry.startSpan({ name: 'rootSpan' }, rootSpan => {
rootSpan.addLinks([
{ context: span1.spanContext() },
{
context: span2.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
]);
});
157 changes: 157 additions & 0 deletions dev-packages/node-integration-tests/suites/tracing/linking/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createRunner } from '../../../utils/runner';

describe('span links', () => {
test('should link spans with addLink() in trace context', done => {
let span1_traceId: string, span1_spanId: string;

createRunner(__dirname, 'scenario-addLink.ts')
.expect({
transaction: event => {
expect(event.transaction).toBe('span1');

span1_traceId = event.contexts?.trace?.trace_id as string;
span1_spanId = event.contexts?.trace?.span_id as string;

expect(event.spans).toEqual([]);
},
})
.expect({
transaction: event => {
expect(event.transaction).toBe('rootSpan');

expect(event.contexts?.trace?.links).toEqual([
expect.objectContaining({
trace_id: expect.stringMatching(span1_traceId),
span_id: expect.stringMatching(span1_spanId),
attributes: expect.objectContaining({
'sentry.link.type': 'previous_trace',
}),
}),
]);
},
})
.start(done);
});

test('should link spans with addLinks() in trace context', done => {
let span1_traceId: string, span1_spanId: string, span2_traceId: string, span2_spanId: string;

createRunner(__dirname, 'scenario-addLinks.ts')
.expect({
transaction: event => {
expect(event.transaction).toBe('span1');

span1_traceId = event.contexts?.trace?.trace_id as string;
span1_spanId = event.contexts?.trace?.span_id as string;

expect(event.spans).toEqual([]);
},
})
.expect({
transaction: event => {
expect(event.transaction).toBe('span2');

span2_traceId = event.contexts?.trace?.trace_id as string;
span2_spanId = event.contexts?.trace?.span_id as string;

expect(event.spans).toEqual([]);
},
})
.expect({
transaction: event => {
expect(event.transaction).toBe('rootSpan');

expect(event.contexts?.trace?.links).toEqual([
expect.not.objectContaining({ attributes: expect.anything() }) &&
expect.objectContaining({
trace_id: expect.stringMatching(span1_traceId),
span_id: expect.stringMatching(span1_spanId),
}),
expect.objectContaining({
trace_id: expect.stringMatching(span2_traceId),
span_id: expect.stringMatching(span2_spanId),
attributes: expect.objectContaining({
'sentry.link.type': 'previous_trace',
}),
}),
]);
},
})
.start(done);
});

test('should link spans with addLink() in nested startSpan() calls', done => {
createRunner(__dirname, 'scenario-addLink-nested.ts')
.expect({
transaction: event => {
expect(event.transaction).toBe('parent1');

const parent1_traceId = event.contexts?.trace?.trace_id as string;
const parent1_spanId = event.contexts?.trace?.span_id as string;

const spans = event.spans || [];
const child1_1 = spans.find(span => span.description === 'child1.1');
const child1_2 = spans.find(span => span.description === 'child1.2');

expect(child1_1).toBeDefined();
expect(child1_1?.links).toEqual([
expect.objectContaining({
trace_id: expect.stringMatching(parent1_traceId),
span_id: expect.stringMatching(parent1_spanId),
attributes: expect.objectContaining({
'sentry.link.type': 'previous_trace',
}),
}),
]);

expect(child1_2).toBeDefined();
expect(child1_2?.links).toEqual([
expect.objectContaining({
trace_id: expect.stringMatching(parent1_traceId),
span_id: expect.stringMatching(parent1_spanId),
attributes: expect.objectContaining({
'sentry.link.type': 'previous_trace',
}),
}),
]);
},
})
.start(done);
});

test('should link spans with addLinks() in nested startSpan() calls', done => {
createRunner(__dirname, 'scenario-addLinks-nested.ts')
.expect({
transaction: event => {
expect(event.transaction).toBe('parent1');

const parent1_traceId = event.contexts?.trace?.trace_id as string;
const parent1_spanId = event.contexts?.trace?.span_id as string;

const spans = event.spans || [];
const child1_1 = spans.find(span => span.description === 'child1.1');
const child2_1 = spans.find(span => span.description === 'child2.1');

expect(child1_1).toBeDefined();

expect(child2_1).toBeDefined();

expect(child2_1?.links).toEqual([
expect.not.objectContaining({ attributes: expect.anything() }) &&
expect.objectContaining({
trace_id: expect.stringMatching(parent1_traceId),
span_id: expect.stringMatching(parent1_spanId),
}),
expect.objectContaining({
trace_id: expect.stringMatching(child1_1?.trace_id || 'non-existent-id-fallback'),
span_id: expect.stringMatching(child1_1?.span_id || 'non-existent-id-fallback'),
attributes: expect.objectContaining({
'sentry.link.type': 'previous_trace',
}),
}),
]);
},
})
.start(done);
});
});
2 changes: 2 additions & 0 deletions packages/core/src/types-hoist/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FeatureFlag } from '../featureFlags';
import type { SpanLinkJSON } from './link';
import type { Primitive } from './misc';
import type { SpanOrigin } from './span';

Expand Down Expand Up @@ -106,6 +107,7 @@ export interface TraceContext extends Record<string, unknown> {
tags?: { [key: string]: Primitive };
trace_id: string;
origin?: SpanOrigin;
links?: SpanLinkJSON[];
}

export interface CloudResourceContext extends Record<string, unknown> {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/utils/spanUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export function spanToJSON(span: Span): SpanJSON {

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

return dropUndefinedKeys({
span_id,
Expand All @@ -158,6 +158,7 @@ export function spanToJSON(span: Span): SpanJSON {
status: getStatusMessage(status),
op: attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP],
origin: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined,
links: convertSpanLinksForEnvelope(links),
});
}

Expand All @@ -184,6 +185,7 @@ export interface OpenTelemetrySdkTraceBaseSpan extends Span {
status: SpanStatus;
endTime: SpanTimeInput;
parentSpanId?: string;
links?: SpanLink[];
}

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/opentelemetry/src/spanExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
TransactionEvent,
TransactionSource,
} from '@sentry/core';
import { convertSpanLinksForEnvelope } from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
Expand Down Expand Up @@ -247,6 +248,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve
...removeSentryAttributes(span.attributes),
});

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

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

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

const { attributes, startTime, endTime, parentSpanId } = span;
const { attributes, startTime, endTime, parentSpanId, links } = span;

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

spans.push(spanJSON);
Expand Down
29 changes: 28 additions & 1 deletion packages/opentelemetry/test/spanExporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions';
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan } from '@sentry/core';
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan, startSpanManual } from '@sentry/core';
import { createTransactionForOtelSpan } from '../src/spanExporter';
import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit';

Expand Down Expand Up @@ -108,4 +108,31 @@ describe('createTransactionForOtelSpan', () => {
transaction_info: { source: 'custom' },
});
});

it('adds span link to the trace context when adding with addLink()', () => {
const span = startInactiveSpan({ name: 'parent1' });
span.end();

startSpanManual({ name: 'rootSpan' }, rootSpan => {
rootSpan.addLink({ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } });
rootSpan.end();

const prevTraceId = span.spanContext().traceId;
const prevSpanId = span.spanContext().spanId;
const event = createTransactionForOtelSpan(rootSpan as any);

expect(event.contexts?.trace).toEqual(
expect.objectContaining({
links: [
expect.objectContaining({
attributes: { 'sentry.link.type': 'previous_trace' },
sampled: true,
trace_id: expect.stringMatching(prevTraceId),
span_id: expect.stringMatching(prevSpanId),
}),
],
}),
);
});
});
});
Loading

0 comments on commit 5e6b852

Please sign in to comment.