diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/.gitignore b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/.gitignore new file mode 100644 index 000000000000..87c54ab857fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +#temporarily excluding my remote function routes. To be removed with the next PR: +./src/routes/remote-functions/data.remote.ts +./src/routes/remote-functions/+page.svelte +./tests/tracing.remote-functions.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json new file mode 100644 index 000000000000..3389d57453b8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json @@ -0,0 +1,42 @@ +{ + "name": "sveltekit-2-otlp", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "proxy": "node start-event-proxy.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/sdk-trace-node": "^2.9.0", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sveltejs/adapter-node": "5.5.1", + "@sveltejs/kit": "2.52.2", + "@sveltejs/vite-plugin-svelte": "^6.1.3", + "svelte": "^5.38.3", + "svelte-check": "^4.3.1", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^7.3.2" + }, + "type": "module", + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/playwright.config.mjs new file mode 100644 index 000000000000..6ae8142df247 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'ORIGIN=http://localhost:3030 node ./build/index.js', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.d.ts new file mode 100644 index 000000000000..ede601ab93e2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.html new file mode 100644 index 000000000000..84ffad1665e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.client.ts new file mode 100644 index 000000000000..98424c98d565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.client.ts @@ -0,0 +1,10 @@ +import { env } from '$env/dynamic/public'; +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + environment: 'qa', + dsn: env.PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server +}); + +export const handleError = Sentry.handleErrorWithSentry(); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.server.ts new file mode 100644 index 000000000000..41086ec49a82 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.server.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/sveltekit'; +import { sequence } from '@sveltejs/kit/hooks'; + +// not logging anything to console to avoid noise in the test output +export const handleError = Sentry.handleErrorWithSentry(() => {}); + +export const handle = sequence(Sentry.sentryHandle()); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/instrumentation.server.ts new file mode 100644 index 000000000000..91406fc9ba24 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/instrumentation.server.ts @@ -0,0 +1,48 @@ +import { E2E_TEST_DSN } from '$env/static/private'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/sveltekit'; +import { OTLP_RECEIVER_PORT, startOtlpReceiver } from './otel-receiver'; + +startOtlpReceiver(); + +const resource = resourceFromAttributes({ 'service.name': 'sveltekit-2-otlp' }); +const otlpBaseUrl = `http://localhost:${OTLP_RECEIVER_PORT}`; + +// In production the exporter would point at `otlpTracesEndpoint.url`; here it points at the local +// receiver so the test can assert what was exported. The auth headers are the real DSN-derived ones. +const otlpTracesEndpoint = Sentry.getOtlpTracesEndpoint(E2E_TEST_DSN); +if (!otlpTracesEndpoint) { + throw new Error('Could not derive an OTLP traces endpoint from E2E_TEST_DSN'); +} + +// The app owns tracing: this registers the global tracer provider, context manager and +// propagator. Sentry is initialized afterwards with `enableOpenTelemetrySetup: false` so it does +// not contend for any of them. +new NodeTracerProvider({ + resource, + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces`, headers: otlpTracesEndpoint.headers }), + { scheduledDelayMillis: 100 }, + ), + ], +}).register(); + +Sentry.init({ + environment: 'qa', + dsn: E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + + // Errors only: no `tracesSampleRate`, so Sentry starts no spans and sends no transactions. + + // The app brings its own OpenTelemetry SDK, which already owns the global tracer provider, + // context manager and propagator. + enableOpenTelemetrySetup: false, + + // Puts the active OpenTelemetry span's trace on everything Sentry sends. + integrations: [Sentry.openTelemetryIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/otel-receiver.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/otel-receiver.ts new file mode 100644 index 000000000000..0177cf40f2d7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/otel-receiver.ts @@ -0,0 +1,67 @@ +import { createServer } from 'node:http'; + +export const OTLP_RECEIVER_PORT = 3033; + +export interface CollectedSpan { + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + sentryAuthHeader?: string; +} + +const collectedSpans: CollectedSpan[] = []; + +function collectSpans(body: any, sentryAuthHeader: string | undefined): void { + for (const resourceSpan of body?.resourceSpans ?? []) { + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + collectedSpans.push({ + traceId: span.traceId, + spanId: span.spanId, + parentSpanId: span.parentSpanId, + name: span.name, + sentryAuthHeader, + }); + } + } + } +} + +async function readJsonBody(stream: AsyncIterable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +/** + * Stands in for the OTLP backend the app would export to in production, so the test can assert what + * the app's OpenTelemetry SDK actually put on the wire. + * + * It deliberately runs as a plain `node:http` server rather than a SvelteKit route: exporting into + * the SvelteKit server would make every export request produce spans of its own, which would then + * be exported again. + */ +export function startOtlpReceiver(): void { + const server = createServer((req, res) => { + void (async () => { + if (req.method === 'POST' && req.url === '/v1/traces') { + const sentryAuthHeader = req.headers['x-sentry-auth']; + collectSpans(await readJsonBody(req), Array.isArray(sentryAuthHeader) ? sentryAuthHeader[0] : sentryAuthHeader); + res.writeHead(200, { 'content-type': 'application/json' }).end('{}'); + return; + } + + if (req.method === 'GET' && req.url === '/collected') { + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ spans: collectedSpans })); + return; + } + + res.writeHead(404).end(); + })(); + }); + + server.listen(OTLP_RECEIVER_PORT); +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/+page.svelte new file mode 100644 index 000000000000..dd5cda2ab2f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/+page.svelte @@ -0,0 +1 @@ +

