Skip to content

Commit 160e48b

Browse files
committed
test(e2e): Add a sveltekit-2-otlp E2E app for user-owned OpenTelemetry setups
Mirrors the nextjs-otlp app: the app registers its own OpenTelemetry tracer and meter providers, Sentry runs with enableOpenTelemetrySetup disabled, and the specs assert that errors, logs and metrics link to the active OpenTelemetry trace while spans and metrics keep exporting over OTLP.
1 parent 9596f42 commit 160e48b

21 files changed

Lines changed: 624 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.DS_Store
2+
node_modules
3+
/build
4+
/.svelte-kit
5+
/package
6+
.env
7+
.env.*
8+
!.env.example
9+
vite.config.js.timestamp-*
10+
vite.config.ts.timestamp-*
11+
12+
#temporarily excluding my remote function routes. To be removed with the next PR:
13+
./src/routes/remote-functions/data.remote.ts
14+
./src/routes/remote-functions/+page.svelte
15+
./tests/tracing.remote-functions.ts
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "sveltekit-2-otlp",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"dev": "vite dev",
7+
"build": "vite build",
8+
"preview": "vite preview",
9+
"proxy": "node start-event-proxy.mjs",
10+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
11+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
12+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
13+
"test:prod": "TEST_ENV=production playwright test",
14+
"test:build": "pnpm install && pnpm build",
15+
"test:assert": "pnpm test:prod"
16+
},
17+
"dependencies": {
18+
"@opentelemetry/api": "^1.9.1",
19+
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
20+
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
21+
"@opentelemetry/resources": "^2.9.0",
22+
"@opentelemetry/sdk-metrics": "^2.9.0",
23+
"@opentelemetry/sdk-trace-base": "^2.9.0",
24+
"@opentelemetry/sdk-trace-node": "^2.9.0",
25+
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
26+
"@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz"
27+
},
28+
"devDependencies": {
29+
"@playwright/test": "~1.56.0",
30+
"@sentry-internal/test-utils": "link:../../../test-utils",
31+
"@sveltejs/adapter-node": "5.5.1",
32+
"@sveltejs/kit": "2.52.2",
33+
"@sveltejs/vite-plugin-svelte": "^6.1.3",
34+
"svelte": "^5.38.3",
35+
"svelte-check": "^4.3.1",
36+
"tslib": "^2.4.1",
37+
"typescript": "^5.0.0",
38+
"vite": "^7.3.2"
39+
},
40+
"type": "module",
41+
"volta": {
42+
"extends": "../../package.json"
43+
}
44+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: 'ORIGIN=http://localhost:3030 node ./build/index.js',
5+
port: 3030,
6+
});
7+
8+
export default config;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// See https://kit.svelte.dev/docs/types#app
2+
// for information about these interfaces
3+
declare global {
4+
namespace App {
5+
// interface Error {}
6+
// interface Locals {}
7+
// interface PageData {}
8+
// interface PageState {}
9+
// interface Platform {}
10+
}
11+
}
12+
13+
export {};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
%sveltekit.head%
8+
</head>
9+
<body data-sveltekit-preload-data="hover">
10+
<div style="display: contents">%sveltekit.body%</div>
11+
</body>
12+
</html>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { env } from '$env/dynamic/public';
2+
import * as Sentry from '@sentry/sveltekit';
3+
4+
Sentry.init({
5+
environment: 'qa',
6+
dsn: env.PUBLIC_E2E_TEST_DSN,
7+
tunnel: `http://localhost:3031/`, // proxy server
8+
});
9+
10+
export const handleError = Sentry.handleErrorWithSentry();
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as Sentry from '@sentry/sveltekit';
2+
import { sequence } from '@sveltejs/kit/hooks';
3+
4+
// not logging anything to console to avoid noise in the test output
5+
export const handleError = Sentry.handleErrorWithSentry(() => {});
6+
7+
export const handle = sequence(Sentry.sentryHandle());
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { E2E_TEST_DSN } from '$env/static/private';
2+
import { metrics } from '@opentelemetry/api';
3+
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
4+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
5+
import { resourceFromAttributes } from '@opentelemetry/resources';
6+
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
7+
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
8+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
9+
import * as Sentry from '@sentry/sveltekit';
10+
import { OTLP_RECEIVER_PORT, startOtlpReceiver } from './otel-receiver';
11+
12+
startOtlpReceiver();
13+
14+
const resource = resourceFromAttributes({ 'service.name': 'sveltekit-2-otlp' });
15+
const otlpBaseUrl = `http://localhost:${OTLP_RECEIVER_PORT}`;
16+
17+
// In production the exporter would point at `otlpTracesEndpoint.url`; here it points at the local
18+
// receiver so the test can assert what was exported. The auth headers are the real DSN-derived ones.
19+
const otlpTracesEndpoint = Sentry.getOtlpTracesEndpoint(E2E_TEST_DSN);
20+
if (!otlpTracesEndpoint) {
21+
throw new Error('Could not derive an OTLP traces endpoint from E2E_TEST_DSN');
22+
}
23+
24+
// The user owns tracing: this registers the global tracer provider, context manager and
25+
// propagator. Sentry is initialized afterwards with `enableOpenTelemetrySetup: false` so it does
26+
// not contend for any of them.
27+
new NodeTracerProvider({
28+
resource,
29+
spanProcessors: [
30+
new BatchSpanProcessor(
31+
new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces`, headers: otlpTracesEndpoint.headers }),
32+
{ scheduledDelayMillis: 100 },
33+
),
34+
],
35+
}).register();
36+
37+
metrics.setGlobalMeterProvider(
38+
new MeterProvider({
39+
resource,
40+
readers: [
41+
new PeriodicExportingMetricReader({
42+
exporter: new OTLPMetricExporter({ url: `${otlpBaseUrl}/v1/metrics` }),
43+
exportIntervalMillis: 500,
44+
exportTimeoutMillis: 500,
45+
}),
46+
],
47+
}),
48+
);
49+
50+
Sentry.init({
51+
environment: 'qa',
52+
dsn: E2E_TEST_DSN,
53+
debug: !!process.env.DEBUG,
54+
tunnel: `http://localhost:3031/`, // proxy server
55+
56+
// Errors only: no `tracesSampleRate`, so Sentry starts no spans and sends no transactions.
57+
58+
// The app brings its own OpenTelemetry SDK, which already owns the global tracer provider,
59+
// context manager and propagator.
60+
enableOpenTelemetrySetup: false,
61+
62+
// Puts the active OpenTelemetry span's trace on everything Sentry sends.
63+
integrations: [Sentry.openTelemetryIntegration()],
64+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { createServer } from 'node:http';
2+
3+
export const OTLP_RECEIVER_PORT = 3033;
4+
5+
export interface CollectedSpan {
6+
traceId: string;
7+
spanId: string;
8+
parentSpanId?: string;
9+
name: string;
10+
sentryAuthHeader?: string;
11+
}
12+
13+
export interface CollectedMetric {
14+
name: string;
15+
value: number;
16+
attributes: Record<string, string>;
17+
}
18+
19+
const collectedSpans: CollectedSpan[] = [];
20+
const collectedMetrics: CollectedMetric[] = [];
21+
22+
interface OtlpAnyValue {
23+
stringValue?: string;
24+
intValue?: string | number;
25+
doubleValue?: number;
26+
boolValue?: boolean;
27+
}
28+
29+
function flattenAttributes(attributes: { key: string; value: OtlpAnyValue }[] = []): Record<string, string> {
30+
const flattened: Record<string, string> = {};
31+
32+
for (const { key, value } of attributes) {
33+
const rawValue = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue;
34+
if (rawValue !== undefined) {
35+
flattened[key] = String(rawValue);
36+
}
37+
}
38+
39+
return flattened;
40+
}
41+
42+
function collectSpans(body: any, sentryAuthHeader: string | undefined): void {
43+
for (const resourceSpan of body?.resourceSpans ?? []) {
44+
for (const scopeSpan of resourceSpan.scopeSpans ?? []) {
45+
for (const span of scopeSpan.spans ?? []) {
46+
collectedSpans.push({
47+
traceId: span.traceId,
48+
spanId: span.spanId,
49+
parentSpanId: span.parentSpanId,
50+
name: span.name,
51+
sentryAuthHeader,
52+
});
53+
}
54+
}
55+
}
56+
}
57+
58+
function collectMetrics(body: any): void {
59+
for (const resourceMetric of body?.resourceMetrics ?? []) {
60+
for (const scopeMetric of resourceMetric.scopeMetrics ?? []) {
61+
for (const metric of scopeMetric.metrics ?? []) {
62+
// Only counters are recorded by this app, so `sum` is the only shape that needs handling.
63+
for (const dataPoint of metric.sum?.dataPoints ?? []) {
64+
collectedMetrics.push({
65+
name: metric.name,
66+
value: Number(dataPoint.asInt ?? dataPoint.asDouble ?? 0),
67+
attributes: flattenAttributes(dataPoint.attributes),
68+
});
69+
}
70+
}
71+
}
72+
}
73+
}
74+
75+
async function readJsonBody(stream: AsyncIterable<Buffer>): Promise<any> {
76+
const chunks: Buffer[] = [];
77+
for await (const chunk of stream) {
78+
chunks.push(chunk);
79+
}
80+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
81+
}
82+
83+
/**
84+
* Stands in for the OTLP backend the app would export to in production, so the test can assert what
85+
* the user's OpenTelemetry SDK actually put on the wire.
86+
*
87+
* It deliberately runs as a plain `node:http` server rather than a Next.js route: exporting into the
88+
* Next.js server would make every export request produce spans of its own, which would then be
89+
* exported again.
90+
*/
91+
export function startOtlpReceiver(): void {
92+
const server = createServer((req, res) => {
93+
void (async () => {
94+
if (req.method === 'POST' && req.url === '/v1/traces') {
95+
const sentryAuthHeader = req.headers['x-sentry-auth'];
96+
collectSpans(await readJsonBody(req), Array.isArray(sentryAuthHeader) ? sentryAuthHeader[0] : sentryAuthHeader);
97+
res.writeHead(200, { 'content-type': 'application/json' }).end('{}');
98+
return;
99+
}
100+
101+
if (req.method === 'POST' && req.url === '/v1/metrics') {
102+
collectMetrics(await readJsonBody(req));
103+
res.writeHead(200, { 'content-type': 'application/json' }).end('{}');
104+
return;
105+
}
106+
107+
if (req.method === 'GET' && req.url === '/collected') {
108+
res
109+
.writeHead(200, { 'content-type': 'application/json' })
110+
.end(JSON.stringify({ spans: collectedSpans, metrics: collectedMetrics }));
111+
return;
112+
}
113+
114+
res.writeHead(404).end();
115+
})();
116+
});
117+
118+
server.listen(OTLP_RECEIVER_PORT);
119+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>SvelteKit app with user-owned OpenTelemetry tracing and metrics</h1>

0 commit comments

Comments
 (0)