Skip to content

feat(opentelemetry): Add links to span options #15403

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

Merged
merged 13 commits into from
Feb 17, 2025
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { loggingTransport } from '@sentry-internal/node-integration-tests';
import * as Sentry from '@sentry/node';

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [],
transport: loggingTransport,
});

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

// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan(
{
name: 'parent2',
links: [{ context: parentSpan1.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }],
},
async () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Sentry.startSpan({ name: 'child2.1' }, async childSpan1 => {
childSpan1.end();
});
},
);
30 changes: 30 additions & 0 deletions dev-packages/node-integration-tests/suites/tracing/linking/test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,36 @@
import { createRunner } from '../../../utils/runner';

describe('span links', () => {
test('should link spans by adding "links" to span options', done => {
let span1_traceId: string, span1_spanId: string;

createRunner(__dirname, 'scenario-span-options.ts')
.expect({
transaction: event => {
expect(event.transaction).toBe('parent1');

const traceContext = event.contexts?.trace;
span1_traceId = traceContext?.trace_id as string;
span1_spanId = traceContext?.span_id as string;
},
})
.expect({
transaction: event => {
expect(event.transaction).toBe('parent2');

const traceContext = event.contexts?.trace;
expect(traceContext).toBeDefined();
expect(traceContext?.links).toEqual([
expect.objectContaining({
trace_id: expect.stringMatching(span1_traceId),
span_id: expect.stringMatching(span1_spanId),
}),
]);
},
})
.start(done);
});

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

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/types-hoist/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export interface SentrySpanArguments {
*/
endTimestamp?: number | undefined;

/**
* Links to associate with the new span. Setting links here is preferred over addLink()
* as certain context information is only available during span creation.
*/
links?: SpanLink[];

/**
* Set to `true` if this span should be sent as a standalone segment span
* as opposed to a transaction.
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types-hoist/startSpanOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Scope } from '../scope';
import type { SpanLink } from './link';
import type { Span, SpanAttributes, SpanTimeInput } from './span';

export interface StartSpanOptions {
Expand Down Expand Up @@ -44,6 +45,12 @@ export interface StartSpanOptions {
/** Attributes for the span. */
attributes?: SpanAttributes;

/**
* Links to associate with the new span. Setting links here is preferred over addLink()
* as it allows sampling decisions to consider the link information.
*/
links?: SpanLink[];

/**
* Experimental options without any stability guarantees. Use with caution!
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/opentelemetry/src/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function getTracer(): Tracer {
}

function getSpanOptions(options: OpenTelemetrySpanContext): SpanOptions {
const { startTime, attributes, kind, op } = options;
const { startTime, attributes, kind, op, links } = options;

// OTEL expects timestamps in ms, not seconds
const fixedStartTime = typeof startTime === 'number' ? ensureTimestampInMilliseconds(startTime) : startTime;
Expand All @@ -173,6 +173,7 @@ function getSpanOptions(options: OpenTelemetrySpanContext): SpanOptions {
}
: attributes,
kind,
links,
startTime: fixedStartTime,
};
}
Expand Down
30 changes: 30 additions & 0 deletions packages/opentelemetry/test/spanExporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,34 @@ describe('createTransactionForOtelSpan', () => {
);
});
});

it('adds span link to the trace context when linked in span options', () => {
const span = startInactiveSpan({ name: 'parent1' });

const prevTraceId = span.spanContext().traceId;
const prevSpanId = span.spanContext().spanId;

const linkedSpan = startInactiveSpan({
name: 'parent2',
links: [{ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }],
});

span.end();
linkedSpan.end();

const event = createTransactionForOtelSpan(linkedSpan 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),
}),
],
}),
);
});
});
114 changes: 114 additions & 0 deletions packages/opentelemetry/test/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,44 @@ describe('trace', () => {
});
});

it('allows to pass span links in span options', () => {
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });

// @ts-expect-error links exists on span
expect(rawSpan1?.links).toEqual([]);

const span1JSON = spanToJSON(rawSpan1);

startSpan(
{
name: '/users/:id',
links: [
{
context: rawSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
],
},
rawSpan2 => {
const span2LinkJSON = spanToJSON(rawSpan2).links?.[0];

expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');

// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id);
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);

// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id);
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);
},
);
});

it('allows to force a transaction with forceTransaction=true', async () => {
const client = getClient()!;
const transactionEvents: Event[] = [];
Expand Down Expand Up @@ -651,6 +689,44 @@ describe('trace', () => {
});
});

it('allows to pass span links in span options', () => {
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });

// @ts-expect-error links exists on span
expect(rawSpan1?.links).toEqual([]);

const rawSpan2 = startInactiveSpan({
name: 'GET users/[id]',
links: [
{
context: rawSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
],
});

const span1JSON = spanToJSON(rawSpan1);
const span2JSON = spanToJSON(rawSpan2);
const span2LinkJSON = span2JSON.links?.[0];

expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');

// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id);
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);

// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id);
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);

// sampling decision is inherited
expect(span2LinkJSON?.sampled).toBe(Boolean(spanToJSON(rawSpan1).data['sentry.sample_rate']));
});

it('allows to force a transaction with forceTransaction=true', async () => {
const client = getClient()!;
const transactionEvents: Event[] = [];
Expand Down Expand Up @@ -974,6 +1050,44 @@ describe('trace', () => {
});
});

it('allows to pass span links in span options', () => {
const rawSpan1 = startInactiveSpan({ name: 'pageload_span' });

// @ts-expect-error links exists on span
expect(rawSpan1?.links).toEqual([]);

const span1JSON = spanToJSON(rawSpan1);

startSpanManual(
{
name: '/users/:id',
links: [
{
context: rawSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
],
},
rawSpan2 => {
const span2LinkJSON = spanToJSON(rawSpan2).links?.[0];

expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace');

// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId);
// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id);
expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id);

// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId);
// @ts-expect-error links and _spanContext exist on span
expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id);
expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id);
},
);
});

it('allows to force a transaction with forceTransaction=true', async () => {
const client = getClient()!;
const transactionEvents: Event[] = [];
Expand Down
Loading