Skip to content

Commit 68051af

Browse files
logaretmclaude
andcommitted
fix(elysia): scope empty span filtering to Elysia spans only and add e2e test
Move span enrichment and filtering into clientHooks.ts. Filter empty <unknown> spans only when they are children of Elysia lifecycle spans, avoiding accidental removal of spans from other integrations. Removes the global emptySpanIds Set entirely — no memory leak possible. Add e2e test verifying no <unknown> spans appear in transactions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 643ff02 commit 68051af

3 files changed

Lines changed: 86 additions & 61 deletions

File tree

dev-packages/e2e-tests/test-applications/bun-elysia/tests/transactions.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,27 @@ test('Creates lifecycle spans for Elysia hooks', async ({ baseURL, request }) =>
138138
);
139139
});
140140

141+
test('Filters out empty anonymous Elysia spans but keeps all other spans', async ({ baseURL, request }) => {
142+
const transactionEventPromise = waitForTransaction('bun-elysia', transactionEvent => {
143+
return (
144+
transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-success'
145+
);
146+
});
147+
148+
await request.get(`${baseURL}/test-success`);
149+
150+
const transactionEvent = await transactionEventPromise;
151+
const spans = transactionEvent.spans || [];
152+
153+
// Elysia produces empty anonymous spans for arrow function handlers that show up as <unknown>.
154+
// These should be filtered out by our beforeSendEvent hook.
155+
const unknownSpans = spans.filter(span => span.description === '<unknown>');
156+
expect(unknownSpans).toHaveLength(0);
157+
158+
// But named Elysia lifecycle spans should still be present
159+
expect(spans.filter(span => span.origin === 'auto.http.otel.elysia').length).toBeGreaterThan(0);
160+
});
161+
141162
test('Creates lifecycle spans for route-specific middleware', async ({ baseURL, request }) => {
142163
const transactionEventPromise = waitForTransaction('bun-elysia', transactionEvent => {
143164
return (

packages/elysia/src/clientHooks.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Client, Event, Span } from '@sentry/core';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core';
3+
4+
const ELYSIA_ORIGIN = 'auto.http.otel.elysia';
5+
6+
const ELYSIA_LIFECYCLE_OP_MAP: Record<string, string> = {
7+
Request: 'middleware.elysia',
8+
Parse: 'middleware.elysia',
9+
Transform: 'middleware.elysia',
10+
BeforeHandle: 'middleware.elysia',
11+
Handle: 'request_handler.elysia',
12+
AfterHandle: 'middleware.elysia',
13+
MapResponse: 'middleware.elysia',
14+
AfterResponse: 'middleware.elysia',
15+
Error: 'middleware.elysia',
16+
};
17+
18+
/**
19+
* Enrich Elysia lifecycle spans with semantic op and origin,
20+
* and filter out empty anonymous child spans that Elysia produces.
21+
*/
22+
export function setupClientHooks(client: Client): void {
23+
// Enrich Elysia lifecycle spans with semantic op and origin.
24+
// We mutate the attributes directly because the span has already ended
25+
// and `setAttribute()` is a no-op on ended OTel spans.
26+
client.on('spanEnd', (span: Span) => {
27+
const spanData = spanToJSON(span);
28+
const op = ELYSIA_LIFECYCLE_OP_MAP[spanData.description || ''];
29+
if (op && spanData.data) {
30+
const attrs = spanData.data;
31+
attrs[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op;
32+
attrs[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ELYSIA_ORIGIN;
33+
}
34+
});
35+
36+
// Filter out empty child spans that Elysia produces for each function handler.
37+
// Users usually use arrow functions so they show up as <unknown>.
38+
// We identify Elysia spans by checking if their parent is an Elysia lifecycle span
39+
// (one we enriched with our origin), so we don't accidentally drop spans from other integrations.
40+
client.on('beforeSendEvent', (event: Event) => {
41+
if (event.type === 'transaction' && event.spans) {
42+
const elysiaSpanIds = new Set<string>();
43+
for (const span of event.spans) {
44+
if (span.origin === ELYSIA_ORIGIN) {
45+
elysiaSpanIds.add(span.span_id);
46+
}
47+
}
48+
49+
if (elysiaSpanIds.size > 0) {
50+
event.spans = event.spans.filter(span => {
51+
if (
52+
(!span.description || span.description === '<unknown>') &&
53+
span.parent_span_id &&
54+
elysiaSpanIds.has(span.parent_span_id)
55+
) {
56+
return false;
57+
}
58+
return true;
59+
});
60+
}
61+
}
62+
});
63+
}

packages/elysia/src/withElysia.ts

Lines changed: 2 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,17 @@ import {
44
getClient,
55
getIsolationScope,
66
getTraceData,
7-
SEMANTIC_ATTRIBUTE_SENTRY_OP,
8-
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
9-
spanToJSON,
107
winterCGRequestToRequestData,
118
} from '@sentry/core';
129
import type { Elysia, ErrorContext } from 'elysia';
10+
import { setupClientHooks } from './clientHooks';
1311

1412
interface ElysiaHandlerOptions {
1513
shouldHandleError: (context: ErrorContext) => boolean;
1614
}
1715

18-
const ELYSIA_ORIGIN = 'auto.http.otel.elysia';
19-
2016
let isClientHooksSetup = false;
2117
const instrumentedApps = new WeakSet<Elysia>();
22-
const emptySpanIds = new Set<string>();
23-
const MAX_EMPTY_SPAN_IDS = 1000;
24-
25-
const ELYSIA_LIFECYCLE_OP_MAP: Record<string, string> = {
26-
Request: 'middleware.elysia',
27-
Parse: 'middleware.elysia',
28-
Transform: 'middleware.elysia',
29-
BeforeHandle: 'middleware.elysia',
30-
Handle: 'request_handler.elysia',
31-
AfterHandle: 'middleware.elysia',
32-
MapResponse: 'middleware.elysia',
33-
AfterResponse: 'middleware.elysia',
34-
Error: 'middleware.elysia',
35-
};
3618

3719
function defaultShouldHandleError(context: ErrorContext): boolean {
3820
const status = context.set.status;
@@ -82,48 +64,7 @@ export function withElysia<T extends Elysia>(app: T, options?: Partial<ElysiaHan
8264
const client = getClient();
8365
if (client) {
8466
isClientHooksSetup = true;
85-
86-
// Enrich Elysia lifecycle spans with semantic op and origin,
87-
// and mark empty spans that Elysia produces as children of lifecycle spans.
88-
client.on('spanEnd', span => {
89-
const spanData = spanToJSON(span);
90-
91-
// Elysia produces empty spans for each function handler.
92-
// Users usually use arrow functions so they show up as <unknown>.
93-
// We track their IDs here so we can drop them in beforeSendEvent.
94-
// Named functions will still show up with their name.
95-
if (!spanData.description && (!spanData.data || Object.keys(spanData.data).length === 0)) {
96-
if (emptySpanIds.size >= MAX_EMPTY_SPAN_IDS) {
97-
emptySpanIds.clear();
98-
}
99-
emptySpanIds.add(spanData.span_id);
100-
return;
101-
}
102-
103-
// Enrich Elysia lifecycle spans with semantic op and origin.
104-
// We mutate the attributes directly because the span has already ended
105-
// and `setAttribute()` is a no-op on ended OTel spans.
106-
const op = ELYSIA_LIFECYCLE_OP_MAP[spanData.description || ''];
107-
if (op && spanData.data) {
108-
const attrs = spanData.data;
109-
attrs[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op;
110-
attrs[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ELYSIA_ORIGIN;
111-
}
112-
});
113-
114-
// Filter out the empty spans we marked above before sending the transaction,
115-
// and delete matched IDs individually to avoid clearing IDs from concurrent transactions.
116-
client.on('beforeSendEvent', event => {
117-
if (event.type === 'transaction' && event.spans) {
118-
event.spans = event.spans.filter(span => {
119-
if (!emptySpanIds.has(span.span_id)) {
120-
return true;
121-
}
122-
emptySpanIds.delete(span.span_id);
123-
return false;
124-
});
125-
}
126-
});
67+
setupClientHooks(client);
12768
}
12869
}
12970

0 commit comments

Comments
 (0)