diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte index 29ce9ec693a8..d8175182884a 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte @@ -26,4 +26,7 @@
  • Redirect
  • +
  • + Route with nested fetch in server load +
  • diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-fetch/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-fetch/+page.server.ts new file mode 100644 index 000000000000..709e52bcf351 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-fetch/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/api/users'); + const data = await res.json(); + return { data }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-fetch/+page.svelte new file mode 100644 index 000000000000..f7f814d31b4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/server-load-fetch/+page.svelte @@ -0,0 +1,8 @@ + + +
    +

    Server Load Fetch

    +

    {JSON.stringify(data, null, 2)}

    +
    diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/test/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/performance.server.test.ts new file mode 100644 index 000000000000..7aec23b30d7a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/test/performance.server.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('server pageload request span has nested request span for sub request', async ({ page }) => { + const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === 'GET /server-load-fetch'; + }); + + await page.goto('/server-load-fetch'); + + const serverTxnEvent = await serverTxnEventPromise; + const spans = serverTxnEvent.spans; + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /server-load-fetch', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + expect(spans).toEqual( + expect.arrayContaining([ + // load span where the server load function initiates the sub request: + expect.objectContaining({ op: 'function.sveltekit.server.load', description: '/server-load-fetch' }), + // sub request span: + expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/package.json b/dev-packages/e2e-tests/test-applications/sveltekit/package.json index c1ed602844c1..5a6b2d4d083c 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit/package.json @@ -22,7 +22,7 @@ "@sentry/utils": "latest || *", "@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-node": "^1.2.4", - "@sveltejs/kit": "^1.30.3", + "@sveltejs/kit": "1.20.5", "svelte": "^3.54.0", "svelte-check": "^3.0.1", "ts-node": "10.9.1", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte index aeb12d58603f..62dbf7856ab7 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/+page.svelte @@ -23,4 +23,7 @@
  • Route with fetch in universal load
  • +
  • + Route with nested fetch in server load +
  • diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-fetch/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-fetch/+page.server.ts new file mode 100644 index 000000000000..709e52bcf351 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-fetch/+page.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ fetch }) => { + const res = await fetch('/api/users'); + const data = await res.json(); + return { data }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-fetch/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-fetch/+page.svelte new file mode 100644 index 000000000000..f7f814d31b4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/src/routes/server-load-fetch/+page.svelte @@ -0,0 +1,8 @@ + + +
    +

    Server Load Fetch

    +

    {JSON.stringify(data, null, 2)}

    +
    diff --git a/dev-packages/e2e-tests/test-applications/sveltekit/test/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit/test/performance.server.test.ts new file mode 100644 index 000000000000..49fde5f01045 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit/test/performance.server.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('server pageload request span has nested request span for sub request', async ({ page }) => { + const serverTxnEventPromise = waitForTransaction('sveltekit', txnEvent => { + return txnEvent?.transaction === 'GET /server-load-fetch'; + }); + + await page.goto('/server-load-fetch'); + + const serverTxnEvent = await serverTxnEventPromise; + const spans = serverTxnEvent.spans; + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /server-load-fetch', + tags: { runtime: 'node' }, + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.sveltekit', + }, + }, + }); + + expect(spans).toEqual( + expect.arrayContaining([ + // load span where the server load function initiates the sub request: + expect.objectContaining({ op: 'function.sveltekit.server.load', description: '/server-load-fetch' }), + // sub request span: + expect.objectContaining({ op: 'http.server', description: 'GET /api/users' }), + ]), + ); +}); diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 08ab880c5f27..0bb08be6ce50 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -149,15 +149,26 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { - // if there is an active span, we know that this handle call is nested and hence - // we don't create a new execution context for it. - // If we created one, nested server calls would create new root span instead - // of adding a child span to the currently active span. - if (getActiveSpan()) { + // event.isSubRequest was added in SvelteKit 1.21.0 and we can use it to check + // if we should create a new execution context or not. + // In case of a same-origin `fetch` call within a server`load` function, + // SvelteKit will actually just re-enter the `handle` function and set `isSubRequest` + // to `true` so that no additional network call is made. + // We want the `http.server` span of that nested call to be a child span of the + // currently active span instead of a new root span to correctly reflect this + // behavior. + // As a fallback for Kit < 1.21.0, we check if there is an active span only if there's none, + // we create a new execution context. + const isSubRequest = typeof input.event.isSubRequest === 'boolean' ? input.event.isSubRequest : !!getActiveSpan(); + + if (isSubRequest) { return instrumentHandle(input, options); } + return withIsolationScope(() => { - return instrumentHandle(input, options); + // We only call continueTrace in the initial top level request to avoid + // creating a new root span for the sub request. + return continueTrace(getTracePropagationData(input.event), () => instrumentHandle(input, options)); }); }; @@ -172,36 +183,32 @@ async function instrumentHandle( return resolve(event); } - const { sentryTrace, baggage } = getTracePropagationData(event); - - return continueTrace({ sentryTrace, baggage }, async () => { - try { - const resolveResult = await startSpan( - { - op: 'http.server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url', - 'http.method': event.request.method, - }, - name: `${event.request.method} ${event.route?.id || event.url.pathname}`, - }, - async (span?: Span) => { - const res = await resolve(event, { - transformPageChunk: addSentryCodeToPage(options), - }); - if (span) { - setHttpStatus(span, res.status); - } - return res; + try { + const resolveResult = await startSpan( + { + op: 'http.server', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url', + 'http.method': event.request.method, }, - ); - return resolveResult; - } catch (e: unknown) { - sendErrorToSentry(e); - throw e; - } finally { - await flushIfServerless(); - } - }); + name: `${event.request.method} ${event.route?.id || event.url.pathname}`, + }, + async (span?: Span) => { + const res = await resolve(event, { + transformPageChunk: addSentryCodeToPage(options), + }); + if (span) { + setHttpStatus(span, res.status); + } + return res; + }, + ); + return resolveResult; + } catch (e: unknown) { + sendErrorToSentry(e); + throw e; + } finally { + await flushIfServerless(); + } } diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server/load.ts index d0d757c31ef6..fe61ed0913bd 100644 --- a/packages/sveltekit/src/server/load.ts +++ b/packages/sveltekit/src/server/load.ts @@ -2,7 +2,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, - continueTrace, startSpan, } from '@sentry/node'; import { addNonEnumerableProperty, objectify } from '@sentry/utils'; @@ -10,7 +9,7 @@ import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; import { isHttpError, isRedirect } from '../common/utils'; -import { flushIfServerless, getTracePropagationData } from './utils'; +import { flushIfServerless } from './utils'; type PatchedLoadEvent = LoadEvent & SentryWrappedFlag; type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag; @@ -132,30 +131,26 @@ export function wrapServerLoadWithSentry any>(origSe // https://github.com/sveltejs/kit/blob/e133aba479fa9ba0e7f9e71512f5f937f0247e2c/packages/kit/src/runtime/server/page/load_data.js#L111C3-L124 const routeId = event.route && (Object.getOwnPropertyDescriptor(event.route, 'id')?.value as string | undefined); - const { sentryTrace, baggage } = getTracePropagationData(event); - - return continueTrace({ sentryTrace, baggage }, async () => { - try { - // We need to await before returning, otherwise we won't catch any errors thrown by the load function - return await startSpan( - { - op: 'function.sveltekit.server.load', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', - 'http.method': event.request.method, - }, - name: routeId ? routeId : event.url.pathname, + try { + // We need to await before returning, otherwise we won't catch any errors thrown by the load function + return await startSpan( + { + op: 'function.sveltekit.server.load', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', + 'http.method': event.request.method, }, - () => wrappingTarget.apply(thisArg, args), - ); - } catch (e: unknown) { - sendErrorToSentry(e); - throw e; - } finally { - await flushIfServerless(); - } - }); + name: routeId ? routeId : event.url.pathname, + }, + () => wrappingTarget.apply(thisArg, args), + ); + } catch (e: unknown) { + sendErrorToSentry(e); + throw e; + } finally { + await flushIfServerless(); + } }, }); } diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index 1d720046fe48..2c3d1a9333f1 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -43,7 +43,6 @@ function mockEvent(override: Record = {}): Parameters[0 isDataRequest: false, ...override, - isSubRequest: false, }; return event; @@ -118,7 +117,6 @@ describe('sentryHandle', () => { response = await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); } catch (e) { expect(e).toBeInstanceOf(Error); - // @ts-expect-error - this is fine expect(e.message).toEqual(type); } @@ -152,6 +150,53 @@ describe('sentryHandle', () => { expect(spans).toHaveLength(1); }); + it('[kit>=1.21.0] creates a child span for nested server calls (i.e. if there is an active span)', async () => { + let _span: Span | undefined = undefined; + let txnCount = 0; + client.on('spanEnd', span => { + if (span === getRootSpan(span)) { + _span = span; + ++txnCount; + } + }); + + try { + await sentryHandle()({ + event: mockEvent(), + resolve: async _ => { + // simulateing a nested load call: + await sentryHandle()({ + event: mockEvent({ route: { id: 'api/users/details/[id]', isSubRequest: true } }), + resolve: resolve(type, isError), + }); + return mockResponse; + }, + }); + } catch (e) { + // + } + + expect(txnCount).toEqual(1); + expect(_span!).toBeDefined(); + + expect(spanToJSON(_span!).description).toEqual('GET /users/[id]'); + expect(spanToJSON(_span!).op).toEqual('http.server'); + expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); + expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); + + expect(spanToJSON(_span!).timestamp).toBeDefined(); + + const spans = getSpanDescendants(_span!).map(spanToJSON); + + expect(spans).toHaveLength(2); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: 'http.server', description: 'GET /users/[id]' }), + expect.objectContaining({ op: 'http.server', description: 'GET api/users/details/[id]' }), + ]), + ); + }); + it('creates a child span for nested server calls (i.e. if there is an active span)', async () => { let _span: Span | undefined = undefined; let txnCount = 0; diff --git a/packages/sveltekit/test/server/load.test.ts b/packages/sveltekit/test/server/load.test.ts index d42615521e7a..699fcf034232 100644 --- a/packages/sveltekit/test/server/load.test.ts +++ b/packages/sveltekit/test/server/load.test.ts @@ -1,13 +1,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, addTracingExtensions, } from '@sentry/core'; import { NodeClient, getCurrentScope, getIsolationScope, setCurrentClient } from '@sentry/node'; import * as SentryNode from '@sentry/node'; -import type { Event, EventEnvelopeHeaders } from '@sentry/types'; +import type { Event } from '@sentry/types'; import type { Load, ServerLoad } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit'; import { vi } from 'vitest'; @@ -77,38 +76,6 @@ function getServerOnlyArgs() { }; } -function getServerArgsWithoutTracingHeaders() { - return { - ...getLoadArgs(), - request: { - method: 'GET', - headers: { - get: (_: string) => { - return null; - }, - }, - }, - }; -} - -function getServerArgsWithoutBaggageHeader() { - return { - ...getLoadArgs(), - request: { - method: 'GET', - headers: { - get: (key: string) => { - if (key === 'sentry-trace') { - return '1234567890abcdef1234567890abcdef-1234567890abcdef-1'; - } - - return null; - }, - }, - }, - }; -} - beforeAll(() => { addTracingExtensions(); }); @@ -281,119 +248,6 @@ describe('wrapServerLoadWithSentry calls `startSpan`', () => { mockCaptureException.mockClear(); }); - it('attaches trace data if available', async () => { - let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; - - client.on('beforeEnvelope', env => { - envelopeHeaders = env[0] as EventEnvelopeHeaders; - }); - - const wrappedLoad = wrapServerLoadWithSentry(serverLoad); - await wrappedLoad(getServerOnlyArgs()); - - await client.flush(); - - expect(txnEvents).toHaveLength(1); - const transaction = txnEvents[0]; - - expect(transaction.contexts?.trace).toEqual({ - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.sveltekit.server.load', - 'http.method': 'GET', - }, - op: 'function.sveltekit.server.load', - parent_span_id: '1234567890abcdef', - span_id: expect.any(String), - trace_id: '1234567890abcdef1234567890abcdef', - origin: 'auto.function.sveltekit', - }); - - expect(transaction.transaction).toEqual('/users/[id]'); - - expect(envelopeHeaders!.trace).toEqual({ - environment: 'production', - public_key: 'dogsarebadatkeepingsecrets', - release: '1.0.0', - sample_rate: '1', - trace_id: '1234567890abcdef1234567890abcdef', - transaction: 'dogpark', - }); - }); - - it("doesn't attach trace data if it's not available", async () => { - let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; - - client.on('beforeEnvelope', env => { - envelopeHeaders = env[0] as EventEnvelopeHeaders; - }); - - const wrappedLoad = wrapServerLoadWithSentry(serverLoad); - await wrappedLoad(getServerArgsWithoutTracingHeaders()); - - await client.flush(); - - expect(txnEvents).toHaveLength(1); - const transaction = txnEvents[0]; - - expect(transaction.contexts?.trace).toEqual({ - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.sveltekit.server.load', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'http.method': 'GET', - }, - op: 'function.sveltekit.server.load', - span_id: expect.any(String), - trace_id: expect.not.stringContaining('1234567890abcdef1234567890abcdef'), - origin: 'auto.function.sveltekit', - }); - expect(transaction.transaction).toEqual('/users/[id]'); - expect(envelopeHeaders!.trace).toEqual({ - environment: 'production', - public_key: 'public', - sample_rate: '1', - sampled: 'true', - release: '8.0.0', - trace_id: transaction.contexts?.trace?.trace_id, - transaction: '/users/[id]', - }); - }); - - it("doesn't attach the DSC data if the baggage header is not available", async () => { - let envelopeHeaders: EventEnvelopeHeaders | undefined = undefined; - - client.on('beforeEnvelope', env => { - envelopeHeaders = env[0] as EventEnvelopeHeaders; - }); - - const wrappedLoad = wrapServerLoadWithSentry(serverLoad); - await wrappedLoad(getServerArgsWithoutBaggageHeader()); - - await client.flush(); - - expect(txnEvents).toHaveLength(1); - const transaction = txnEvents[0]; - - expect(transaction.contexts?.trace).toEqual({ - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.sveltekit.server.load', - 'http.method': 'GET', - }, - op: 'function.sveltekit.server.load', - parent_span_id: '1234567890abcdef', - span_id: expect.any(String), - trace_id: '1234567890abcdef1234567890abcdef', - origin: 'auto.function.sveltekit', - }); - expect(transaction.transaction).toEqual('/users/[id]'); - expect(envelopeHeaders!.trace).toEqual({}); - }); - it('falls back to the raw url if `event.route.id` is not available', async () => { const event = getServerOnlyArgs(); // @ts-expect-error - this is fine (just tests here) @@ -412,11 +266,11 @@ describe('wrapServerLoadWithSentry calls `startSpan`', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.sveltekit.server.load', 'http.method': 'GET', + 'sentry.sample_rate': 1, }, op: 'function.sveltekit.server.load', - parent_span_id: '1234567890abcdef', span_id: expect.any(String), - trace_id: '1234567890abcdef1234567890abcdef', + trace_id: expect.any(String), origin: 'auto.function.sveltekit', }); expect(transaction.transaction).toEqual('/users/123');