Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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 {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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());
Original file line number Diff line number Diff line change
@@ -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');
}
Comment thread
chargome marked this conversation as resolved.

// 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()],
});
Original file line number Diff line number Diff line change
@@ -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<Buffer>): Promise<any> {
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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>SvelteKit app with app-owned OpenTelemetry tracing</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const GET = ({ params }) => {
throw new Error(`This is a server route error with id ${params.id}`);
};
Original file line number Diff line number Diff line change
@@ -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 });
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const load = ({ params }) => {
throw new Error(`This is a server load error with id ${params.id}`);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Server load error</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'sveltekit-2-otlp',
});
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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<T>(select: (collected: Collected) => T | undefined, description: string): Promise<T> {
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<CollectedSpan> => 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 };
}
Loading
Loading