SvelteKit app with app-owned OpenTelemetry tracing

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/server-route-error/[id]/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/server-route-error/[id]/+server.ts new file mode 100644 index 000000000000..a02c8d20f641 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/server-route-error/[id]/+server.ts @@ -0,0 +1,3 @@ +export const GET = ({ params }) => { + throw new Error(`This is a server route error with id ${params.id}`); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/telemetry/[id]/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/telemetry/[id]/+server.ts new file mode 100644 index 000000000000..856fd656ecc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/telemetry/[id]/+server.ts @@ -0,0 +1,19 @@ +import { trace } from '@opentelemetry/api'; +import * as Sentry from '@sentry/sveltekit'; +import { json } from '@sveltejs/kit'; + +export const GET = ({ params }) => { + const { id } = params; + + return trace.getTracer('sveltekit-2-otlp').startActiveSpan('telemetry-handler', span => { + const { traceId, spanId } = span.spanContext(); + + Sentry.logger.info(`This is a log with id ${id}`); + Sentry.metrics.count('sentry.test.count', 1, { attributes: { id } }); + Sentry.captureException(new Error(`This is an exception with id ${id}`)); + + span.end(); + + return json({ traceId, spanId }); + }); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.server.ts new file mode 100644 index 000000000000..0a3e9c41449a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.server.ts @@ -0,0 +1,3 @@ +export const load = ({ params }) => { + throw new Error(`This is a server load error with id ${params.id}`); +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.svelte new file mode 100644 index 000000000000..307d92cf54eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.svelte @@ -0,0 +1 @@ +

Server load error

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/start-event-proxy.mjs new file mode 100644 index 000000000000..17639d1c349e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'sveltekit-2-otlp', +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/svelte.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/svelte.config.js new file mode 100644 index 000000000000..f310915da94c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/svelte.config.js @@ -0,0 +1,23 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + // SvelteKit's native server-side tracing emits its spans through the global OpenTelemetry + // tracer provider, which the app registers itself in `src/instrumentation.server.ts`. + experimental: { + instrumentation: { + server: true, + }, + tracing: { + server: true, + }, + }, + }, +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otel-telemetry.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otel-telemetry.test.ts new file mode 100644 index 000000000000..b324d74261d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otel-telemetry.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { triggerTelemetry, waitForExportedSpan } from './otlp'; + +test('keeps exporting the app-owned spans over OTLP with the DSN-derived auth header', async ({ baseURL }) => { + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '345'); + + const span = await waitForExportedSpan(span => span.spanId === spanId, `the span ${spanId}`); + + expect(span).toEqual({ + sentryAuthHeader: expect.stringMatching(/^Sentry sentry_version=7, sentry_key=\w+$/), + traceId, + spanId, + parentSpanId: expect.stringMatching(/^[a-f0-9]{16}$/), + name: 'telemetry-handler', + }); +}); + +test('sends no transactions to Sentry', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('sveltekit-2-otlp', () => true); + const errorPromise = waitForError('sveltekit-2-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await triggerTelemetry(baseURL as string, '456'); + // Proves the request's telemetry reached the proxy, so the absence check below is not vacuous. + await errorPromise; + + // Absence can only be time bounded. This guards against Sentry's tracing defaults changing under + // the app, which would emit a transaction for every request, well inside this window. + const transaction = await Promise.race([ + transactionPromise, + new Promise(resolve => setTimeout(() => resolve(undefined), 3000)), + ]); + + expect(transaction).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otlp.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otlp.ts new file mode 100644 index 000000000000..4fb64e207882 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otlp.ts @@ -0,0 +1,42 @@ +import type { CollectedSpan } from '../src/otel-receiver'; +import { OTLP_RECEIVER_PORT } from '../src/otel-receiver'; + +const OTLP_RECEIVER_URL = `http://localhost:${OTLP_RECEIVER_PORT}`; + +interface Collected { + spans: CollectedSpan[]; +} + +async function waitForCollected(select: (collected: Collected) => T | undefined, description: string): Promise { + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + const response = await fetch(`${OTLP_RECEIVER_URL}/collected`); + const collected = (await response.json()) as Collected; + + const match = select(collected); + if (match !== undefined) { + return match; + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + + const response = await fetch(`${OTLP_RECEIVER_URL}/collected`); + const { spans } = (await response.json()) as Collected; + const exportedSpanNames = [...new Set(spans.map(span => span.name))].join(', '); + + throw new Error( + `Timed out waiting for ${description} to be exported over OTLP. Exported span names: ${exportedSpanNames}`, + ); +} + +export const waitForExportedSpan = ( + matches: (span: CollectedSpan) => boolean, + description: string, +): Promise => waitForCollected(({ spans }) => spans.find(matches), description); + +export async function triggerTelemetry(baseURL: string, id: string): Promise<{ traceId: string; spanId: string }> { + const response = await fetch(`${baseURL}/api/telemetry/${id}`); + return (await response.json()) as { traceId: string; spanId: string }; +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/trace-linking.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/trace-linking.test.ts new file mode 100644 index 000000000000..891f9cf41a88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/trace-linking.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForError, waitForMetric, waitForRequest } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; +import { triggerTelemetry, waitForExportedSpan } from './otlp'; + +test('stamps captured exceptions with the trace of the active OpenTelemetry span', async ({ baseURL }) => { + const errorEventPromise = waitForError('sveltekit-2-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '123'); + const errorEvent = await errorEventPromise; + + expect(errorEvent.contexts?.trace).toEqual({ trace_id: traceId, span_id: spanId }); + expect(errorEvent.transaction).toBe('GET /api/telemetry/[id]'); +}); + +test('stamps logs with the trace of the active OpenTelemetry span', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('sveltekit-2-otlp', envelope => { + return ( + envelope[0].type === 'log' && + (envelope[1] as SerializedLogContainer).items.some(item => item.body === 'This is a log with id 124') + ); + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '124'); + const logEnvelope = await logEnvelopePromise; + + const log = (logEnvelope[1] as SerializedLogContainer).items.find(item => item.body === 'This is a log with id 124'); + expect(log?.trace_id).toBe(traceId); +}); + +test('stamps metrics with the trace of the active OpenTelemetry span', async ({ baseURL }) => { + const metricPromise = waitForMetric('sveltekit-2-otlp', metric => { + return metric.name === 'sentry.test.count' && metric.attributes?.id?.value === '125'; + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '125'); + const metric = await metricPromise; + + expect(metric.trace_id).toBe(traceId); +}); + +test('sends no envelope trace header while riding along on an OpenTelemetry span', async ({ baseURL }) => { + const envelopePromise = waitForRequest('sveltekit-2-otlp', ({ envelope }) => { + const [, items] = envelope; + return items.some( + item => + (item[1] as { exception?: { values?: { value?: string }[] } })?.exception?.values?.[0]?.value === + 'This is an exception with id 126', + ); + }); + + await triggerTelemetry(baseURL as string, '126'); + const { envelope } = await envelopePromise; + const [envelopeHeaders] = envelope; + + // The Sentry scope's sampling context describes a different trace than the OpenTelemetry one the + // event is stamped with, so no `trace` header is sent rather than one naming the wrong trace. + expect((envelopeHeaders as { trace?: unknown }).trace).toBeUndefined(); +}); + +test('links captured exceptions to the request span the app exports over OTLP', async ({ baseURL }) => { + const errorEventPromise = waitForError('sveltekit-2-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 135'; + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '135'); + const errorEvent = await errorEventPromise; + + const requestSpan = await waitForExportedSpan( + span => span.name === 'GET /api/telemetry/[id]' && span.traceId === traceId, + `the request span for trace ${traceId}`, + ); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(requestSpan.traceId); + expect(requestSpan.parentSpanId).toBeUndefined(); +}); + +test('captures errors thrown in server routes on the request trace', async ({ baseURL }) => { + const errorEventPromise = waitForError('sveltekit-2-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is a server route error with id 246'; + }); + + const response = await fetch(`${baseURL}/api/server-route-error/246`); + expect(response.status).toBe(500); + + const errorEvent = await errorEventPromise; + const traceId = errorEvent.contexts?.trace?.trace_id as string; + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + + const requestSpan = await waitForExportedSpan( + span => span.name === 'GET /api/server-route-error/[id]' && span.traceId === traceId, + `the request span for trace ${traceId}`, + ); + + expect(requestSpan.parentSpanId).toBeUndefined(); + expect(errorEvent.transaction).toBe('GET /api/server-route-error/[id]'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: true, + type: 'auto.function.sveltekit.handle_error', + }); +}); + +test('captures errors thrown in server load functions on the request trace', async ({ baseURL }) => { + const errorEventPromise = waitForError('sveltekit-2-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is a server load error with id 357'; + }); + + const response = await fetch(`${baseURL}/server-load-error/357`); + expect(response.status).toBe(500); + + const errorEvent = await errorEventPromise; + const traceId = errorEvent.contexts?.trace?.trace_id as string; + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + + const requestSpan = await waitForExportedSpan( + span => span.name === 'GET /server-load-error/[id]' && span.traceId === traceId, + `the request span for trace ${traceId}`, + ); + + expect(requestSpan.parentSpanId).toBeUndefined(); + expect(errorEvent.transaction).toBe('GET /server-load-error/[id]'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: true, + type: 'auto.function.sveltekit.handle_error', + }); +}); + +test('keeps concurrent requests on separate traces', async ({ baseURL }) => { + const errorEventPromises = ['567', '678'].map(id => + waitForError('sveltekit-2-otlp', event => { + return event.exception?.values?.[0]?.value === `This is an exception with id ${id}`; + }), + ); + + const [first, second] = await Promise.all([ + triggerTelemetry(baseURL as string, '567'), + triggerTelemetry(baseURL as string, '678'), + ]); + + const [firstError, secondError] = await Promise.all(errorEventPromises); + + expect(first.traceId).not.toBe(second.traceId); + expect(firstError.contexts?.trace?.trace_id).toBe(first.traceId); + expect(secondError.contexts?.trace?.trace_id).toBe(second.traceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tsconfig.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tsconfig.json new file mode 100644 index 000000000000..593dc1963a0e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/vite.config.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/vite.config.ts new file mode 100644 index 000000000000..be8b485d022a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/vite.config.ts @@ -0,0 +1,12 @@ +import { sentrySvelteKit } from '@sentry/sveltekit/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sentrySvelteKit({ + autoUploadSourceMaps: false, + }), + sveltekit(), + ], +}); diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 0d9160e9b341..b850e256ba38 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -48,6 +48,7 @@ export { getRootSpan, getSentryRelease, getSpanDescendants, + getOtlpTracesEndpoint, getSpanStatusFromHttpCode, getTraceData, getTraceMetaTags, @@ -73,6 +74,7 @@ export { NodeClient, nodeContextIntegration, onUncaughtExceptionIntegration, + openTelemetryIntegration, onUnhandledRejectionIntegration, parameterize, postgresIntegration,