From 160e48b4a01637ca5bc91bb2b2e9127067f649ce Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 17:02:29 +0200 Subject: [PATCH 1/3] 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. --- .../sveltekit-2-otlp/.gitignore | 15 ++ .../sveltekit-2-otlp/package.json | 44 ++++++ .../sveltekit-2-otlp/playwright.config.mjs | 8 + .../sveltekit-2-otlp/src/app.d.ts | 13 ++ .../sveltekit-2-otlp/src/app.html | 12 ++ .../sveltekit-2-otlp/src/hooks.client.ts | 10 ++ .../sveltekit-2-otlp/src/hooks.server.ts | 7 + .../src/instrumentation.server.ts | 64 ++++++++ .../sveltekit-2-otlp/src/otel-receiver.ts | 119 ++++++++++++++ .../sveltekit-2-otlp/src/routes/+page.svelte | 1 + .../api/server-route-error/[id]/+server.ts | 3 + .../src/routes/api/telemetry/[id]/+server.ts | 21 +++ .../server-load-error/[id]/+page.server.ts | 3 + .../server-load-error/[id]/+page.svelte | 1 + .../sveltekit-2-otlp/start-event-proxy.mjs | 6 + .../sveltekit-2-otlp/svelte.config.js | 23 +++ .../tests/otel-telemetry.test.ts | 48 ++++++ .../sveltekit-2-otlp/tests/otlp.ts | 48 ++++++ .../tests/trace-linking.test.ts | 147 ++++++++++++++++++ .../sveltekit-2-otlp/tsconfig.json | 19 +++ .../sveltekit-2-otlp/vite.config.ts | 12 ++ 21 files changed, 624 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/app.html create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.client.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/hooks.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/instrumentation.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/otel-receiver.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/+page.svelte create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/server-route-error/[id]/+server.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/telemetry/[id]/+server.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/server-load-error/[id]/+page.svelte create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/svelte.config.js create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otel-telemetry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otlp.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/trace-linking.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/vite.config.ts 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..55fdad17e0c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json @@ -0,0 +1,44 @@ +{ + "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-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^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..73084950c5e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/instrumentation.server.ts @@ -0,0 +1,64 @@ +import { E2E_TEST_DSN } from '$env/static/private'; +import { metrics } from '@opentelemetry/api'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +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 user 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(); + +metrics.setGlobalMeterProvider( + new MeterProvider({ + resource, + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${otlpBaseUrl}/v1/metrics` }), + exportIntervalMillis: 500, + exportTimeoutMillis: 500, + }), + ], + }), +); + +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..c8db2702201d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/otel-receiver.ts @@ -0,0 +1,119 @@ +import { createServer } from 'node:http'; + +export const OTLP_RECEIVER_PORT = 3033; + +export interface CollectedSpan { + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + sentryAuthHeader?: string; +} + +export interface CollectedMetric { + name: string; + value: number; + attributes: Record; +} + +const collectedSpans: CollectedSpan[] = []; +const collectedMetrics: CollectedMetric[] = []; + +interface OtlpAnyValue { + stringValue?: string; + intValue?: string | number; + doubleValue?: number; + boolValue?: boolean; +} + +function flattenAttributes(attributes: { key: string; value: OtlpAnyValue }[] = []): Record { + const flattened: Record = {}; + + for (const { key, value } of attributes) { + const rawValue = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue; + if (rawValue !== undefined) { + flattened[key] = String(rawValue); + } + } + + return flattened; +} + +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, + }); + } + } + } +} + +function collectMetrics(body: any): void { + for (const resourceMetric of body?.resourceMetrics ?? []) { + for (const scopeMetric of resourceMetric.scopeMetrics ?? []) { + for (const metric of scopeMetric.metrics ?? []) { + // Only counters are recorded by this app, so `sum` is the only shape that needs handling. + for (const dataPoint of metric.sum?.dataPoints ?? []) { + collectedMetrics.push({ + name: metric.name, + value: Number(dataPoint.asInt ?? dataPoint.asDouble ?? 0), + attributes: flattenAttributes(dataPoint.attributes), + }); + } + } + } + } +} + +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 user's OpenTelemetry SDK actually put on the wire. + * + * It deliberately runs as a plain `node:http` server rather than a Next.js route: exporting into the + * Next.js 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 === 'POST' && req.url === '/v1/metrics') { + collectMetrics(await readJsonBody(req)); + 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, metrics: collectedMetrics })); + 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..eb342572a56f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/+page.svelte @@ -0,0 +1 @@ +

SvelteKit app with user-owned OpenTelemetry tracing and metrics

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..1f32f752ae1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/src/routes/api/telemetry/[id]/+server.ts @@ -0,0 +1,21 @@ +import { metrics, 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(); + + metrics.getMeter('sveltekit-2-otlp').createCounter('otlp.test.count').add(1, { id }); + + 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..efb4011de91c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otel-telemetry.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { triggerTelemetry, waitForExportedMetric, waitForExportedSpan } from './otlp'; + +test('keeps exporting the app-owned metrics over OTLP', async ({ baseURL }) => { + await triggerTelemetry(baseURL as string, '234'); + + const metric = await waitForExportedMetric( + metric => metric.name === 'otlp.test.count' && metric.attributes.id === '234', + 'the metric for id 234', + ); + + expect(metric).toEqual({ name: 'otlp.test.count', value: 1, attributes: { id: '234' } }); +}); + +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..90d348153afe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/tests/otlp.ts @@ -0,0 +1,48 @@ +import type { CollectedMetric, 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[]; + metrics: CollectedMetric[]; +} + +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 const waitForExportedMetric = ( + matches: (metric: CollectedMetric) => boolean, + description: string, +): Promise => waitForCollected(({ metrics }) => metrics.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(), + ], +}); From 9c14895f72be626114ad5c6e77bf76ec4c352b0f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 17:11:17 +0200 Subject: [PATCH 2/3] fix(sveltekit): Export OTLP helpers explicitly from the server entry The server entry lists Node SDK exports explicitly because some Vite production builds put star-export bindings on the default export. openTelemetryIntegration and getOtlpTracesEndpoint were only reachable via the star export, so they now join the explicit list. --- packages/sveltekit/src/server/index.ts | 2 ++ 1 file changed, 2 insertions(+) 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, From b70b88b7701a8fc120330a2f0ebe814016e3c641 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 17:36:46 +0200 Subject: [PATCH 3/3] test(e2e): Emit metrics through Sentry only in the sveltekit-2-otlp app The app no longer registers an OpenTelemetry meter provider or exports metrics over OTLP. Metrics, like logs, go through Sentry and are asserted to carry the active OpenTelemetry trace. --- .../sveltekit-2-otlp/package.json | 2 - .../src/instrumentation.server.ts | 18 +----- .../sveltekit-2-otlp/src/otel-receiver.ts | 62 ++----------------- .../sveltekit-2-otlp/src/routes/+page.svelte | 2 +- .../src/routes/api/telemetry/[id]/+server.ts | 4 +- .../tests/otel-telemetry.test.ts | 13 +--- .../sveltekit-2-otlp/tests/otlp.ts | 8 +-- 7 files changed, 10 insertions(+), 99 deletions(-) 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 index 55fdad17e0c6..3389d57453b8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-otlp/package.json @@ -16,10 +16,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", "@opentelemetry/resources": "^2.9.0", - "@opentelemetry/sdk-metrics": "^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", 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 index 73084950c5e1..91406fc9ba24 100644 --- 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 @@ -1,9 +1,6 @@ import { E2E_TEST_DSN } from '$env/static/private'; -import { metrics } from '@opentelemetry/api'; -import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { resourceFromAttributes } from '@opentelemetry/resources'; -import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import * as Sentry from '@sentry/sveltekit'; @@ -21,7 +18,7 @@ if (!otlpTracesEndpoint) { throw new Error('Could not derive an OTLP traces endpoint from E2E_TEST_DSN'); } -// The user owns tracing: this registers the global tracer provider, context manager and +// 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({ @@ -34,19 +31,6 @@ new NodeTracerProvider({ ], }).register(); -metrics.setGlobalMeterProvider( - new MeterProvider({ - resource, - readers: [ - new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ url: `${otlpBaseUrl}/v1/metrics` }), - exportIntervalMillis: 500, - exportTimeoutMillis: 500, - }), - ], - }), -); - Sentry.init({ environment: 'qa', dsn: E2E_TEST_DSN, 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 index c8db2702201d..0177cf40f2d7 100644 --- 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 @@ -10,34 +10,7 @@ export interface CollectedSpan { sentryAuthHeader?: string; } -export interface CollectedMetric { - name: string; - value: number; - attributes: Record; -} - const collectedSpans: CollectedSpan[] = []; -const collectedMetrics: CollectedMetric[] = []; - -interface OtlpAnyValue { - stringValue?: string; - intValue?: string | number; - doubleValue?: number; - boolValue?: boolean; -} - -function flattenAttributes(attributes: { key: string; value: OtlpAnyValue }[] = []): Record { - const flattened: Record = {}; - - for (const { key, value } of attributes) { - const rawValue = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue; - if (rawValue !== undefined) { - flattened[key] = String(rawValue); - } - } - - return flattened; -} function collectSpans(body: any, sentryAuthHeader: string | undefined): void { for (const resourceSpan of body?.resourceSpans ?? []) { @@ -55,23 +28,6 @@ function collectSpans(body: any, sentryAuthHeader: string | undefined): void { } } -function collectMetrics(body: any): void { - for (const resourceMetric of body?.resourceMetrics ?? []) { - for (const scopeMetric of resourceMetric.scopeMetrics ?? []) { - for (const metric of scopeMetric.metrics ?? []) { - // Only counters are recorded by this app, so `sum` is the only shape that needs handling. - for (const dataPoint of metric.sum?.dataPoints ?? []) { - collectedMetrics.push({ - name: metric.name, - value: Number(dataPoint.asInt ?? dataPoint.asDouble ?? 0), - attributes: flattenAttributes(dataPoint.attributes), - }); - } - } - } - } -} - async function readJsonBody(stream: AsyncIterable): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) { @@ -82,11 +38,11 @@ async function readJsonBody(stream: AsyncIterable): Promise { /** * Stands in for the OTLP backend the app would export to in production, so the test can assert what - * the user's OpenTelemetry SDK actually put on the wire. + * the app's OpenTelemetry SDK actually put on the wire. * - * It deliberately runs as a plain `node:http` server rather than a Next.js route: exporting into the - * Next.js server would make every export request produce spans of its own, which would then be - * exported again. + * 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) => { @@ -98,16 +54,8 @@ export function startOtlpReceiver(): void { return; } - if (req.method === 'POST' && req.url === '/v1/metrics') { - collectMetrics(await readJsonBody(req)); - 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, metrics: collectedMetrics })); + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ spans: collectedSpans })); return; } 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 index eb342572a56f..dd5cda2ab2f1 100644 --- 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 @@ -1 +1 @@ -

