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 number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ The following span names were adjusted:
| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) |
| `http.client`, `http.client.stream` | The request method and sanitized URL (`GET https://api.example.com/users/123`) | The request method and the domain (`GET api.example.com`), or just the method if there is no domain (`GET`) |
| `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`) |
| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) |
| `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation (`chain format_prompt`) | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order (`invoke_agent format_prompt`), or `{operation}` if the span carries none |
Expand Down Expand Up @@ -950,6 +951,8 @@ Because the URL path is gone from `http.client` names, `graphqlClientIntegration

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 its handler spans. Elysia request handler spans also carry `http.route` now. Both attributes are set in both trace lifecycles.

Messaging span names now read `<operation type> <destination>` in every integration. The amqplib, kafkajs and NestJS BullMQ integrations used their own word order or verb, so their names change: `my-queue process` became `process my-queue`, amqplib's `publish` became `send`, and the kafkajs batch span's `poll` became `receive`. Cloudflare Queues and the kafkajs producer already matched the conventions, so their names are the same in both trace lifecycles. The operation name an integration reports upstream stays on `messaging.operation.name`.

AWS SQS `SendMessage`, `SendMessageBatch` and `ReceiveMessage`, and SNS `Publish`, are messaging spans (e.g. `queue.publish`) rather than `rpc` ones now. Every other command on those clients, such as `DeleteMessage`, stays `rpc`. Their names follow the messaging conventions too, so the operation comes first (`my-queue receive` becomes `receive my-queue`, `my-topic send` becomes `send my-topic`). A streamed SNS `Publish` to a platform endpoint is named `send`, because the endpoint ARN it used to carry ends in a per-device id (`endpoint/GCM/myapp/<uuid> send`). The full ARN remains on `messaging.destination.name`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
traceLifecycle: 'static',
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
// disable attaching headers to /test/* endpoints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,37 @@ describe('express tracing', () => {
await runner.completed();
});

test('names router and request handler spans after their route when span streaming is enabled', async () => {
const runner = createRunner()
.withEnv({ STREAMED: 'true' })
.expect({
span: container => {
const spanFor = (type: string): (typeof container.items)[number] | undefined =>
container.items.find(item => item.attributes['express.type']?.value === type);

const handlerSpan = spanFor('request_handler');
expect(handlerSpan?.name).toBe('/test/router/user/:id');
// The name has to stay in step with the attribute it comes from.
expect(handlerSpan?.attributes['http.route']?.value).toBe('/test/router/user/:id');
expect(handlerSpan?.attributes['sentry.op']?.value).toBe('handler');

const routerSpan = spanFor('router');
expect(routerSpan?.name).toBe('/test/router/user');

// Spans of other layer types keep their names.
expect(container.items.find(item => item.name === 'corsMiddleware')?.attributes['express.type']).toEqual({
type: 'string',
value: 'middleware',
});
},
})
.start();

await runner.makeRequest('get', '/test/router/user/123');

await runner.completed();
});

test('should set a correct transaction name for routes specified in RegEx', async () => {
const runner = createRunner()
.expect({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
traceLifecycle: 'static',
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ describe('fastify v5 auto-instrumentation', () => {
await runner.completed();
});

test('names request handler spans after their route when span streaming is enabled', async () => {
const runner = createRunner()
.withEnv({ STREAMED: 'true' })
.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');
// The name has to stay in step with the attribute it comes from.
expect(span.attributes['http.route']?.value).toBe('/test-transaction');
}

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

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

await runner.completed();
});

test('captures errors thrown in route handlers', async () => {
const runner = createRunner()
.ignore('transaction')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
traceLifecycle: 'static',
traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
Expand Down
24 changes: 24 additions & 0 deletions dev-packages/node-integration-tests/suites/tracing/hapi/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ describe('hapi auto-instrumentation', () => {
await runner.completed();
});

test('names request handler spans after their route when span streaming is enabled', async () => {
const runner = createRunner()
.withEnv({ STREAMED: 'true' })
.expect({
span: container => {
const handlerSpan = container.items.find(item => item.attributes['sentry.op']?.value === 'handler');

// The route alone, without the `GET ` prefix the static name carries.
expect(handlerSpan?.name).toBe('/plugin-route');
// The name has to stay in step with the attribute it comes from.
expect(handlerSpan?.attributes['http.route']?.value).toBe('/plugin-route');
expect(handlerSpan?.attributes['hapi.type']?.value).toBe('plugin');

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

await runner.makeRequest('get', '/plugin-route');

await runner.completed();
});

test('should handle returned plain errors in routes.', async () => {
const runner = createRunner()
.expect({
Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/integrations/express/patch-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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 @@ -184,10 +184,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);

const spanName = isStreamedRouterSpan ? actualMatchedRoute || ROUTER_SPAN_NAME_FALLBACK : name;
// 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
: 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 @@ -77,6 +77,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 @@ -649,6 +649,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
51 changes: 45 additions & 6 deletions packages/elysia/src/withElysia.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { HTTP_ROUTE, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
import {
CODE_FUNCTION_NAME,
HTTP_ROUTE,
SENTRY_OP,
SENTRY_SEGMENT_NAME_SOURCE,
URL_FULL,
URL_PATH,
} from '@sentry/conventions/attributes';
import { HANDLER, HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op';
import type { Span } from '@sentry/core';
import {
Expand All @@ -9,6 +16,8 @@ import {
getIsolationScope,
getRootSpan,
getTraceData,
hasSpanStreamingEnabled,
REQUEST_HANDLER_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setHttpStatus,
Comment thread
sentry[bot] marked this conversation as resolved.
Expand All @@ -18,11 +27,18 @@ import {
winterCGRequestToRequestData,
withIsolationScope,
filterCollectedUrl,
hasSpanStreamingEnabled,
HTTP_SPAN_NAME_FALLBACK,
} 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 @@ -109,20 +125,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 @@ -132,11 +166,16 @@ 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,
// Streamed request handler spans are named after the route, so the
// handler name has no other place to go. Anonymous handlers have no
// name to record.
...(isRequestHandlerSpan && child.name ? { [CODE_FUNCTION_NAME]: child.name } : {}),
},
});

Expand Down Expand Up @@ -285,7 +324,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