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,