Skip to content

Commit f6149d8

Browse files
Lms24c298lee
authored andcommitted
fix(sveltekit): Ensure sub requests are recorded as child spans of parent request (#11130)
When switching the SvelteKit server side SDK to `@sentry/node` powered by Otel, the semantics behind `continueTrace` changed as outlined in #11199. TLDR: We previously called `continueTrace` in a nested way when dealing with sub-requests`*` in SvelteKit. In our old Node SDK, this did nothing; in the new SDK, this currently causes a new root span/transaction to be created for the sub request. This patch now ensures that we continue to send sub request spans as child spans of the top-level/parent request.
1 parent ecb82fb commit f6149d8

File tree

13 files changed

+217
-214
lines changed

13 files changed

+217
-214
lines changed

dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte

+3
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,7 @@
2626
<li>
2727
<a id="redirectLink" href="/redirect1">Redirect</a>
2828
</li>
29+
<li>
30+
<a href="/server-load-fetch">Route with nested fetch in server load</a>
31+
</li>
2932
</ul>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const load = async ({ fetch }) => {
2+
const res = await fetch('/api/users');
3+
const data = await res.json();
4+
return { data };
5+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script lang="ts">
2+
export let data;
3+
</script>
4+
5+
<main>
6+
<h1>Server Load Fetch</h1>
7+
<p>{JSON.stringify(data, null, 2)}</p>
8+
</main>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '../event-proxy-server';
3+
4+
test('server pageload request span has nested request span for sub request', async ({ page }) => {
5+
const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => {
6+
return txnEvent?.transaction === 'GET /server-load-fetch';
7+
});
8+
9+
await page.goto('/server-load-fetch');
10+
11+
const serverTxnEvent = await serverTxnEventPromise;
12+
const spans = serverTxnEvent.spans;
13+
14+
expect(serverTxnEvent).toMatchObject({
15+
transaction: 'GET /server-load-fetch',
16+
tags: { runtime: 'node' },
17+
transaction_info: { source: 'route' },
18+
type: 'transaction',
19+
contexts: {
20+
trace: {
21+
op: 'http.server',
22+
origin: 'auto.http.sveltekit',
23+
},
24+
},
25+
});
26+
27+
expect(spans).toEqual(
28+
expect.arrayContaining([
29+
// load span where the server load function initiates the sub request:
30+
expect.objectContaining({ op: 'function.sveltekit.server.load', description: '/server-load-fetch' }),
31+
// sub request span:
32+
expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }),
33+
]),
34+
);
35+
});

dev-packages/e2e-tests/test-applications/sveltekit/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@sentry/utils": "latest || *",
2323
"@sveltejs/adapter-auto": "^2.0.0",
2424
"@sveltejs/adapter-node": "^1.2.4",
25-
"@sveltejs/kit": "^1.30.3",
25+
"@sveltejs/kit": "1.20.5",
2626
"svelte": "^3.54.0",
2727
"svelte-check": "^3.0.1",
2828
"ts-node": "10.9.1",

dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte

+3
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@
2323
<li>
2424
<a href="/universal-load-fetch">Route with fetch in universal load</a>
2525
</li>
26+
<li>
27+
<a href="/server-load-fetch">Route with nested fetch in server load</a>
28+
</li>
2629
</ul>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const load = async ({ fetch }) => {
2+
const res = await fetch('/api/users');
3+
const data = await res.json();
4+
return { data };
5+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script lang="ts">
2+
export let data;
3+
</script>
4+
5+
<main>
6+
<h1>Server Load Fetch</h1>
7+
<p>{JSON.stringify(data, null, 2)}</p>
8+
</main>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '../event-proxy-server';
3+
4+
test('server pageload request span has nested request span for sub request', async ({ page }) => {
5+
const serverTxnEventPromise = waitForTransaction('sveltekit', txnEvent => {
6+
return txnEvent?.transaction === 'GET /server-load-fetch';
7+
});
8+
9+
await page.goto('/server-load-fetch');
10+
11+
const serverTxnEvent = await serverTxnEventPromise;
12+
const spans = serverTxnEvent.spans;
13+
14+
expect(serverTxnEvent).toMatchObject({
15+
transaction: 'GET /server-load-fetch',
16+
tags: { runtime: 'node' },
17+
transaction_info: { source: 'route' },
18+
type: 'transaction',
19+
contexts: {
20+
trace: {
21+
op: 'http.server',
22+
origin: 'auto.http.sveltekit',
23+
},
24+
},
25+
});
26+
27+
expect(spans).toEqual(
28+
expect.arrayContaining([
29+
// load span where the server load function initiates the sub request:
30+
expect.objectContaining({ op: 'function.sveltekit.server.load', description: '/server-load-fetch' }),
31+
// sub request span:
32+
expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }),
33+
]),
34+
);
35+
});

