Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(opentelemetry): Add addLink(s) to span #15387

Merged
merged 12 commits into from
Feb 17, 2025
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
Loading