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 all 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://[email protected]/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,76 @@
import { expect } from '@playwright/test';
import type { SpanJSON, TransactionEvent } from '@sentry/core';
import { sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers';

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

const rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1');
const rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2');

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);

const rootSpan1 = envelopeRequestParser<TransactionEvent>(await rootSpan1Promise);
const rootSpan2 = envelopeRequestParser<TransactionEvent>(await rootSpan2Promise);

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 rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1');
const rootSpan3Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan3');

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);

const rootSpan1 = envelopeRequestParser<TransactionEvent>(await rootSpan1Promise);
const rootSpan3 = envelopeRequestParser<TransactionEvent>(await rootSpan3Promise);

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://[email protected]/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,93 @@
import { expect } from '@playwright/test';
import type { SpanJSON, TransactionEvent } from '@sentry/core';
import { sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers';

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

const rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1');
const rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2');
const rootSpan3Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan3');

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);

const rootSpan1 = envelopeRequestParser<TransactionEvent>(await rootSpan1Promise);
const rootSpan2 = envelopeRequestParser<TransactionEvent>(await rootSpan2Promise);
const rootSpan3 = envelopeRequestParser<TransactionEvent>(await rootSpan3Promise);

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 rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2');
const rootSpan4Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan4');

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);

const rootSpan2 = envelopeRequestParser<TransactionEvent>(await rootSpan2Promise);
const rootSpan4 = envelopeRequestParser<TransactionEvent>(await rootSpan4Promise);

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
Loading
Loading