packages/sveltekit/src/server/handle.ts

+44-37
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,26 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle {
149149
};
150150

151151
const sentryRequestHandler: Handle = input => {
152-
// if there is an active span, we know that this handle call is nested and hence
153-
// we don't create a new execution context for it.
154-
// If we created one, nested server calls would create new root span instead
155-
// of adding a child span to the currently active span.
156-
if (getActiveSpan()) {
152+
// event.isSubRequest was added in SvelteKit 1.21.0 and we can use it to check
153+
// if we should create a new execution context or not.
154+
// In case of a same-origin `fetch` call within a server`load` function,
155+
// SvelteKit will actually just re-enter the `handle` function and set `isSubRequest`
156+
// to `true` so that no additional network call is made.
157+
// We want the `http.server` span of that nested call to be a child span of the
158+
// currently active span instead of a new root span to correctly reflect this
159+
// behavior.
160+
// As a fallback for Kit < 1.21.0, we check if there is an active span only if there's none,
161+
// we create a new execution context.
162+
const isSubRequest = typeof input.event.isSubRequest === 'boolean' ? input.event.isSubRequest : !!getActiveSpan();
163+
164+
if (isSubRequest) {
157165
return instrumentHandle(input, options);
158166
}
167+
159168
return withIsolationScope(() => {
160-
return instrumentHandle(input, options);
169+
// We only call continueTrace in the initial top level request to avoid
170+
// creating a new root span for the sub request.
171+
return continueTrace(getTracePropagationData(input.event), () => instrumentHandle(input, options));
161172
});
162173
};
163174

@@ -172,36 +183,32 @@ async function instrumentHandle(
172183
return resolve(event);
173184
}
174185

175-
const { sentryTrace, baggage } = getTracePropagationData(event);
176-
177-
return continueTrace({ sentryTrace, baggage }, async () => {
178-
try {
179-
const resolveResult = await startSpan(
180-
{
181-
op: 'http.server',
182-
attributes: {
183-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit',
184-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url',
185-
'http.method': event.request.method,
186-
},
187-
name: `${event.request.method} ${event.route?.id || event.url.pathname}`,
188-
},
189-
async (span?: Span) => {
190-
const res = await resolve(event, {
191-
transformPageChunk: addSentryCodeToPage(options),
192-
});
193-
if (span) {
194-
setHttpStatus(span, res.status);
195-
}
196-
return res;
186+
try {
187+
const resolveResult = await startSpan(
188+
{
189+
op: 'http.server',
190+
attributes: {
191+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit',
192+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url',
193+
'http.method': event.request.method,
197194
},
198-
);
199-
return resolveResult;
200-
} catch (e: unknown) {
201-
sendErrorToSentry(e);
202-
throw e;
203-
} finally {
204-
await flushIfServerless();
205-
}
206-
});
195+
name: `${event.request.method} ${event.route?.id || event.url.pathname}`,
196+
},
197+
async (span?: Span) => {
198+
const res = await resolve(event, {
199+
transformPageChunk: addSentryCodeToPage(options),
200+
});
201+
if (span) {
202+
setHttpStatus(span, res.status);
203+
}
204+
return res;
205+
},
206+
);
207+
return resolveResult;
208+
} catch (e: unknown) {
209+
sendErrorToSentry(e);
210+
throw e;
211+
} finally {
212+
await flushIfServerless();
213+
}
207214
}

packages/sveltekit/src/server/load.ts

+20-25
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ import {
22
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
33
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
44
captureException,
5-
continueTrace,
65
startSpan,
76
} from '@sentry/node';
87
import { addNonEnumerableProperty, objectify } from '@sentry/utils';
98
import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit';
109

1110
import type { SentryWrappedFlag } from '../common/utils';
1211
import { isHttpError, isRedirect } from '../common/utils';
13-
import { flushIfServerless, getTracePropagationData } from './utils';
12+
import { flushIfServerless } from './utils';
1413

1514
type PatchedLoadEvent = LoadEvent & SentryWrappedFlag;
1615
type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag;
@@ -132,30 +131,26 @@ export function wrapServerLoadWithSentry<T extends (...args: any) => any>(origSe
132131
// https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/server/page/load_data.js#L111C3-L124
133132
const routeId = event.route && (Object.getOwnPropertyDescriptor(event.route, 'id')?.value as string | undefined);
134133

135-
const { sentryTrace, baggage } = getTracePropagationData(event);
136-
137-
return continueTrace({ sentryTrace, baggage }, async () => {
138-
try {
139-
// We need to await before returning, otherwise we won't catch any errors thrown by the load function
140-
return await startSpan(
141-
{
142-
op: 'function.sveltekit.server.load',
143-
attributes: {
144-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
145-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url',
146-
'http.method': event.request.method,
147-
},
148-
name: routeId ? routeId : event.url.pathname,
134+
try {
135+
// We need to await before returning, otherwise we won't catch any errors thrown by the load function
136+
return await startSpan(
137+
{
138+
op: 'function.sveltekit.server.load',
139+
attributes: {
140+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
141+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url',
142+
'http.method': event.request.method,
149143
},
150-
() => wrappingTarget.apply(thisArg, args),
151-
);
152-
} catch (e: unknown) {
153-
sendErrorToSentry(e);
154-
throw e;
155-
} finally {
156-
await flushIfServerless();
157-
}
158-
});
144+
name: routeId ? routeId : event.url.pathname,
145+
},
146+
() => wrappingTarget.apply(thisArg, args),
147+
);
148+
} catch (e: unknown) {
149+
sendErrorToSentry(e);
150+
throw e;
151+
} finally {
152+
await flushIfServerless();
153+
}
159154
},
160155
});
161156
}

