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
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' },
},
]);
});
166 changes: 166 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,166 @@
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');

// assigning a string to prevent "might be undefined"
span1_traceId = event.contexts?.trace?.trace_id || 'non-existent-trace-id';
span1_spanId = event.contexts?.trace?.span_id || 'non-existent-span-id';

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');

// assigning a string to prevent "might be undefined"
span1_traceId = event.contexts?.trace?.trace_id || 'non-existent-trace-id';
span1_spanId = event.contexts?.trace?.span_id || 'non-existent-span-id';

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

// assigning a string to prevent "might be undefined"
span2_traceId = event.contexts?.trace?.trace_id || 'non-existent-trace-id';
span2_spanId = event.contexts?.trace?.span_id || 'non-existent-span-id';

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');

// assigning a string to prevent "might be undefined"
const parent1_traceId = event.contexts?.trace?.trace_id || 'non-existent-span-id';
const parent1_spanId = event.contexts?.trace?.span_id || 'non-existent-span-id';

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');

// assigning a string to prevent "might be undefined"
const parent1_traceId = event.contexts?.trace?.trace_id || 'non-existent-span-id';
const parent1_spanId = event.contexts?.trace?.span_id || 'non-existent-span-id';

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({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error traceID is defined
trace_id: expect.stringMatching(child1_1?.trace_id),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error spanID is defined
span_id: expect.stringMatching(child1_1?.span_id),
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
2 changes: 1 addition & 1 deletion packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,7 @@ describe('startInactiveSpan', () => {
origin: 'manual',
},
});
expect(innerTransaction?.spans).toEqual([]);
expect(innerTransaction?.spans).toEqual(undefined);
expect(innerTransaction?.transaction).toEqual('inner transaction');
expect(innerTransaction?.sdkProcessingMetadata).toEqual({
dynamicSamplingContext: {
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
Loading
Loading