Skip to content

feat(core): Add addLink(s) to Sentry span #15452

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 3 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// REGULAR ---
const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' });
rootSpan1.end();

Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => {
rootSpan2.addLink({
context: rootSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
});
});

// NESTED ---
Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => {
Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => {
childSpan1.addLink({
context: rootSpan1.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
});

childSpan1.end();
});

Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => {
childSpan2.addLink({ context: rootSpan3.spanContext() });

childSpan2.end();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';
import type { Event, SpanJSON } from '@sentry/core';
import { sentryTest } from '../../../utils/fixtures';
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../utils/helpers';

sentryTest('should link spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
const [rootSpan1, rootSpan2] = await getMultipleSentryEnvelopeRequests<Event>(page, 3, { url });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we need to wait for 3 events here (do we?) but only want 2, my gut feeling tells me that there's room for flakes. Let's use the waitForTransactionRequest helper instead and check for the correct transaction (or some other identifier). This should be safer for flakes and makes things more explicit.

(side note: I think we should generally use this helper more often, as it works out really well in our e2e tests. But obviously, no action required, we can do this iteratively and over time)


const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string;
const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string;

expect(rootSpan1.transaction).toBe('rootSpan1');
expect(rootSpan1.spans).toEqual([]);

expect(rootSpan2.transaction).toBe('rootSpan2');
expect(rootSpan2.spans).toEqual([]);

expect(rootSpan2.contexts?.trace?.links?.length).toBe(1);
expect(rootSpan2.contexts?.trace?.links?.[0]).toMatchObject({
attributes: { 'sentry.link.type': 'previous_trace' },
sampled: true,
span_id: rootSpan1_spanId,
trace_id: rootSpan1_traceId,
});
});