packages/sveltekit/test/server/handle.test.ts

+47-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ function mockEvent(override: Record<string, unknown> = {}): Parameters<Handle>[0
4343
isDataRequest: false,
4444

4545
...override,
46-
isSubRequest: false,
4746
};
4847

4948
return event;
@@ -118,7 +117,6 @@ describe('sentryHandle', () => {
118117
response = await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) });
119118
} catch (e) {
120119
expect(e).toBeInstanceOf(Error);
121-
// @ts-expect-error - this is fine
122120
expect(e.message).toEqual(type);
123121
}
124122

@@ -152,6 +150,53 @@ describe('sentryHandle', () => {
152150
expect(spans).toHaveLength(1);
153151
});
154152

153+
it('[kit>=1.21.0] creates a child span for nested server calls (i.e. if there is an active span)', async () => {
154+
let _span: Span | undefined = undefined;
155+
let txnCount = 0;
156+
client.on('spanEnd', span => {
157+
if (span === getRootSpan(span)) {
158+
_span = span;
159+
++txnCount;
160+
}
161+
});
162+
163+
try {
164+
await sentryHandle()({
165+
event: mockEvent(),
166+
resolve: async _ => {
167+
// simulateing a nested load call:
168+
await sentryHandle()({
169+
event: mockEvent({ route: { id: 'api/users/details/[id]', isSubRequest: true } }),
170+
resolve: resolve(type, isError),
171+
});
172+
return mockResponse;
173+
},
174+
});
175+
} catch (e) {
176+
//
177+
}
178+
179+
expect(txnCount).toEqual(1);
180+
expect(_span!).toBeDefined();
181+
182+
expect(spanToJSON(_span!).description).toEqual('GET /users/[id]');
183+
expect(spanToJSON(_span!).op).toEqual('http.server');
184+
expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok');
185+
expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route');
186+
187+
expect(spanToJSON(_span!).timestamp).toBeDefined();
188+
189+
const spans = getSpanDescendants(_span!).map(spanToJSON);
190+
191+
expect(spans).toHaveLength(2);
192+
expect(spans).toEqual(
193+
expect.arrayContaining([
194+
expect.objectContaining({ op: 'http.server', description: 'GET /users/[id]' }),
195+
expect.objectContaining({ op: 'http.server', description: 'GET api/users/details/[id]' }),
196+
]),
197+
);
198+
});
199+
155200
it('creates a child span for nested server calls (i.e. if there is an active span)', async () => {
156201
let _span: Span | undefined = undefined;
157202
let txnCount = 0;

0 commit comments

Comments
 (0)