SvelteKit app with user-owned OpenTelemetry tracing and metrics

+

SvelteKit app with app-owned OpenTelemetry tracing

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 index 1f32f752ae1d..856fd656ecc1 100644 --- 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 @@ -1,4 +1,4 @@ -import { metrics, trace } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; import * as Sentry from '@sentry/sveltekit'; import { json } from '@sveltejs/kit'; @@ -8,8 +8,6 @@ export const GET = ({ params }) => { return trace.getTracer('sveltekit-2-otlp').startActiveSpan('telemetry-handler', span => { const { traceId, spanId } = span.spanContext(); - metrics.getMeter('sveltekit-2-otlp').createCounter('otlp.test.count').add(1, { id }); - 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}`)); 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 index efb4011de91c..b324d74261d0 100644 --- 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 @@ -1,17 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -import { triggerTelemetry, waitForExportedMetric, waitForExportedSpan } from './otlp'; - -test('keeps exporting the app-owned metrics over OTLP', async ({ baseURL }) => { - await triggerTelemetry(baseURL as string, '234'); - - const metric = await waitForExportedMetric( - metric => metric.name === 'otlp.test.count' && metric.attributes.id === '234', - 'the metric for id 234', - ); - - expect(metric).toEqual({ name: 'otlp.test.count', value: 1, attributes: { id: '234' } }); -}); +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'); 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 index 90d348153afe..4fb64e207882 100644 --- 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 @@ -1,11 +1,10 @@ -import type { CollectedMetric, CollectedSpan } from '../src/otel-receiver'; +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[]; - metrics: CollectedMetric[]; } async function waitForCollected(select: (collected: Collected) => T | undefined, description: string): Promise { @@ -37,11 +36,6 @@ export const waitForExportedSpan = ( description: string, ): Promise => waitForCollected(({ spans }) => spans.find(matches), description); -export const waitForExportedMetric = ( - matches: (metric: CollectedMetric) => boolean, - description: string, -): Promise => waitForCollected(({ metrics }) => metrics.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 };