Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ The following span names were adjusted:
| ------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `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 |
| `handler` | Framework-specific, often carrying the request method (`GET /users/:id`, `route-handler`, `getUser`) | The span's `http.route`, or `Request handler` 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 @@ -642,6 +643,8 @@ For the same reason, `useOperationNameForRootSpan` no longer renames the enclosi

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`.

The Express, Fastify, Hapi and Elysia integrations resolve a route template for `handler` spans. NestJS has none when the span starts, so its request handler spans are named `Request handler`. The handler function name is no longer part of these span names. It stays on an attribute: `nestjs.callback` for NestJS, and `code.function.name` for Elysia, which now sets it on the handler spans it renames. Elysia request handler spans also carry `http.route` now, in both trace lifecycles.

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
traceLifecycle: 'stream',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
import Fastify from 'fastify';

const app = Fastify();

// The routes go through `register` so that they reach the SDK's `onRoute` hook.
// That hook is installed when Fastify flushes its plugin list, which is after
// routes registered directly on the root instance are already in place.
app.register(async instance => {
instance.get(
'/test-transaction/:id',
{
preHandler: function routePreHandler(_request, _reply, done) {
done();
},
},
async () => {
return {};
},
);
});

const run = async () => {
await app.listen({ port: 0, host: 'localhost' });
sendPortToRunner(app.server.address().port);
};

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

describe('fastify auto-instrumentation (streamed)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('names request handler spans after their route', async () => {
const runner = createRunner()
.expect({
span: container => {
const handlerSpans = container.items.filter(item => item.attributes['sentry.op']?.value === 'handler');

// The request span and the route handler span.
expect(handlerSpans).toHaveLength(2);
for (const span of handlerSpans) {
expect(span.name).toBe('/test-transaction/:id');
// The name has to stay in step with the attribute it comes from.
expect(span.attributes['http.route']?.value).toBe('/test-transaction/:id');
}

// Spans of other ops keep their names.
const hookSpan = container.items.find(item => item.name === 'preHandler - routePreHandler');
expect(hookSpan).toBeDefined();
},
})
.start();

await runner.makeRequest('get', '/test-transaction/123');

await runner.completed();
});
});
});
17 changes: 13 additions & 4 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ 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 { REQUEST_HANDLER_SPAN_NAME_FALLBACK, 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 Down Expand Up @@ -168,10 +168,19 @@ export function patchLayer(
}

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);
// With span streaming, span names have to be low cardinality, so router
// and request handler spans are named after their route. A route that did
// not validate against the request URL can describe a different request,
// so those spans take the static fallback instead.
const isStreamedSpan = !!client && hasSpanStreamingEnabled(client);
const isStreamedRouterSpan = isStreamedSpan && type === ExpressLayerType_ROUTER;
const isStreamedRequestHandlerSpan = isStreamedSpan && type === ExpressLayerType_REQUEST_HANDLER;

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

return startSpanManual({ name: spanName, attributes }, span => {
let spanHasEnded = false;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/tracing/spans/spanNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,6 @@ export const ROUTER_SPAN_NAME_FALLBACK = 'Router';

/**
* Fallback name for request handler spans when no better-suited span name is available.
* @see https://getsentry.github.io/sentry-conventions/names/#resource-resources
* @see https://getsentry.github.io/sentry-conventions/names/#web_server-request-handler
*/
export const REQUEST_HANDLER_SPAN_NAME_FALLBACK = 'Request Handler';
export const REQUEST_HANDLER_SPAN_NAME_FALLBACK = 'Request handler';
73 changes: 73 additions & 0 deletions packages/core/test/lib/integrations/express/patch-layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,79 @@ describe('patchLayer', () => {
]);
});

it('names request handler 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: 'handle',
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': '/a/b/c',
'express.type': 'request_handler',
'http.route': '/a/b/c',
'sentry.op': 'handler',
'sentry.origin': 'auto.http.express',
},
description: '/a/b/c',
},
]);
res.emit('finish');
checkSpans([]);
});

it('falls back to a static request handler 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: 'handle',
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': '/a/b/c',
'express.type': 'request_handler',
'sentry.op': 'handler',
'sentry.origin': 'auto.http.express',
},
description: 'Request handler',
},
]);
res.emit('finish');
checkSpans([]);
});