sentryTest('should link spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
const events = await getMultipleSentryEnvelopeRequests<Event>(page, 3, { url });
const [rootSpan1, /* rootSpan2 */ , rootSpan3] = events;

const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string;
const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string;

const [childSpan_3_1, childSpan_3_2] = rootSpan3.spans as [SpanJSON, SpanJSON];
const rootSpan3_traceId = rootSpan3.contexts?.trace?.trace_id as string;
const rootSpan3_spanId = rootSpan3.contexts?.trace?.span_id as string;

expect(rootSpan3.transaction).toBe('rootSpan3');

expect(childSpan_3_1.description).toBe('childSpan3.1');
expect(childSpan_3_1.links?.length).toBe(1);
expect(childSpan_3_1.links?.[0]).toMatchObject({
attributes: { 'sentry.link.type': 'previous_trace' },
sampled: true,
span_id: rootSpan1_spanId,
trace_id: rootSpan1_traceId,
});

expect(childSpan_3_2.description).toBe('childSpan3.2');
expect(childSpan_3_2.links?.[0]).toMatchObject({
sampled: true,
span_id: rootSpan3_spanId,
trace_id: rootSpan3_traceId,
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// REGULAR ---
const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' });
rootSpan1.end();

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

Sentry.startSpan({ name: 'rootSpan3' }, rootSpan3 => {
rootSpan3.addLinks([
{ context: rootSpan1.spanContext() },
{
context: rootSpan2.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
]);
});

// NESTED ---
Sentry.startSpan({ name: 'rootSpan4' }, async rootSpan4 => {
Sentry.startSpan({ name: 'childSpan4.1' }, async childSpan1 => {
Sentry.startSpan({ name: 'childSpan4.2' }, async childSpan2 => {
childSpan2.addLinks([
{ context: rootSpan4.spanContext() },
{
context: rootSpan2.spanContext(),
attributes: { 'sentry.link.type': 'previous_trace' },
},
]);

childSpan2.end();
});

childSpan1.end();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { expect } from '@playwright/test';
import type { Event, SpanJSON } from '@sentry/core';
import { sentryTest } from '../../../utils/fixtures';
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../utils/helpers';

sentryTest('should link spans with addLinks() in trace context', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
const [rootSpan1, rootSpan2, rootSpan3] = await getMultipleSentryEnvelopeRequests<Event>(page, 3, { url });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand this test correctly, we're sending 4 transaction events here, right? waiting on 3 then also introduces a potential flake as we depend on events being sent in the correct order.


const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string;
const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string;

expect(rootSpan1.transaction).toBe('rootSpan1');
expect(rootSpan1.spans).toEqual([]);

const rootSpan2_traceId = rootSpan2.contexts?.trace?.trace_id as string;
const rootSpan2_spanId = rootSpan2.contexts?.trace?.span_id as string;

expect(rootSpan2.transaction).toBe('rootSpan2');
expect(rootSpan2.spans).toEqual([]);

expect(rootSpan3.transaction).toBe('rootSpan3');
expect(rootSpan3.spans).toEqual([]);
expect(rootSpan3.contexts?.trace?.links?.length).toBe(2);
expect(rootSpan3.contexts?.trace?.links).toEqual([
{
sampled: true,
span_id: rootSpan1_spanId,
trace_id: rootSpan1_traceId,
},
{
attributes: { 'sentry.link.type': 'previous_trace' },
sampled: true,
span_id: rootSpan2_spanId,
trace_id: rootSpan2_traceId,
},
]);
});

sentryTest('should link spans with addLinks() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
const events = await getMultipleSentryEnvelopeRequests<Event>(page, 4, { url });
const [/* rootSpan1 */ , rootSpan2, /* rootSpan3 */ , rootSpan4] = events;

const rootSpan2_traceId = rootSpan2.contexts?.trace?.trace_id as string;
const rootSpan2_spanId = rootSpan2.contexts?.trace?.span_id as string;

const [childSpan_4_1, childSpan_4_2] = rootSpan4.spans as [SpanJSON, SpanJSON];
const rootSpan4_traceId = rootSpan4.contexts?.trace?.trace_id as string;
const rootSpan4_spanId = rootSpan4.contexts?.trace?.span_id as string;

expect(rootSpan4.transaction).toBe('rootSpan4');

expect(childSpan_4_1.description).toBe('childSpan4.1');
expect(childSpan_4_1.links).toBe(undefined);

expect(childSpan_4_2.description).toBe('childSpan4.2');
expect(childSpan_4_2.links?.length).toBe(2);
expect(childSpan_4_2.links).toEqual([
{
sampled: true,
span_id: rootSpan4_spanId,
trace_id: rootSpan4_traceId,
},
{
attributes: { 'sentry.link.type': 'previous_trace' },
sampled: true,
span_id: rootSpan2_spanId,
trace_id: rootSpan2_traceId,
},
]);
});
18 changes: 16 additions & 2 deletions packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import type {
TransactionEvent,
TransactionSource,
} from '../types-hoist';
import type { SpanLink } from '../types-hoist/link';
import { logger } from '../utils-hoist/logger';
import { dropUndefinedKeys } from '../utils-hoist/object';
import { generateSpanId, generateTraceId } from '../utils-hoist/propagationContext';
import { timestampInSeconds } from '../utils-hoist/time';
import {
TRACE_FLAG_NONE,
TRACE_FLAG_SAMPLED,
convertSpanLinksForEnvelope,
getRootSpan,
getSpanDescendants,
getStatusMessage,
Expand All @@ -55,6 +57,7 @@ export class SentrySpan implements Span {
protected _sampled: boolean | undefined;
protected _name?: string | undefined;
protected _attributes: SpanAttributes;
protected _links?: SpanLink[];
/** Epoch timestamp in seconds when the span started. */
protected _startTime: number;
/** Epoch timestamp in seconds when the span ended. */
Expand Down Expand Up @@ -110,12 +113,22 @@ export class SentrySpan implements Span {
}

/** @inheritDoc */
public addLink(_link: unknown): this {
public addLink(link: SpanLink): this {
if (this._links) {
this._links.push(link);
} else {
this._links = [link];
}
return this;
}

/** @inheritDoc */
public addLinks(_links: unknown[]): this {
public addLinks(links: SpanLink[]): this {
if (this._links) {
this._links.push(...links);
} else {
this._links = links;
}
return this;
}

Expand Down Expand Up @@ -225,6 +238,7 @@ export class SentrySpan implements Span {
measurements: timedEventsToMeasurements(this._events),
is_segment: (this._isStandaloneSpan && getRootSpan(this) === this) || undefined,
segment_id: this._isStandaloneSpan ? getRootSpan(this).spanContext().spanId : undefined,
links: convertSpanLinksForEnvelope(this._links),
});
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils/spanUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ let hasShownSpanDropWarning = false;
*/
export function spanToTransactionTraceContext(span: Span): TraceContext {
const { spanId: span_id, traceId: trace_id } = span.spanContext();
const { data, op, parent_span_id, status, origin } = spanToJSON(span);
const { data, op, parent_span_id, status, origin, links } = spanToJSON(span);

return dropUndefinedKeys({
parent_span_id,
Expand All @@ -50,6 +50,7 @@ export function spanToTransactionTraceContext(span: Span): TraceContext {
op,
status,
origin,
links,
});
}

Expand Down
68 changes: 68 additions & 0 deletions packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,40 @@ describe('startSpan', () => {
});
});

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

// @ts-expect-error _links exists on span
expect(rawSpan1?._links).toEqual(undefined);

const span1JSON = spanToJSON(rawSpan1);

startSpan({ name: '/users/:id' }, rawSpan2 => {
rawSpan2.addLink({
context: rawSpan1.spanContext(),
attributes: {
'sentry.link.type': 'previous_trace',
},
});

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

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

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

// @ts-expect-error _links and _traceId exist on SentrySpan
expect(rawSpan2?._links?.[0].context.spanId).toEqual(rawSpan1?._spanId);
// @ts-expect-error _links and _traceId exist on SentrySpan
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 options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
client = new TestClient(options);
Expand Down Expand Up @@ -900,6 +934,40 @@ describe('startSpanManual', () => {
});
});

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

// @ts-expect-error _links exists on span
expect(rawSpan1?._links).toEqual(undefined);

const span1JSON = spanToJSON(rawSpan1);

startSpanManual({ name: '/users/:id' }, rawSpan2 => {
rawSpan2.addLink({
context: rawSpan1.spanContext(),
attributes: {
'sentry.link.type': 'previous_trace',
},
});

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

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

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

// @ts-expect-error _links and _traceId exist on SentrySpan
expect(rawSpan2?._links?.[0].context.spanId).toEqual(rawSpan1?._spanId);
// @ts-expect-error _links and _traceId exist on SentrySpan
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 options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
client = new TestClient(options);
Expand Down
Loading