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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
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
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -626,6 +626,7 @@ 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 |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |

Expand All@@ -639,6 +640,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root 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 span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/src/tracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,9 @@ import { FUNCTION } from '@sentry/conventions/op';
import type { Integration, Span } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
parseStringToURLObject,
ROUTER_SPAN_NAME_FALLBACK,
stripUrlQueryAndFragment,
timestampInSeconds,
filterCollectedUrl,
Expand DownExpand Up@@ -136,7 +138,9 @@ export class TraceService implements OnDestroy {
this._routingSpan =
runOutsideAngular(() =>
startInactiveSpan({
name: `${navigationEvent.url}`,
// With span streaming, span names have to be low cardinality. The parameterized route is only
// known at `ResolveEnd`, well after this span starts, so there is nothing but the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `${navigationEvent.url}`,
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,8 @@ import { MIDDLEWARE } from '@sentry/conventions/op';
import { DEBUG_BUILD } from '../../debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { SPAN_STATUS_ERROR, withActiveSpan } from '../../tracing';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { ROUTER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpanManual } from '../../tracing/trace';
import { debug } from '../../utils/debug-logger';
import type { SpanAttributes } from '../../types/span';
Expand All@@ -56,7 +58,7 @@ import {
getLayerMetadata,
isLayerIgnored,
} from './utils';
import { getIsolationScope } from '../../currentScopes';
import { getClient, getIsolationScope } from '../../currentScopes';
import { getDefaultIsolationScope } from '../../defaultScopes';
import { getOriginalFunction, markFunctionWrapped } from '../../utils/object';
import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
Expand DownExpand Up@@ -165,7 +167,13 @@ export function patchLayer(
DEBUG_BUILD && debug.warn('Isolation scope is still default isolation scope - skipping setting transactionName');
}

return startSpanManual({ name, attributes }, span => {
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === ExpressLayerType_ROUTER && !!client && hasSpanStreamingEnabled(client);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,10 +52,15 @@ const defaultIsolationScope = {
this._scopeData.sdkProcessingMetadata = data;
},
};
let spanStreamingEnabled = false;
beforeEach(() => (spanStreamingEnabled = false));
vi.mock('../../../../src/currentScopes', () => ({
getIsolationScope() {
return inDefaultIsolationScope ? defaultIsolationScope : notDefaultIsolationScope;
},
getClient() {
return { getOptions: () => ({ traceLifecycle: spanStreamingEnabled ? 'stream' : 'static' }) };
},
}));
vi.mock('../../../../src/defaultScopes', () => ({
getDefaultIsolationScope() {
Expand DownExpand Up@@ -468,6 +473,75 @@ describe('patchLayer', () => {
checkSpans([]);
});

it('names router spans after their route when span streaming is enabled', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/a/b/c',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'http.route': '/a/b/c',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
});

it('falls back to a static router span name when the route is unknown', () => {
spanStreamingEnabled = true;
const options: ExpressPatchLayerOptions = {};
const req = Object.assign(new EventEmitter(), {
originalUrl: '/abcdef',
}) as unknown as ExpressRequest;

const layer = {
name: 'router',
handle: vi.fn(),
} as unknown as ExpressLayer;

const res = Object.assign(new EventEmitter(), {}) as unknown as ExpressResponse;

storeLayer(req, '/a');
storeLayer(req, '/b');

patchLayer(() => options, layer, '/c');
layer.handle(req, res);

checkSpans([
{
status: { code: 0, message: 'OK' },
data: {
'express.name': '/c',
'express.type': 'router',
'sentry.op': 'router',
'sentry.origin': 'auto.http.express',
},
description: 'Router',
},
]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import {
getCurrentScope,
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
spanToJSON,
type Client,
type Span,
Expand DownExpand Up@@ -148,7 +149,9 @@ export function instrumentEmberAppInstanceForPerformance(
[SENTRY_OP]: 'router',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember',
},
name: `route:${fromRoute} -> route:${toRoute}`,
// With span streaming, span names have to be low cardinality, and Ember gives us no route
// template for the transition itself, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : `route:${fromRoute} -> route:${toRoute}`,
onlyIfParent: true,
});
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,11 @@ import type { Span } from '@sentry/core';
import {
debug,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startInactiveSpan,
stringMatchesSomePattern,
Expand DownExpand Up@@ -224,8 +227,12 @@ function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegration
return undefined;
}

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = type === 'router' && !!client && hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name,
name: isStreamedRouterSpan ? matchedRoute || ROUTER_SPAN_NAME_FALLBACK : name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SENTRY_OP]: EXPRESS_TYPE_TO_SPAN_OP[type],
Expand Down
21 changes: 19 additions & 2 deletions packages/server-utils/src/integrations/hapi-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,14 @@
* is replaced with `getActiveSpan()`.
*/

import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
import {
getActiveSpan,
getClient,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
import { SENTRY_OP } from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type {
Expand DownExpand Up@@ -74,7 +81,17 @@ export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanM
attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER;
}

return { attributes, name: `${route.method.toUpperCase()} ${route.path}` };
const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their
// route alone, without the method prefix.
const isStreamedRouterSpan = !pluginName && !!client && hasSpanStreamingEnabled(client);

return {
attributes,
name: isStreamedRouterSpan
? route.path || ROUTER_SPAN_NAME_FALLBACK
: `${route.method.toUpperCase()} ${route.path}`,
};
};

/** Build the span name and attributes for a Hapi server extension. */
Expand Down
10 changes: 9 additions & 1 deletion packages/server-utils/src/integrations/koa.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,11 @@ import {
debug,
defineIntegration,
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
hasSpanStreamingEnabled,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startSpan,
} from '@sentry/core';
Expand DownExpand Up@@ -173,7 +176,12 @@ function patchLayer(
const koaName = metadata.attributes[KOA_NAME];
// Somehow, name is sometimes `''` for middleware spans.
// See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220
const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;
const staticName = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name;

const client = getClient();
// With span streaming, span names have to be low cardinality, so router spans are named after their route.
const isStreamedRouterSpan = layerType === LAYER_TYPE.ROUTER && !!client && hasSpanStreamingEnabled(client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Avoidable getClient calls

Low Severity

Flagged because it was mentioned in the review rules file — this is more an "is this necessary" check than a hard violation. These sites newly call getClient() to gate span streaming, while the Koa, Express, and Hapi integrations already receive a client in setup. Prefering that existing reference would avoid relying on ambient current-client state in multi-client setups.

Additional Locations (2)
Fix in CursorFix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit be3a06a. Configure here.

const name = isStreamedRouterSpan ? metadata.attributes[HTTP_ROUTE] || ROUTER_SPAN_NAME_FALLBACK : staticName;

return startSpan(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/server-utils/test/integrations/hapi-utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest';
import { setCurrentClient } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';

describe('getRouteMetadata', () => {
const route = { path: '/users/{id}', method: 'get' } as any;

afterEach(() => {
setCurrentClient(undefined as unknown as TestClient);
});

it('describes a directly-registered route as a router layer', () => {
expect(getRouteMetadata(route)).toEqual({
name: 'GET /users/{id}',
Expand All@@ -26,6 +32,20 @@ describe('getRouteMetadata', () => {
},
});
});

it('drops the method from the router span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route).name).toBe('/users/{id}');
});

it('keeps the plugin span name when span streaming is enabled', () => {
const client = new TestClient(getDefaultTestClientOptions({ traceLifecycle: 'stream' }));
setCurrentClient(client);

expect(getRouteMetadata(route, 'my-plugin').name).toBe('GET /users/{id}');
});
});

describe('getExtMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte4BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -132,7 +133,9 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable<Naviga
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/svelte5BrowserTracing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
import {
hasSpanStreamingEnabled,
PAGELOAD_SPAN_NAME_FALLBACK,
ROUTER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
Expand DownExpand Up@@ -112,7 +113,9 @@ function _instrumentNavigations(client: Client): void {
);

routingSpan = startInactiveSpan({
name: 'SvelteKit Route Change',
// With span streaming, span names have to be low cardinality, and this span carries no route
// of its own, so it's the fallback.
name: hasSpanStreamingEnabled(client) ? ROUTER_SPAN_NAME_FALLBACK : 'SvelteKit Route Change',
attributes: {
// TODO(conventions): Replace `'router'` with the `router` span op constant once it is released in `@sentry/conventions`.
[SENTRY_OP]: 'router',
Expand Down
31 changes: 31 additions & 0 deletions packages/sveltekit/test/client/browserTracingIntegration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,37 @@ describe('browserTracingIntegration', () => {
expect(routingSpanEndSpy).toHaveBeenCalledTimes(1);
});

it('names the routing span with the low cardinality fallback when span streaming is enabled', async () => {
const streamingClient = {
getOptions: () => ({ traceLifecycle: 'stream' }),
on: () => {},
addEventProcessor: () => {},
addIntegration: () => {},
};
const integration = browserTracingIntegration({
instrumentPageLoad: false,
});
// @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine
integration.afterAllSetup(streamingClient);
await vi.dynamicImportSettled();

// TODO(v11): switch to `navigating` from `$app/state`
// @ts-expect-error - navigating is a writable but the types say it's just readable
// eslint-disable-next-line typescript/no-deprecated
navigating.set({
from: { route: { id: '/users' }, url: { pathname: '/users' } },
to: { route: { id: '/users/[id]' }, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } },
type: 'link',
});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Router',
attributes: expect.objectContaining({ [SENTRY_OP]: 'router' }),
}),
);
});

describe('handling same origin and destination navigations', () => {
it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => {
const integration = browserTracingIntegration({
Expand Down
Loading