it('handles case when route does not match url', () => {
const onRouteResolved = vi.fn();
const options: ExpressPatchLayerOptions = { onRouteResolved };
Expand Down
49 changes: 44 additions & 5 deletions packages/elysia/src/withElysia.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
import {
SENTRY_SEGMENT_NAME_SOURCE,
CODE_FUNCTION_NAME,
HTTP_ROUTE,
URL_FULL,
URL_PATH,
} from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type { Span } from '@sentry/core';
import {
captureException,
continueTrace,
getActiveSpan,
getClient,
getIsolationScope,
getRootSpan,
getTraceData,
hasSpanStreamingEnabled,
REQUEST_HANDLER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setHttpStatus,
Expand All @@ -20,6 +29,14 @@ import {
} from '@sentry/core';
import type { AnyElysia, Elysia, ErrorContext, TraceHandler, TraceListener } from 'elysia';

/**
* The part of Elysia's request context that the lifecycle spans read. Elysia types
* `.trace()`'s context as an index signature, which a required property would reject.
*/
interface LifecycleContext {
route?: string;
}

interface ElysiaHandlerOptions {
shouldHandleError?: (context: ErrorContext) => boolean;
}
Expand Down Expand Up @@ -107,20 +124,38 @@ function defaultShouldHandleError(context: ErrorContext): boolean {
* @param rootSpan - The root server span to parent lifecycle spans under.
* Must be passed explicitly because Elysia's .trace() listener callbacks run
* in a different async context where getActiveSpan() returns undefined.
* @param context - The request context. Read `route` off it inside the listener:
* Elysia assigns the route when the request enters the compiled handler, which
* is after `.trace()` hands out its listeners.
*/
function instrumentLifecyclePhase(phaseName: string, listener: TraceListener, rootSpan: Span | undefined): void {
function instrumentLifecyclePhase(
phaseName: string,
listener: TraceListener,
rootSpan: Span | undefined,
context: LifecycleContext,
): void {
const op = ELYSIA_LIFECYCLE_OP_MAP[phaseName];
if (!op) {
return;
}

void listener(process => {
const client = getClient();
const isRequestHandlerSpan = op === 'handler';
// With span streaming, span names have to be low cardinality, so request handler
// spans are named after their route.
const isStreamedRequestHandlerSpan = isRequestHandlerSpan && !!client && hasSpanStreamingEnabled(client);
// The route describes the span in both trace lifecycles, and the other server
// integrations put it on their request handler spans too.
const routeAttribute = isRequestHandlerSpan && context.route ? { [HTTP_ROUTE]: context.route } : {};

const phaseSpan = startInactiveSpan({
name: phaseName,
name: isStreamedRequestHandlerSpan ? context.route || REQUEST_HANDLER_SPAN_NAME_FALLBACK : phaseName,
parentSpan: rootSpan,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN,
...routeAttribute,
},
});

Expand All @@ -130,11 +165,15 @@ function instrumentLifecyclePhase(phaseName: string, listener: TraceListener, ro
void process.onEvent(child => {
const handlerName = child.name || 'anonymous';
const childSpan = startInactiveSpan({
name: handlerName,
name: isStreamedRequestHandlerSpan ? context.route || REQUEST_HANDLER_SPAN_NAME_FALLBACK : handlerName,
parentSpan: phaseSpan,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN,
...routeAttribute,
// These spans are named after the route, so the handler name has no
// other place to go. Anonymous handlers have no name to record.
...(isStreamedRequestHandlerSpan && child.name ? { [CODE_FUNCTION_NAME]: child.name } : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
},
});

Expand Down Expand Up @@ -277,7 +316,7 @@ export function withElysia<T extends AnyElysia>(app: T, options: ElysiaHandlerOp

for (const [phaseName, listener] of phases) {
if (listener) {
instrumentLifecyclePhase(phaseName, listener, rootSpan);
instrumentLifecyclePhase(phaseName, listener, rootSpan, lifecycle.context);
}
}
};
Expand Down
Loading
Loading