Skip to content
Draft
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
31 changes: 31 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,37 @@ These changes are not caught by TypeScript. If you filter, group, or alert on sp
| `browser.TLS/SSL` | `browser.tls_ssl` |
| `browser.DNS` | `browser.dns` |

### Span name changes

Affected SDKs: All SDKs running in the browser.

With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/).

In v11, this only affects `pageload` spans. Further ops will follow in future releases.
If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged.

The following span names were adjusted:

| Span op | Before | After |
| ---------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |

Some consequences to be aware of:

Child spans of a pageload span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references.

`ignoreSpans` is evaluated when a span **starts**, at which point a pageload span without a resolved route is already named `'Pageload'`, so filters matching a URL path no longer apply to it. Match on attributes instead:

```js
Sentry.init({
// Before
ignoreSpans: ['/health'],

// After
ignoreSpans: [{ name: 'Pageload', attributes: { 'sentry.op': 'pageload', 'url.path': '/health' } }],
});
```

### LangGraph no longer emits `create_agent` spans

Affected SDKs: All server-side SDKs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser
},
[SENTRY_SEGMENT_NAME]: {
type: 'string',
value: '/index.html',
value: 'Pageload',
},
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
type: 'string',
Expand All @@ -116,7 +116,8 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser
},
end_timestamp: expect.any(Number),
is_segment: true,
name: '/index.html',
// Interaction spans are named after the current route, which is the pageload span's name.
name: 'Pageload',
span_id: interactionSegmentSpan!.span_id,
start_timestamp: expect.any(Number),
status: 'ok',
Expand Down Expand Up @@ -155,7 +156,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser
},
[SENTRY_SEGMENT_NAME]: {
type: 'string',
value: '/index.html',
value: 'Pageload',
},
[SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: {
type: 'string',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow
expect(navigationTraceId).toBeDefined();
expect(pageloadTraceId).not.toEqual(navigationTraceId);

expect(pageloadSpan.name).toEqual('/index.html');
expect(pageloadSpan.name).toEqual('Pageload');

expect(navigationSpan).toEqual({
attributes: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ sentryTest(
},
[SENTRY_SEGMENT_NAME]: {
type: 'string',
value: '/index.html',
value: 'Pageload',
},
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
type: 'string',
Expand All @@ -180,7 +180,9 @@ sentryTest(
},
end_timestamp: expect.any(Number),
is_segment: true,
name: '/index.html',
// The raw URL stays in `url.path`/`url.full`: with span streaming, a pageload span name is
// low cardinality and falls back to 'Pageload' when there is no parameterized route.
name: 'Pageload',
span_id: expect.stringMatching(/^[\da-f]{16}$/),
start_timestamp: expect.any(Number),
status: 'ok',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ sentryTest('captures CLS as a streamed span with source attributes', async ({ ge
expect(clsSpan.attributes['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));

// Check the CLS span carries the segment name it belongs to
expect(clsSpan.attributes['sentry.segment.name']).toEqual({ type: 'string', value: '/index.html' });
expect(clsSpan.attributes['sentry.segment.name']).toEqual({ type: 'string', value: 'Pageload' });

// Check browser.web_vital.cls.source attributes
expect(clsSpan.attributes['browser.web_vital.cls.source.1']?.value).toEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, pa
expect(inpSpan.attributes['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));

// Check the INP span carries the transaction/segment name it belongs to
expect(inpSpan.attributes['sentry.transaction']).toEqual({ type: 'string', value: '/index.html' });
expect(inpSpan.attributes['sentry.segment.name']).toEqual({ type: 'string', value: '/index.html' });
expect(inpSpan.attributes['sentry.transaction']).toEqual({ type: 'string', value: 'Pageload' });
expect(inpSpan.attributes['sentry.segment.name']).toEqual({ type: 'string', value: 'Pageload' });

const inpValue = inpSpan.attributes['browser.web_vital.inp.value']?.value as number;
expect(inpValue).toBeGreaterThan(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g
expect(lcpSpan.attributes['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));

// Check the LCP span carries the segment name it belongs to
expect(lcpSpan.attributes['sentry.segment.name']).toEqual({ type: 'string', value: '/index.html' });
expect(lcpSpan.attributes['sentry.segment.name']).toEqual({ type: 'string', value: 'Pageload' });

// Check browser.web_vital.lcp.* attributes
expect(lcpSpan.attributes['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ test('Prerendered shell does not stitch the pageload onto a stale trace', async
});

const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => {
return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment;
return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment;
});

await page.goto('/pageload-tracing');
Expand All @@ -80,6 +80,10 @@ test('Prerendered shell does not stitch the pageload onto a stale trace', async

const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]);

expect(pageloadSpan.attributes).toMatchObject({
['sentry.segment.name.source']: 'url',
['url.pathname']: '/pageload-tracing',
});
// Under Cache Components the can be prerendered and rendered in a context detached from the
// runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The
// SDK therefore does not enable the trace meta tags, and the browser pageload starts a fresh trace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test('Server and client pageload spans should share the same trace', async ({ pa
});

const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => {
return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment;
return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment;
});

await page.goto(`/pageload-tracing`);
Expand All @@ -16,4 +16,8 @@ test('Server and client pageload spans should share the same trace', async ({ pa

expect(pageloadSpan.trace_id).toBeTruthy();
expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id);
expect(pageloadSpan.attributes).toMatchObject({
['sentry.segment.name.source']: 'url',
['url.pathname']: '/pageload-tracing',
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong streamed span attribute assertions

Medium Severity

The new toMatchObject checks compare streamed span attributes to plain strings and look up url.pathname, but serialized streamed attributes are { type, value } objects keyed as url.path. Sibling assertions in the same files already use ?.value and the url.path convention, so these expectations will fail.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1a7dc4f. Configure here.

});
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@ test('should create a static streamed span when the `app` directory is used and
page,
}) => {
const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => {
return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment;
return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment;
});

await page.goto(`/parameterized/static`);

const span = await spanPromise;

expect(span.name).toBe('/parameterized/static');
expect(span.name).toBe('Pageload');
expect(span.trace_id).toMatch(/[a-f0-9]{32}/);
expect(span.attributes['sentry.source']?.value).toBe('url');
expect(span.attributes).toMatchObject({
['sentry.segment.name.source']: 'url',
['url.pathname']: '/parameterized/static',
});
});

test('should create a partially parameterized streamed span when the `app` directory is used', async ({ page }) => {
Expand Down
11 changes: 7 additions & 4 deletions packages/astro/src/client/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import {
startBrowserTracingPageLoadSpan,
WINDOW,
} from '@sentry/browser';
import type { Integration, TransactionSource } from '@sentry/core';
import type { Client, Integration, TransactionSource } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
debug,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand Down Expand Up @@ -40,7 +42,7 @@ export function browserTracingIntegration(
if (options.instrumentPageLoad != false) {
const origin = browserPerformanceTimeOrigin();

const { name, source } = getPageloadSpanName();
const { name, source } = getPageloadSpanName(client);

startBrowserTracingPageLoadSpan(client, {
name,
Expand All @@ -58,7 +60,7 @@ export function browserTracingIntegration(
};
}

function getPageloadSpanName(): { name: string; source: TransactionSource } {
function getPageloadSpanName(client: Client): { name: string; source: TransactionSource } {
try {
const routeNameFromMetaTags = getMetaContent('sentry-route-name');
if (routeNameFromMetaTags) {
Expand All @@ -75,7 +77,8 @@ function getPageloadSpanName(): { name: string; source: TransactionSource } {
// fail silently if decoding or reading the meta tag fails
}
return {
name: WINDOW.location.pathname,
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
name: hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : WINDOW.location.pathname,
source: 'url',
};
}
15 changes: 8 additions & 7 deletions packages/browser-utils/src/web-vitals/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,21 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
standalone,
} = options;

const routeName = getCurrentScope().getScopeData().transactionName;
// Taken off the segment span itself, so it can't diverge from it: a routing instrumentation may
// rename that span (a pageload span is named `Pageload` until its route resolves), and the scope's
// transaction name is deliberately not kept in sync with it. Only a standalone span, which is sent
// without its segment span, has to fall back to the scope.
const segmentSpan = parentSpan && getRootSpan(parentSpan);
const segmentName = segmentSpan ? spanToJSON(segmentSpan).name : getCurrentScope().getScopeData().transactionName;

const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0,
[`browser.web_vital.${metricName}.value`]: value,
// oxlint-disable-next-line typescript-eslint/no-deprecated
[SENTRY_TRANSACTION]: routeName,
[SENTRY_SEGMENT_NAME]: routeName,
[SENTRY_TRANSACTION]: segmentName,
[SENTRY_SEGMENT_NAME]: segmentName,
// Web vital score calculation relies on the user agent
'user_agent.original': WINDOW.navigator?.userAgent,
...passedAttributes,
Expand Down Expand Up @@ -343,7 +348,6 @@ export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming, st
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;

const spanToUse = cachedContext?.span || rootSpan;
const routeName = spanToUse ? spanToJSON(spanToUse).name : getCurrentScope().getScopeData().transactionName;
const name = cachedContext?.elementName || htmlTreeAsString(entry.target);

_emitWebVitalSpan({
Expand All @@ -354,9 +358,6 @@ export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming, st
value: inpValue,
attributes: {
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration,
// oxlint-disable-next-line typescript-eslint/no-deprecated
[SENTRY_TRANSACTION]: routeName,
[SENTRY_SEGMENT_NAME]: routeName,
},
startTime,
endTime: startTime + duration,
Expand Down
37 changes: 37 additions & 0 deletions packages/browser-utils/test/web-vitals/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ describe('_emitWebVitalSpan', () => {
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: {} } as any);
// A root span is its own root, which is what the web vital spans are parented to.
vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span);
vi.mocked(SentryCore.getClient).mockReturnValue({ getIntegrationByName: () => undefined } as any);
});

Expand Down Expand Up @@ -103,6 +105,31 @@ describe('_emitWebVitalSpan', () => {
expect(mockSpan.end).toHaveBeenCalledWith(1.5);
});

it("takes 'sentry.segment.name' from the span it is parented to, not from the scope", () => {
const parentSpan = { spanContext: () => ({ spanId: 'pageload-1' }) } as any;
vi.mocked(SentryCore.getRootSpan).mockReturnValue(parentSpan);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({ name: 'Pageload', attributes: {} } as any);

_emitWebVitalSpan({
name: 'Test Vital',
op: 'ui.webvital.lcp',
origin: 'auto.http.browser.lcp',
metricName: 'lcp',
value: 100,
startTime: 1.5,
parentSpan,
});

expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
expect.objectContaining({
attributes: expect.objectContaining({
'sentry.segment.name': 'Pageload',
'sentry.transaction': 'Pageload',
}),
}),
);
});

it('marks the span as standalone when standalone is set', () => {
_emitWebVitalSpan({
name: 'Test',
Expand Down Expand Up @@ -187,6 +214,8 @@ describe('_emitWebVitalSpan', () => {
it('includes pageload span id when parentSpan is a pageload span', () => {
const mockPageloadSpan = createMockPageloadSpan('abc123');
vi.mocked(SentryCore.spanToJSON).mockReturnValue({
// The web vital span takes its segment name off the pageload span it is parented to.
name: 'test-route',
attributes: { 'sentry.op': 'pageload' },
} as any);

Expand Down Expand Up @@ -308,6 +337,8 @@ describe('_sendLcpSpan', () => {
vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({
// The web vital span takes its segment name off the pageload span it is parented to.
name: 'test-route',
attributes: { 'sentry.op': 'pageload' },
} as any);
});
Expand Down Expand Up @@ -395,6 +426,8 @@ describe('_sendClsSpan', () => {
vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({
// The web vital span takes its segment name off the pageload span it is parented to.
name: 'test-route',
attributes: { 'sentry.op': 'pageload' },
} as any);
});
Expand Down Expand Up @@ -478,6 +511,8 @@ describe('_sendInpSpan', () => {
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: {} } as any);
// A root span is its own root, which is what the web vital spans are parented to.
vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span);
});

afterEach(() => {
Expand Down Expand Up @@ -595,6 +630,8 @@ describe('trackInpAsSpan', () => {
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
vi.mocked(SentryCore.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({ attributes: {} } as any);
// A root span is its own root, which is what the web vital spans are parented to.
vi.mocked(SentryCore.getRootSpan).mockImplementation(span => span);
vi.mocked(htmlTreeAsString).mockReturnValue('<button>');
vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined);
vi.spyOn(instrument, 'addInpInstrumentationHandler').mockImplementation((cb: any) => {
Expand Down
11 changes: 9 additions & 2 deletions packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
GLOBAL_OBJ,
hasSpansEnabled,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
isURLObjectRelative,
parseStringToURLObject,
propagationContextFromHeaders,
Expand Down Expand Up @@ -637,7 +638,9 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
if (instrumentPageLoad) {
const origin = browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client, {
name: WINDOW.location.pathname,
// With span streaming, span names have to be low cardinality, and there is no route
// information available here.
name: hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin / 1000 : undefined,
attributes: {
Expand Down Expand Up @@ -718,7 +721,11 @@ export function startBrowserTracingPageLoadSpan(
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
): Span | undefined {
client.emit('startPageLoadSpan', spanOptions, traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);

// `Pageload` is a low-cardinality span name, not a description of the page. The scope's
// transaction name is what error events are grouped by, so it keeps the URL instead.
const isFallbackSpanName = spanOptions.name === PAGELOAD_SPAN_NAME_FALLBACK;
getCurrentScope().setTransactionName(isFallbackSpanName ? WINDOW.location?.pathname : spanOptions.name);
Comment thread
cursor[bot] marked this conversation as resolved.

const pageloadSpan = getActiveIdleSpan(client);

Expand Down
Loading
Loading