diff --git a/.size-limit.js b/.size-limit.js
index 220fa9c349e8..6e085d4f6e52 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -419,7 +419,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
- limit: '150 KB',
+ limit: '151 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
@@ -465,7 +465,7 @@ module.exports = [
path: 'packages/node/build/esm/index.js',
import: createImport('init'),
gzip: true,
- limit: '128 KB',
+ limit: '130 KB',
disablePlugins: ['@size-limit/esbuild'],
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
modifyWebpackConfig: function (config) {
@@ -533,6 +533,27 @@ module.exports = [
return config;
},
},
+ // Next.js on Cloudflare Workers (ESM) - minified to match `wrangler deploy --dry-run --minify` output. An import of
+ // `@sentry/node` in this entry more than doubles its size.
+ {
+ name: '@sentry/nextjs/cloudflare (withSentry) - minified',
+ path: 'packages/nextjs/build/esm/cloudflare/index.js',
+ import: createImport('withSentry'),
+ ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
+ gzip: false,
+ brotli: false,
+ limit: '223 KiB',
+ disablePlugins: ['@size-limit/webpack'],
+ webpack: false,
+ modifyEsbuildConfig: function (config) {
+ config.keepNames = true;
+ // Match wrangler's build settings
+ config.conditions = ['workerd', 'worker', 'browser'];
+ config.platform = 'browser';
+ config.format = 'esm';
+ return config;
+ },
+ },
];
function createImport(...args) {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ca19496299f..21394e7db5b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,87 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 11.6.0
+
+### Important Changes
+
+- **feat(nextjs): Add `@sentry/nextjs/cloudflare` ([#24998](https://github.com/getsentry/sentry-javascript/pull/24998))**
+
+ The new `@sentry/nextjs/cloudflare` entry point exports `withSentry` for the Worker entry of a Next.js app on Cloudflare Workers, for example `.open-next/worker.js` of OpenNext. It is `withSentry` of `@sentry/cloudflare` with the Next.js server handling added, so a Next.js app on Workers gets the same spans as on Node.js.
+
+ ```ts
+ import * as Sentry from '@sentry/nextjs/cloudflare';
+ import handler from './.open-next/worker.js';
+
+ export default Sentry.withSentry(
+ env => ({
+ dsn: env.SENTRY_DSN,
+ tracesSampleRate: 1.0,
+ }),
+ handler,
+ );
+ ```
+
+- **feat(server-utils): Add instrumentation for pi-durable ([#24993](https://github.com/getsentry/sentry-javascript/pull/24993))**
+
+ The new `piDurableIntegration` traces agents built with [pi-durable](https://github.com/earendil-works/pi/tree/main/packages/durable): each run, with its model requests and tool calls.
+
+- **fix(core): Correct sleep clock drift on telemetry timestamps ([#23054](https://github.com/getsentry/sentry-javascript/pull/23054))**
+
+ When a device sleeps, the `performance.now()` clock of the browser stops. Before this change, timestamps of spans and other telemetry recorded after the sleep could be too early. The SDK now detects this drift and corrects its time origin, also for spans created from performance entries. Span end times are now computed from the `performance.now()` duration since the span start, like in OpenTelemetry ([#24903](https://github.com/getsentry/sentry-javascript/pull/24903)).
+
+### Other Changes
+
+- feat(cloudflare): Skip spans for the pi_ tables of PiHarness ([#25028](https://github.com/getsentry/sentry-javascript/pull/25028))
+- feat(remix): Propagate the server trace to Remix 3 page loads ([#25145](https://github.com/getsentry/sentry-javascript/pull/25145))
+- feat(server-utils): Map Mastra classifier evaluations to gen_ai.evaluate spans ([#25044](https://github.com/getsentry/sentry-javascript/pull/25044))
+- feat(server-utils): Record LangChain TypeSafeClassifier runs as gen_ai.evaluate spans ([#25120](https://github.com/getsentry/sentry-javascript/pull/25120))
+- fix(browser): Check for clock drift on page lifecycle events ([#25091](https://github.com/getsentry/sentry-javascript/pull/25091))
+- fix(core): Exclude attachments from internal exception events ([#25160](https://github.com/getsentry/sentry-javascript/pull/25160))
+- fix(effect): Convert Effect span times to the Sentry clock ([#24972](https://github.com/getsentry/sentry-javascript/pull/24972))
+- fix(effect): Detect HTTP spans named after the request method ([#25159](https://github.com/getsentry/sentry-javascript/pull/25159))
+- fix(node): Register Vercel keep-alive listeners once ([#25146](https://github.com/getsentry/sentry-javascript/pull/25146))
+- fix(replay): Place CLS and INP web vitals at the right time ([#24990](https://github.com/getsentry/sentry-javascript/pull/24990))
+- fix(server-utils): Record LangGraph span I/O for non-MessagesAnnotation state ([#23315](https://github.com/getsentry/sentry-javascript/pull/23315))
+- fix(sveltekit): Upload source maps with SvelteKit 3 ([#25139](https://github.com/getsentry/sentry-javascript/pull/25139))
+
+
+ Internal Changes
+
+- chore: Add external contributor to CHANGELOG.md ([#25136](https://github.com/getsentry/sentry-javascript/pull/25136))
+- chore(deps-dev): Bump next from 15.5.24 to 15.5.27 ([#25171](https://github.com/getsentry/sentry-javascript/pull/25171))
+- chore(nitro): Remove stale bundler-plugin-core rollup external ([#25149](https://github.com/getsentry/sentry-javascript/pull/25149))
+- feat(cloudflare): Wrap vinext Worker entries with `@sentry/nextjs/cloudflare` ([#25000](https://github.com/getsentry/sentry-javascript/pull/25000))
+- feat(deps): Bump next from 16.2.11 to 16.3.8 in /dev-packages/e2e-tests/test-applications/nextjs-sourcemaps ([#25170](https://github.com/getsentry/sentry-javascript/pull/25170))
+- fix(deps): Bump @graphql-tools/utils to 12.0.3 ([#25162](https://github.com/getsentry/sentry-javascript/pull/25162))
+- ref(eslint-plugin-sdk): Improve `no-unsafe-random-apis` rule ([#25123](https://github.com/getsentry/sentry-javascript/pull/25123))
+- test(cloudflare): Port the remaining static suites to span streaming ([#25112](https://github.com/getsentry/sentry-javascript/pull/25112))
+- test(cloudflare): Widen the wrangler readiness retry window in integration tests ([#24925](https://github.com/getsentry/sentry-javascript/pull/24925))
+- test(e2e): Add a cloudflare-pi-durable end-to-end application ([#25022](https://github.com/getsentry/sentry-javascript/pull/25022))
+- test(e2e): Add a node-pi-durable end-to-end application ([#24994](https://github.com/getsentry/sentry-javascript/pull/24994))
+- test(e2e): Add Mastra Jev classifier test to node-mastra ([#25115](https://github.com/getsentry/sentry-javascript/pull/25115))
+- test(e2e): Wait for the soft navigation entry before hiding the page ([#25127](https://github.com/getsentry/sentry-javascript/pull/25127))
+- test(nextjs): Run nextjs-16 on Bun, Deno and Cloudflare ([#24999](https://github.com/getsentry/sentry-javascript/pull/24999))
+- test(node): Add streamed span collection helpers ([#25131](https://github.com/getsentry/sentry-javascript/pull/25131))
+- test(node): Cover Jev calls inside createAgent and LangGraph ([#25132](https://github.com/getsentry/sentry-javascript/pull/25132))
+- test(node): Port AMQP integration tests to span streaming ([#25158](https://github.com/getsentry/sentry-javascript/pull/25158))
+- test(node): Port Anthropic integration tests to span streaming ([#25082](https://github.com/getsentry/sentry-javascript/pull/25082))
+- test(node): Port Bedrock tests to span streaming ([#25086](https://github.com/getsentry/sentry-javascript/pull/25086))
+- test(node): Port Google GenAI tests to span streaming ([#25083](https://github.com/getsentry/sentry-javascript/pull/25083))
+- test(node): Port Knex integration tests to span streaming ([#25154](https://github.com/getsentry/sentry-javascript/pull/25154))
+- test(node): Port LangChain tests to span streaming ([#25084](https://github.com/getsentry/sentry-javascript/pull/25084))
+- test(node): Port LangGraph tests to span streaming ([#25085](https://github.com/getsentry/sentry-javascript/pull/25085))
+- test(node): Port Mastra tests to span streaming ([#25087](https://github.com/getsentry/sentry-javascript/pull/25087))
+- test(node): Port MySQL integration tests to span streaming ([#25148](https://github.com/getsentry/sentry-javascript/pull/25148))
+- test(node): Port OpenAI integration tests to span streaming ([#25081](https://github.com/getsentry/sentry-javascript/pull/25081))
+- test(node): Port pool and DataLoader integration tests to span streaming ([#25156](https://github.com/getsentry/sentry-javascript/pull/25156))
+- test(node): Port Tedious integration tests to span streaming ([#25155](https://github.com/getsentry/sentry-javascript/pull/25155))
+- test(node): Port Vercel AI v5-v7 tests to span streaming ([#25090](https://github.com/getsentry/sentry-javascript/pull/25090))
+
+
+
+Work in this release was contributed by @zkasuran. Thank you for your contribution!
+
## 11.5.0
### Important Changes
diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts
index d27790a79a0f..5b99ffc866ab 100644
--- a/dev-packages/bun-integration-tests/node-suites/excludes.ts
+++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts
@@ -100,7 +100,7 @@ export const NO_AUTO_INSTRUMENTATION = [
'suites/tracing/knex/**',
'suites/tracing/koa/test.ts',
'suites/tracing/langchain/**',
- 'suites/tracing/langgraph/test.ts',
+ 'suites/tracing/langgraph/**',
'suites/tracing/lru-memoizer/test.ts',
'suites/tracing/mastra/test.ts',
'suites/tracing/mcp-handler-exact-once/test.ts',
@@ -124,6 +124,7 @@ export const NO_AUTO_INSTRUMENTATION = [
'suites/tracing/openai/v6/test.ts',
'suites/tracing/openai/v7/test.ts',
'suites/tracing/orchestrion-lazy-registration/test.ts',
+ 'suites/tracing/pi-durable/test.ts',
'suites/tracing/postgres-streamed/test.ts',
'suites/tracing/postgres/test.ts',
'suites/tracing/postgresjs-streamed/test.ts',
@@ -244,6 +245,7 @@ const BUN_BUILD_NOT_TRIAGED = [
'suites/tracing/google-genai-v2/test.ts',
'suites/tracing/google-genai/test.ts',
'suites/tracing/langchain/v1/test.ts',
+ 'suites/tracing/langgraph/v1/test.ts',
'suites/tracing/mastra/test.ts',
'suites/tracing/mcp-handler-exact-once/test.ts',
'suites/tracing/mcp-server-streamed/test.ts',
diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts
index 1ce7493466bd..69ab9e20fc2e 100644
--- a/dev-packages/cloudflare-integration-tests/runner.ts
+++ b/dev-packages/cloudflare-integration-tests/runner.ts
@@ -85,7 +85,7 @@ type RetryOptions = { maxRetries?: number; retryDelayMs?: number };
async function fetchWithRetry(
url: string,
init: RequestInit,
- { maxRetries = 25, retryDelayMs = 200 }: RetryOptions = {},
+ { maxRetries = 75, retryDelayMs = 200 }: RetryOptions = {},
): Promise {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
@@ -569,7 +569,12 @@ export function createRunner(...paths: string[]) {
if (!res.ok) {
if (!expectError) {
- reject(new Error(`Expected request to "${path}" to succeed, but got a ${res.status} response`));
+ const responseBody = await res.text().catch(() => '');
+ reject(
+ new Error(
+ `Expected request to "${path}" to succeed, but got a ${res.status} response: ${responseBody.slice(0, 500)}`,
+ ),
+ );
}
return;
diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts
index 4355b90010d6..e577550ae0b8 100644
--- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts
@@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare';
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
- traceLifecycle: 'static',
tracesSampleRate: 1.0,
}));
diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts
index 074a6e258414..1fb8a9680507 100644
--- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts
@@ -1,49 +1,35 @@
-import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../../runner';
+import { getSpanOp } from '../../../../spanUtils';
it('propagates the worker trace into each of two overlapping Durable Object RPC calls', async ({ signal }) => {
- const doTraces: Record = {};
- let workerTraceId: string | undefined;
- let workerSpanId: string | undefined;
-
- const runner = createRunner(__dirname)
- .unordered()
- .expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- expect(transactionEvent.transaction).toBe('work');
- expect(transactionEvent.contexts?.trace?.op).toBe('rpc');
- expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('a');
- doTraces.a = transactionEvent.contexts;
- })
- .expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- expect(transactionEvent.transaction).toBe('work');
- expect(transactionEvent.contexts?.trace?.op).toBe('rpc');
- expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('b');
- doTraces.b = transactionEvent.contexts;
- })
- .expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- expect(transactionEvent.transaction).toBe('GET /overlapping');
- expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
- workerTraceId = transactionEvent.contexts?.trace?.trace_id;
- workerSpanId = transactionEvent.contexts?.trace?.span_id;
- })
- .start(signal);
+ const runner = createRunner(__dirname).start(signal);
+
+ // The worker and the Durable Object stream from separate isolates, and both RPC segments come from
+ // the same Durable Object, so they can share an envelope.
+ const spansPromise = runner.collectStreamedSpans(
+ spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 3,
+ );
const response = await runner.makeRequest('get', '/overlapping');
expect(response).toBe('a,b');
- await runner.completed();
+ const segmentSpans = (await spansPromise).filter(span => span.is_segment);
+ const workerSpan = segmentSpans.find(span => getSpanOp(span) === 'http.server');
+ const rpcSpanA = segmentSpans.find(span => span.attributes['test.label']?.value === 'a');
+ const rpcSpanB = segmentSpans.find(span => span.attributes['test.label']?.value === 'b');
+
+ // `/overlapping` is a raw URL, so the streamed segment name keeps the method only.
+ expect(workerSpan?.name).toBe('GET');
+ expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/overlapping' });
- expect(workerTraceId).toMatch(/^[\da-f]{32}$/);
- expect(workerSpanId).toMatch(/^[\da-f]{16}$/);
+ expect(rpcSpanA?.name).toBe('work');
+ expect(getSpanOp(rpcSpanA!)).toBe('rpc');
+ expect(rpcSpanA?.parent_span_id).toBe(workerSpan?.span_id);
- expect(doTraces.a?.trace?.trace_id).toBe(workerTraceId);
- expect(doTraces.a?.trace?.parent_span_id).toBe(workerSpanId);
- expect(doTraces.b?.trace?.trace_id).toBe(workerTraceId);
- expect(doTraces.b?.trace?.parent_span_id).toBe(workerSpanId);
+ expect(rpcSpanB?.name).toBe('work');
+ expect(getSpanOp(rpcSpanB!)).toBe('rpc');
+ expect(rpcSpanB?.parent_span_id).toBe(workerSpan?.span_id);
- expect(doTraces.a?.trace?.span_id).not.toBe(doTraces.b?.trace?.span_id);
+ expect(rpcSpanA?.span_id).not.toBe(rpcSpanB?.span_id);
});
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/greeter.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/greeter.ts
index 78cb5873f593..b6bbd4cf37c1 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/greeter.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/greeter.ts
@@ -14,6 +14,6 @@ class GreeterImpl extends WorkerEntrypoint {
// Manually instrumented here, in a module separate from the worker entry, which
// only imports and re-exports the wrapped class.
export const GreeterEntrypoint = Sentry.withSentry(
- (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }),
+ (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
GreeterImpl,
);
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/instrument.server.ts
index 4355b90010d6..e577550ae0b8 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/instrument.server.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/instrument.server.ts
@@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare';
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
- traceLifecycle: 'static',
tracesSampleRate: 1.0,
}));
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/test.ts
index fe8f168b8e4c..8e583ad8fdc8 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/test.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint-reexport-instrumented/test.ts
@@ -1,30 +1,38 @@
-import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';
+import { getSpanOp } from '../../../spanUtils';
// `GreeterEntrypoint` is hand-wrapped in `./greeter` and only re-exported by
// the entry, so the transform's emitted `_INTERNAL_wrapUnlessInstrumented`
// guard must hand the manual wrap back instead of nesting a second wrapper.
-// Nested entrypoint wrappers each instrument `fetch`, which shows up as extra
-// spans on the entrypoint transaction, the strict shape below catches that.
+// A nested wrapper throws inside the entrypoint and fails the request. The
+// child-less assertion below catches a nested wrapper that runs without an
+// error but instruments `fetch` a second time.
it('does not double-instrument an imported, already-wrapped WorkerEntrypoint', async ({ signal }) => {
- const runner = createRunner(__dirname)
- .unordered()
- .expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- // The entrypoint's own transaction, child-less when wrapped exactly once.
- expect(transactionEvent.transaction).toBe('GET /greet');
- expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
- expect(transactionEvent.spans ?? []).toHaveLength(0);
- })
- .expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- // The auto-wrapped default export's transaction.
- expect(transactionEvent.transaction).toBe('GET /call-entrypoint');
- expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
- })
- .start(signal);
+ const runner = createRunner(__dirname).start(signal);
+
+ // The default handler and the entrypoint stream from separate isolates, so the two segment spans
+ // of the trace arrive in separate envelopes.
+ const spansPromise = runner.collectStreamedSpans(
+ spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2,
+ );
await runner.makeRequest('get', '/call-entrypoint');
- await runner.completed();
+
+ const spans = await spansPromise;
+ // Both routes are raw URLs, so the streamed segment names keep the method only and the route is
+ // read from `url.path`.
+ const workerSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/call-entrypoint');
+ const entrypointSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/greet');
+
+ // The auto-wrapped default export's segment span.
+ expect(workerSpan?.name).toBe('GET');
+ expect(getSpanOp(workerSpan!)).toBe('http.server');
+
+ expect(entrypointSpan?.name).toBe('GET');
+ expect(getSpanOp(entrypointSpan!)).toBe('http.server');
+ expect(entrypointSpan?.parent_span_id).toBe(workerSpan?.span_id);
+ // The entrypoint's spans stay buffered until its invocation flushes, so a child span would arrive
+ // in the same envelope as the entrypoint's segment span.
+ expect(spans.filter(span => span.parent_span_id === entrypointSpan?.span_id)).toEqual([]);
});
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/index.ts
index 080a6d9619ff..8a29d5f7b4e1 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/index.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/index.ts
@@ -15,9 +15,9 @@ export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
- // Issued by the test after `/trigger` returned, its transaction is the
+ // Issued by the test after `/trigger` returned, its segment span is the
// sentinel proving every earlier envelope (including a duplicate step
- // transaction from an accidental double wrap) has been delivered.
+ // span from an accidental double wrap) has been delivered.
if (url.pathname === '/sentinel') {
return new Response('ok');
}
@@ -26,7 +26,7 @@ export default {
const instance = await env.MY_WORKFLOW.create();
// Respond only once the workflow finished, so every step envelope (including
// a duplicate from an accidental double wrap) is sent before this request's
- // own transaction completes the test's expectations.
+ // own segment span completes the test's expectations.
for (let i = 0; i < 20; i++) {
try {
const s = await instance.status();
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/instrument.server.ts
index 4355b90010d6..e577550ae0b8 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/instrument.server.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/instrument.server.ts
@@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare';
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
- traceLifecycle: 'static',
tracesSampleRate: 1.0,
}));
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/test.ts
index 5b874733fac4..8cf1d76364fa 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/test.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/test.ts
@@ -1,39 +1,44 @@
-import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';
+import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';
// `MyWorkflow` is hand-wrapped in `./workflow` and only re-exported by the
// entry, so the transform's emitted `_INTERNAL_wrapUnlessInstrumented` guard
// must hand the manual wrap back instead of nesting a second wrapper. Nested
// workflow wrappers each run the step through their own client, producing TWO
-// identical `step-one` transactions, each individually well-formed, so the
-// real check is the count assertion at the end.
+// identical `step-one` spans, each individually well-formed, so the real check
+// is the count assertion at the end.
//
// Ordering is anchored by a sentinel rather than by waiting: `/trigger`
// responds only after the workflow finished (every step envelope, including a
// duplicate, is flushed before then), and `/sentinel` is requested after that,
-// so its transaction arrives a full request/response cycle behind any
-// duplicate. Once the sentinel envelope has been matched, everything sent
-// before it is known to have been delivered.
+// so its span arrives a full request/response cycle behind any duplicate. Once
+// the sentinel envelope has been matched, everything sent before it is known to
+// have been delivered.
it('does not double-instrument an imported, already-wrapped Workflow', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- expect(transactionEvent.transaction).toBe('step-one');
- expect(transactionEvent.contexts?.trace?.op).toBe('function');
- expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow');
+ const stepSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment && span.name === 'step-one');
+ expect(getSpanOp(stepSpan!)).toBe('function');
+ expect(stepSpan?.attributes['sentry.origin']).toEqual({
+ type: 'string',
+ value: 'auto.faas.cloudflare.workflow',
+ });
})
.expect(envelope => {
- const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
- // The auto-wrapped default export's own transaction.
- expect(transactionEvent.transaction).toBe('GET /trigger');
- expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
+ // The auto-wrapped default export's own segment span. `/trigger` is a raw URL, so the streamed
+ // segment name keeps the method only.
+ const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
+ expect(segmentSpan?.name).toBe('GET');
+ expect(getSpanOp(segmentSpan!)).toBe('http.server');
+ expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/trigger' });
})
// The sentinel is part of the expected set, so the runner keeps everything
// alive (and keeps receiving envelopes) until it has arrived.
.expect(envelope => {
- expect((envelope[1]?.[0]?.[1] as TransactionEvent).transaction).toBe('GET /sentinel');
+ const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
+ expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/sentinel' });
})
.start(signal);
@@ -41,8 +46,9 @@ it('does not double-instrument an imported, already-wrapped Workflow', async ({
await runner.makeRequest('get', '/sentinel');
await runner.completed();
- const stepTransactions = runner
+ const stepSpans = runner
.getReceivedEnvelopes()
- .filter(envelope => (envelope[1]?.[0]?.[1] as TransactionEvent | undefined)?.transaction === 'step-one');
- expect(stepTransactions).toHaveLength(1);
+ .flatMap(envelope => getSpansFromEnvelope(envelope))
+ .filter(span => span.name === 'step-one');
+ expect(stepSpans).toHaveLength(1);
});
diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/workflow.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/workflow.ts
index cc74ee7dfa68..fe8edeccd371 100644
--- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/workflow.ts
+++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow-reexport-instrumented/workflow.ts
@@ -15,6 +15,6 @@ class MyWorkflowImpl extends WorkflowEntrypoint {
// Manually instrumented here, in a module separate from the worker entry, which
// only imports and re-exports the wrapped class.
export const MyWorkflow = Sentry.instrumentWorkflowWithSentry(
- (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }),
+ (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
MyWorkflowImpl,
);
diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md
index f4fd271d5c74..096dfa4f1b91 100644
--- a/dev-packages/e2e-tests/README.md
+++ b/dev-packages/e2e-tests/README.md
@@ -153,7 +153,10 @@ is the reference setup.
- **SDK per runtime**: each runtime inits its own SDK, the way a user of that runtime would: Node the framework SDK
(`instrument.mjs`), Bun `@sentry/bun` (`instrument.bun.mjs`), Deno `@sentry/deno` (`instrument.deno.mjs`) and
Cloudflare `@sentry/cloudflare`. The framework SDK only provides the framework wrappers on the other runtimes, so
- values that come from its `init()` (`sdk.name`, default integrations) are Node-only.
+ values that come from its `init()` (`sdk.name`, default integrations) are Node-only. Next.js is different: it loads
+ `instrumentation.ts` on every runtime, so `nextjs-16` calls `init()` of `@sentry/nextjs` on Bun and Deno too, and
+ adds the integrations of `@sentry/bun` or `@sentry/deno` that replace the Node instrumentation those runtimes do not
+ trigger.
- **Start commands**: `playwright.config.mjs` selects the start command with `getRuntime()`. Bun and Deno use the same build
as Node and only change the start command, for example
`bun --bun --preload ./instrument.bun.mjs ./node_modules/@react-router/serve/bin.cjs ./build/server/index.js` and
@@ -168,7 +171,9 @@ is the reference setup.
separate file (for example `workers/app.ts`) and exports a plain handler: the Sentry plugin wraps it with
`withSentry` and reads the init options from `instrument.server.ts` next to the entry. The start command runs
`wrangler dev` on the build output. Code at module scope must not do I/O (for example open a database connection),
- because workerd does not allow it.
+ because workerd does not allow it. `nextjs-16` has no Vite build: its Cloudflare variant builds with
+ `opennextjs-cloudflare build` and wraps the generated Worker in `worker.ts` with `withSentry` from
+ `@sentry/nextjs/cloudflare`.
- **Runtime-specific files**: a file that has the runtime as a part of its name replaces the existing file without that
part, for example `app/entry.server.cloudflare.tsx` replaces `app/entry.server.tsx` and `vite.cloudflare.config.ts`
replaces `vite.config.ts`. For a variant with `runtime`, the runner copies these files over the others in the
diff --git a/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts
index 15bc9a2f401b..0af2a016396f 100644
--- a/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts
+++ b/dev-packages/e2e-tests/test-applications/angular-22/tests/soft-navigation-web-vitals.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
+import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils';
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
// that triggered it, so it only holds while the navigation span is started before the interaction's
@@ -15,6 +15,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea
await page.goto('/');
await page.locator('#navLink').click();
+ await waitForSoftNavigation(page);
+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
// is reported for it until the page goes away.
await hidePage(page);
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json
new file mode 100644
index 000000000000..5f172858bcf4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "cloudflare-pi-durable",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview --port 38789",
+ "typecheck": "tsc --noEmit",
+ "test:build": "pnpm install && pnpm build",
+ "test:build-latest": "pnpm install && pnpm add agents@latest @earendil-works/pi-durable@latest @earendil-works/pi-ai@latest && pnpm build",
+ "test:assert": "playwright test"
+ },
+ "dependencies": {
+ "@earendil-works/pi-ai": "1.0.0",
+ "@earendil-works/pi-durable": "1.0.0",
+ "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
+ "agents": "0.26.0"
+ },
+ "devDependencies": {
+ "@cloudflare/vite-plugin": "1.57.2",
+ "@cloudflare/workers-types": "^4.20260426.0",
+ "@playwright/test": "~1.63.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@sentry/conventions": "0.26.0",
+ "typescript": "^5.5.2",
+ "vite": "7.3.5",
+ "wrangler": "^4.136.2"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ },
+ "sentryTest": {
+ "optional": true,
+ "optionalVariants": [
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "cloudflare-pi-durable (latest)"
+ }
+ ]
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/playwright.config.ts
new file mode 100644
index 000000000000..1b9be61d8261
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/playwright.config.ts
@@ -0,0 +1,13 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig(
+ {
+ startCommand: 'pnpm preview',
+ port: 38789,
+ },
+ // Each test drives real OpenRouter tool-calling turns, and one resets the Durable Object mid-run,
+ // which does not fit the default 30s timeout when the provider is slow.
+ { timeout: 120_000 },
+);
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts
new file mode 100644
index 000000000000..bd0d25a8c5b3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts
@@ -0,0 +1,96 @@
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
+import { createRegistry, defineExtension, defineTool, Harness, section } from '@earendil-works/pi-durable';
+import * as Sentry from '@sentry/cloudflare';
+import { Agent } from 'agents';
+import { PiHarness } from 'agents/harness/pi';
+
+/**
+ * pi-durable hosted by the Agents SDK `PiHarness`: pi keeps its state in this object's SQLite
+ * database, and the harness reopens pi when the object restarts. Nothing wires Sentry into
+ * pi-durable by hand, the Vite plugin injects the channel into `Harness.open()`.
+ */
+export class Assistant extends Agent {
+ public registry = createRegistry();
+
+ public harness = new PiHarness({
+ harness: ({ storage, context }) => {
+ // pi-ai reads provider keys from `process.env` by default, so the Worker hands over its secret.
+ const models = createModels({
+ authContext: {
+ env: async name => (name === 'OPENROUTER_API_KEY' ? this.env.E2E_OPENROUTER_API_KEY : undefined),
+ fileExists: async () => false,
+ },
+ });
+ models.setProvider(openrouterProvider());
+ return Harness.open(storage, { models, registry: this.registry }, context);
+ },
+ defaults: { model: { provider: 'openrouter', id: 'anthropic/claude-haiku-4.5' } },
+ });
+
+ public constructor(ctx: DurableObjectState, env: Env) {
+ super(ctx, env);
+ this.registry.install(
+ defineExtension({
+ name: 'e2e',
+ sections: [
+ section('preamble', () => 'You are a test assistant. Use the tools exactly as asked. Keep answers short.', {
+ tag: false,
+ }),
+ ],
+ tools: [
+ defineTool({
+ name: 'get_weather',
+ description: 'Get the current weather for a city.',
+ parameters: Type.Object({ city: Type.String() }),
+ // The manual span should nest under the SDK's `execute_tool` span.
+ execute: async args =>
+ Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({
+ content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }],
+ })),
+ }),
+ defineTool({
+ name: 'fail_now',
+ description: 'Always throws an error. Call this when the user asks to trigger a failure.',
+ parameters: Type.Object({}),
+ execute: async () => {
+ throw new Error('Intentional pi-durable tool failure');
+ },
+ }),
+ defineTool({
+ name: 'crash_once',
+ description: 'Runs one step of a job. Call this when the user asks for crash_once.',
+ parameters: Type.Object({ job: Type.String() }),
+ // Replay-safe, so pi-durable reruns the call after the reset instead of failing it.
+ replay: 'safe',
+ execute: async args => {
+ const marker = `crashed:${args.job}`;
+ if (await this.ctx.storage.get(marker)) {
+ return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] };
+ }
+ await this.ctx.storage.put(marker, true);
+ await this.ctx.storage.sync();
+ // Drops the object mid-call, like an eviction. The next request starts it again.
+ this.ctx.abort('crash_once resets the Durable Object');
+ throw new Error('The Durable Object did not reset');
+ },
+ }),
+ ],
+ }),
+ );
+ this.lifecycle.use(this.harness);
+ }
+
+ public async onRequest(request: Request): Promise {
+ if (request.method === 'GET') {
+ const operationId = new URL(request.url).searchParams.get('operation') ?? '';
+ const { status, reason, text } = await this.harness.wait(operationId);
+ return Response.json({ status, reason, text });
+ }
+
+ const { message, operationId } = await request.json<{ message: string; operationId?: string }>();
+ const { status, reason, text } = await this.harness.prompt(message, { operationId });
+ return Response.json({ status, reason, text });
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/env.d.ts
new file mode 100644
index 000000000000..a088008cd966
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/env.d.ts
@@ -0,0 +1,9 @@
+declare namespace Cloudflare {
+ interface Env {
+ E2E_TEST_DSN: string;
+ E2E_OPENROUTER_API_KEY: string;
+ Assistant: DurableObjectNamespace;
+ }
+}
+
+interface Env extends Cloudflare.Env {}
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/index.ts
new file mode 100644
index 000000000000..756f31007b6b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/index.ts
@@ -0,0 +1,9 @@
+import { routeAgentRequest } from 'agents';
+
+export { Assistant } from './assistant';
+
+export default {
+ async fetch(request, env): Promise {
+ return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 });
+ },
+} satisfies ExportedHandler;
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/instrument.server.ts
new file mode 100644
index 000000000000..4bd8e8b9fdc0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/instrument.server.ts
@@ -0,0 +1,10 @@
+import { defineCloudflareOptions } from '@sentry/cloudflare';
+
+// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in
+// wrangler's `main`, and hands its default export to `withSentry`.
+export default defineCloudflareOptions((env: Env) => ({
+ dsn: env.E2E_TEST_DSN,
+ environment: 'qa',
+ tunnel: 'http://localhost:3031/',
+ tracesSampleRate: 1.0,
+}));
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/start-event-proxy.mjs
new file mode 100644
index 000000000000..bdcf0b3d4465
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'cloudflare-pi-durable',
+});
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts
new file mode 100644
index 000000000000..bf2a4092188d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts
@@ -0,0 +1,147 @@
+import { expect, test } from '@playwright/test';
+import {
+ GEN_AI_CONVERSATION_ID,
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_RESPONSE_FINISH_REASONS,
+ GEN_AI_TOOL_CALL_ARGUMENTS,
+ GEN_AI_TOOL_CALL_RESULT,
+ GEN_AI_TOOL_NAME,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+} from '@sentry/conventions/attributes';
+import { DB_QUERY, GEN_AI_CHAT, GEN_AI_INVOKE_AGENT, HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op';
+import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
+import { newAgentId, prompt, submitPrompt, waitForOperation } from './utils';
+
+const APP = 'cloudflare-pi-durable';
+
+test('traces a PiHarness prompt as invoke_agent with chat, execute_tool and provider spans', async ({ baseURL }) => {
+ // A run is its own trace, and its `invoke_agent` segment ends last. Only this test calls get_weather.
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(span => span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT) &&
+ spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'get_weather'),
+ );
+
+ const answer = await prompt(
+ baseURL!,
+ newAgentId('weather'),
+ 'What is the weather in Vienna? Use the get_weather tool.',
+ );
+ expect(answer.status, answer.reason).toBe('done');
+
+ const spans = await spansPromise;
+ const agent = spans.find(span => span.is_segment)!;
+ const chats = spans.filter(span => getSpanOp(span) === GEN_AI_CHAT);
+ // The manual span of the tool is the one way to pick the call that ran, should the model call twice.
+ const manualSpan = spans.find(span => span.name === 'resolve-weather')!;
+ const tool = spans.find(span => span.span_id === manualSpan.parent_span_id)!;
+ const providerCalls = spans.filter(span => getSpanOp(span) === HTTP_CLIENT);
+
+ expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
+ // The PiHarness root session is pi conversation 1, in a Harness of its own per Durable Object.
+ expect(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).toMatch(/^[0-9a-f]{32}:1$/);
+ // The request that submitted the prompt is a trace of its own: the scheduler runs the work.
+ expect(spans.some(span => getSpanOp(span) === HTTP_SERVER)).toBe(false);
+ // pi-durable keeps its state in the `pi_` tables of `PiHarness`, whose statements start no span.
+ expect(spans.filter(span => getSpanOp(span) === DB_QUERY && /\bpi_/.test(String(span.name)))).toEqual([]);
+ // pi-ai sends the requests through `@anthropic-ai/sdk`, whose own integration must stay out so
+ // each request is reported once.
+ expect(spans.filter(span => String(span.attributes[SENTRY_ORIGIN]?.value).startsWith('auto.ai.'))).toEqual(
+ spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable'),
+ );
+
+ // One tool-calling response, then the answer.
+ expect(chats.length).toBeGreaterThanOrEqual(2);
+ for (const chat of chats) {
+ expect(chat.parent_span_id).toBe(agent.span_id);
+ expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('openrouter');
+ expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('anthropic/claude-haiku-4.5');
+ expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(agent.attributes[GEN_AI_CONVERSATION_ID]?.value);
+ // pi-durable retries a provider error inside the run; such a request has no usage and no HTTP span
+ // of its own to assert on.
+ if (chat.status === 'ok') {
+ expect(typeof chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe('number');
+ expect(typeof chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe('number');
+ expect(providerCalls.some(providerCall => providerCall.parent_span_id === chat.span_id)).toBe(true);
+ }
+ }
+
+ expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
+ expect(tool.parent_span_id).toBe(agent.span_id);
+ expect(tool.status).toBe('ok');
+ expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toContain('Vienna');
+ expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('21 degrees and sunny in');
+
+ // The provider's HTTP calls nest inside the `chat` span that sent them.
+ expect(providerCalls.length).toBeGreaterThan(0);
+ for (const providerCall of providerCalls) {
+ expect(chats.map(chat => chat.span_id)).toContain(providerCall.parent_span_id);
+ expect(providerCall.attributes[SERVER_ADDRESS]?.value).toBe('openrouter.ai');
+ }
+});
+
+test('reports a throwing tool as an error on its execute_tool span', async ({ baseURL }) => {
+ const errorPromise = waitForError(
+ APP,
+ event => event.exception?.values?.[0]?.value === 'Intentional pi-durable tool failure',
+ );
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(span => span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT) &&
+ spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'fail_now'),
+ );
+
+ const answer = await prompt(baseURL!, newAgentId('fail'), 'Call the fail_now tool, then tell me what happened.');
+ expect(answer.status, answer.reason).toBe('done');
+
+ const [error, spans] = await Promise.all([errorPromise, spansPromise]);
+ // The error is reported on the span of the call that threw.
+ const tool = spans.find(span => span.span_id === error.contexts?.trace?.span_id)!;
+
+ expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('fail_now');
+ expect(tool.status).toBe('error');
+ expect(tool.trace_id).toBe(error.contexts?.trace?.trace_id);
+ expect(error.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+});
+
+test('resumes a run in a new trace after the Durable Object resets during a tool call', async ({ baseURL }) => {
+ const agentId = newAgentId('reset');
+ const operationId = crypto.randomUUID();
+ // The tool span of the reset object never ends, so a finished `crash_once` span can only come
+ // from the rerun after the restart.
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(span => span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT) &&
+ spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once' && span.status === 'ok'),
+ );
+
+ // The reset fails the request that submitted the prompt, but pi has stored the input already.
+ await submitPrompt(
+ baseURL!,
+ agentId,
+ 'Run the crash_once tool for job "nightly", then reply with the word DONE.',
+ operationId,
+ );
+ const answer = await waitForOperation(baseURL!, agentId, operationId);
+ expect(answer.status, answer.reason).toBe('done');
+
+ const spans = await spansPromise;
+ const agent = spans.find(span => span.is_segment)!;
+ const tool = spans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once')!;
+ const chats = spans.filter(span => getSpanOp(span) === GEN_AI_CHAT);
+ const finalAnswer = chats.find(span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["stop"]');
+
+ expect(getSpanOp(agent)).toBe(GEN_AI_INVOKE_AGENT);
+ expect(tool.parent_span_id).toBe(agent.span_id);
+ expect(finalAnswer?.parent_span_id).toBe(agent.span_id);
+ // The request that called the tool ran before the reset, so this trace starts with the rerun of the
+ // tool. A run that never reset would start with that request.
+ expect(chats.every(chat => chat.start_timestamp >= tool.start_timestamp)).toBe(true);
+});
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/utils.ts
new file mode 100644
index 000000000000..0e23ca86301e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/utils.ts
@@ -0,0 +1,38 @@
+import { expect } from '@playwright/test';
+
+export type Operation = { status: string; reason?: string; text?: string };
+
+/**
+ * A Durable Object name nothing has used yet. The name is the `:name` path segment
+ * `routeAgentRequest` maps to an object, so each test gets its own SQLite database and pi Harness.
+ */
+export function newAgentId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+/** Submit a prompt to the root session. The response arrives once pi settles it. */
+export function submitPrompt(
+ baseURL: string,
+ agentId: string,
+ message: string,
+ operationId?: string,
+): Promise {
+ return fetch(`${baseURL}/agents/assistant/${agentId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message, operationId }),
+ });
+}
+
+export async function prompt(baseURL: string, agentId: string, message: string): Promise {
+ const res = await submitPrompt(baseURL, agentId, message);
+ expect(res.status).toBe(200);
+ return (await res.json()) as Operation;
+}
+
+/** Wait until pi settles an operation. The request starts the object again if it was reset. */
+export async function waitForOperation(baseURL: string, agentId: string, operationId: string): Promise {
+ const res = await fetch(`${baseURL}/agents/assistant/${agentId}?operation=${operationId}`);
+ expect(res.status).toBe(200);
+ return (await res.json()) as Operation;
+}
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tsconfig.json
new file mode 100644
index 000000000000..40863136da68
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["es2022"],
+ "module": "es2022",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "checkJs": false,
+ "noEmit": true,
+ "isolatedModules": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "types": ["@cloudflare/workers-types"]
+ },
+ "exclude": ["tests"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/vite.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/vite.config.mts
new file mode 100644
index 000000000000..005f4448f6cb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/vite.config.mts
@@ -0,0 +1,7 @@
+import { cloudflare } from '@cloudflare/vite-plugin';
+import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [cloudflare(), sentryCloudflareVitePlugin()],
+});
diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/wrangler.toml
new file mode 100644
index 000000000000..1b2207bd862f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/wrangler.toml
@@ -0,0 +1,18 @@
+#:schema node_modules/wrangler/config-schema.json
+name = "cloudflare-pi-durable"
+main = "src/index.ts"
+compatibility_date = "2026-09-15"
+compatibility_flags = ["nodejs_compat"]
+
+# `vite preview` takes no `--var` flag, so the secrets are declared here and read from
+# the process environment the Playwright web server inherits.
+[secrets]
+required = ["E2E_TEST_DSN", "E2E_OPENROUTER_API_KEY"]
+
+[[durable_objects.bindings]]
+name = "Assistant"
+class_name = "Assistant"
+
+[[migrations]]
+tag = "v1"
+new_sqlite_classes = ["Assistant"]
diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts
index 395f5263f286..d3ef5add987c 100644
--- a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts
+++ b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts
@@ -12,7 +12,7 @@ import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-in
await fetch(`${baseURL}/${route}`);
const span = await spanPromise;
- expect(span.name).toBe('http.server GET');
+ expect(span.name).toBe('GET');
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.effect');
});
});
@@ -30,7 +30,7 @@ test('Sends a manual Effect span', async ({ baseURL }) => {
const spans = await spansPromise;
const segment = spans.find(span => span.is_segment)!;
const children = spans.filter(span => !span.is_segment);
- expect(segment.name).toBe('http.server GET');
+ expect(segment.name).toBe('GET');
expect(children).toHaveLength(1);
expect(children[0]).toMatchObject({ name: 'test-span', parent_span_id: segment.span_id });
});
@@ -49,7 +49,7 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL
const spans = await spansPromise;
const segment = spans.find(span => span.is_segment)!;
const children = spans.filter(span => !span.is_segment);
- expect(segment.name).toBe('http.server GET');
+ expect(segment.name).toBe('GET');
expect(segment.attributes['sentry.origin']?.value).toBe('auto.http.effect');
expect(segment.attributes['sentry.sdk.name']?.value).toBe('sentry.javascript.effect');
expect(children).toHaveLength(2);
@@ -83,7 +83,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU
const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]);
const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!;
- expect(segment.name).toBe('http.server GET');
+ expect(segment.name).toBe('GET');
expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']);
expect(detachedSpan.is_segment).toBe(true);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/.gitignore
deleted file mode 100644
index dd146b53d966..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/.gitignore
+++ /dev/null
@@ -1,44 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.*
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/versions
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-
-# env files (can opt-in for committing if needed)
-.env*
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
-
-# Sentry Config File
-.env.sentry-build-plugin
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/layout.tsx
deleted file mode 100644
index ace0c2f086b7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default function Layout({ children }: PropsWithChildren<{}>) {
- return (
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx
deleted file mode 100644
index dbdc60adadc2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default function Layout({ children }: PropsWithChildren<{}>) {
- return (
-
-
DynamicLayout
- {children}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/[dynamic]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/[dynamic]/page.tsx
deleted file mode 100644
index 3eaddda2a1df..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/[dynamic]/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- return (
-
- );
-}
-
-export async function generateMetadata() {
- return {
- title: 'I am dynamic page generated metadata',
- };
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/layout.tsx
deleted file mode 100644
index ace0c2f086b7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default function Layout({ children }: PropsWithChildren<{}>) {
- return (
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/page.tsx
deleted file mode 100644
index 8077c14d23ca..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/(nested-layout)/nested-layout/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default function Page() {
- return Hello World!
;
-}
-
-export async function generateMetadata() {
- return {
- title: 'I am generated metadata',
- };
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/api/endpoint-behind-middleware/route.ts
deleted file mode 100644
index 2733cc918f44..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/api/endpoint-behind-middleware/route.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function GET() {
- return Response.json({ name: 'John Doe' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/global-error.tsx
deleted file mode 100644
index 20c175015b03..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/global-error.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-'use client';
-
-import * as Sentry from '@sentry/nextjs';
-import NextError from 'next/error';
-import { useEffect } from 'react';
-
-export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
- useEffect(() => {
- Sentry.captureException(error);
- }, [error]);
-
- return (
-
-
- {/* `NextError` is the default Next.js error page component. Its type
- definition requires a `statusCode` prop. However, since the App Router
- does not expose status codes for errors, we simply pass 0 to render a
- generic error message. */}
-
-
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/layout.tsx
deleted file mode 100644
index c8f9cee0b787..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/layout.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function Layout({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/nested-rsc-error/[param]/page.tsx
deleted file mode 100644
index 675b248026be..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/nested-rsc-error/[param]/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Suspense } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- return (
- Loading...
}>
- {/* @ts-ignore */}
- ;
-
- );
-}
-
-async function Crash() {
- throw new Error('I am technically uncatchable');
- return unreachable
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/page.tsx
deleted file mode 100644
index 3204f4df69e1..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function Page() {
- return Next 16 Bun test app
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/pageload-tracing/layout.tsx
deleted file mode 100644
index 1f0cbe478f88..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/pageload-tracing/layout.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default async function Layout({ children }: PropsWithChildren) {
- await new Promise(resolve => setTimeout(resolve, 500));
- return <>{children}>;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/pageload-tracing/page.tsx
deleted file mode 100644
index 689735d61ddf..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/pageload-tracing/page.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- await new Promise(resolve => setTimeout(resolve, 1000));
- return I am page 2
;
-}
-
-export async function generateMetadata() {
- (await fetch('https://example.com/', { cache: 'no-store' })).text();
-
- return {
- title: 'my title',
- };
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/beep/[two]/page.tsx
deleted file mode 100644
index f34461c2bb07..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/beep/[two]/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function ParameterizedPage() {
- return Dynamic page two
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/beep/page.tsx
deleted file mode 100644
index a7d9164c8c03..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/beep/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function BeepPage() {
- return Beep
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/page.tsx
deleted file mode 100644
index 9fa617a22381..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/[one]/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function ParameterizedPage() {
- return Dynamic page one
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/static/page.tsx
deleted file mode 100644
index 16ef0482d53b..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/parameterized/static/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function StaticPage() {
- return Static page
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/route-handler/[xoxo]/edge/route.ts
deleted file mode 100644
index 7cd1fc7e332c..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/route-handler/[xoxo]/edge/route.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { NextResponse } from 'next/server';
-
-export const runtime = 'edge';
-export const dynamic = 'force-dynamic';
-
-export async function GET() {
- return NextResponse.json({ message: 'Hello Edge Route Handler' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/route-handler/[xoxo]/node/route.ts
deleted file mode 100644
index 5bc418f077aa..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/route-handler/[xoxo]/node/route.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { NextResponse } from 'next/server';
-
-export const dynamic = 'force-dynamic';
-
-export async function GET() {
- return NextResponse.json({ message: 'Hello Node Route Handler' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/streaming-rsc-error/[param]/client-page.tsx
deleted file mode 100644
index 7b66c3fbdeef..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/streaming-rsc-error/[param]/client-page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-'use client';
-
-import { use } from 'react';
-
-export function RenderPromise({ stringPromise }: { stringPromise: Promise }) {
- const s = use(stringPromise);
- return <>{s}>;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/streaming-rsc-error/[param]/page.tsx
deleted file mode 100644
index 9531f9a42139..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/streaming-rsc-error/[param]/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Suspense } from 'react';
-import { RenderPromise } from './client-page';
-
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- const crashingPromise = new Promise((_, reject) => {
- setTimeout(() => {
- reject(new Error('I am a data streaming error'));
- }, 100);
- });
-
- return (
- Loading...}>
- ;
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/third-party-filter/page.tsx
deleted file mode 100644
index b6b4bea80def..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/third-party-filter/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-'use client';
-
-import * as Sentry from '@sentry/nextjs';
-
-function throwFirstPartyError(): void {
- throw new Error('first-party-error');
-}
-
-export default function Page() {
- return (
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts
deleted file mode 100644
index 9c0366b0cea2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
- tunnel: `http://localhost:3031/`, // proxy server
- tracesSampleRate: 1.0,
- integrations: [
- Sentry.thirdPartyErrorFilterIntegration({
- filterKeys: ['nextjs-16-bun-e2e'],
- behaviour: 'apply-tag-if-exclusively-contains-third-party-frames',
- }),
- ],
-});
-
-export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation.ts
deleted file mode 100644
index 964f937c439a..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-
-export async function register() {
- if (process.env.NEXT_RUNTIME === 'nodejs') {
- await import('./sentry.server.config');
- }
-
- if (process.env.NEXT_RUNTIME === 'edge') {
- await import('./sentry.edge.config');
- }
-}
-
-export const onRequestError = Sentry.captureRequestError;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/next.config.ts
deleted file mode 100644
index 34d17391df28..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/next.config.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { withSentryConfig } from '@sentry/nextjs/config';
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {};
-
-export default withSentryConfig(nextConfig, {
- silent: true,
- applicationKey: 'nextjs-16-bun-e2e',
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json
deleted file mode 100644
index 6b510830e98e..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "name": "nextjs-16-bun",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "dev": "bun --bun next dev",
- "build": "bun --bun next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
- "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
- "start": "bun --bun next start",
- "test:prod": "TEST_ENV=production playwright test",
- "test:build": "pnpm install && pnpm build",
- "test:assert": "pnpm test:prod"
- },
- "dependencies": {
- "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
- "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz",
- "@sentry/core": "file:../../packed/sentry-core-packed.tgz",
- "import-in-the-middle": "^2",
- "next": "16.2.11",
- "react": "19.1.0",
- "react-dom": "19.1.0",
- "require-in-the-middle": "^8"
- },
- "devDependencies": {
- "@playwright/test": "~1.63.0",
- "@sentry-internal/test-utils": "link:../../../test-utils",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "typescript": "^5"
- },
- "volta": {
- "extends": "../../package.json"
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/playwright.config.mjs
deleted file mode 100644
index 28662eab2d7b..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/playwright.config.mjs
+++ /dev/null
@@ -1,25 +0,0 @@
-import { getPlaywrightConfig } from '@sentry-internal/test-utils';
-const testEnv = process.env.TEST_ENV;
-
-if (!testEnv) {
- throw new Error('No test env defined');
-}
-
-const getStartCommand = () => {
- if (testEnv === 'development') {
- return 'bun --bun next dev -p 3030 2>&1 | tee .tmp_dev_server_logs';
- }
-
- if (testEnv === 'production') {
- return 'bun --bun next start -p 3030';
- }
-
- throw new Error(`Unknown test env: ${testEnv}`);
-};
-
-const config = getPlaywrightConfig({
- startCommand: getStartCommand(),
- port: 3030,
-});
-
-export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/proxy.ts
deleted file mode 100644
index e2d263c4d80c..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/proxy.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { getDefaultIsolationScope } from '@sentry/core';
-import * as Sentry from '@sentry/nextjs';
-import { NextResponse } from 'next/server';
-import type { NextRequest } from 'next/server';
-
-export async function proxy(request: NextRequest) {
- Sentry.setTag('my-isolated-tag', true);
- Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope
-
- // Streamed spans carry no scope tags, so the tests read the isolation state from this attribute instead
- const activeSpan = Sentry.getActiveSpan();
- if (activeSpan) {
- Sentry.getRootSpan(activeSpan).setAttribute(
- 'isolation_scope.is_default',
- Sentry.getIsolationScope() === getDefaultIsolationScope(),
- );
- }
-
- if (request.headers.has('x-should-throw')) {
- throw new Error('Middleware Error');
- }
-
- if (request.headers.has('x-should-make-request')) {
- await fetch('http://localhost:3030/');
- }
-
- return NextResponse.next();
-}
-
-// See "Matching Paths" below to learn more
-export const config = {
- matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'],
-};
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.edge.config.ts
deleted file mode 100644
index ad780407a5b7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.edge.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
- tunnel: `http://localhost:3031/`, // proxy server
- tracesSampleRate: 1.0,
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts
deleted file mode 100644
index e683c5362818..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { bunHttpServerIntegration } from '@sentry/bun';
-import * as Sentry from '@sentry/nextjs';
-import { bunServerIntegration, fetchIntegration } from '@sentry/bun';
-
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
- tunnel: `http://localhost:3031/`, // proxy server
- tracesSampleRate: 1.0,
- tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'],
- integrations: [
- // Bun does not emit the `node:http` diagnostics channel the Node SDK uses to isolate incoming
- // requests, so each request would otherwise share one trace. Next.js emits its own server spans,
- // hence `spans: false` — this only isolates the request and resets its trace.
- bunHttpServerIntegration({ spans: false }),
- // Adding bun-specific integration here
- fetchIntegration(),
- ],
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/start-event-proxy.mjs
deleted file mode 100644
index d56d5fb74e59..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/start-event-proxy.mjs
+++ /dev/null
@@ -1,14 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-import { startEventProxyServer } from '@sentry-internal/test-utils';
-
-const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));
-
-startEventProxyServer({
- port: 3031,
- proxyServerName: 'nextjs-16-bun',
- envelopeDumpPath: path.join(
- process.cwd(),
- `event-dumps/nextjs-16-bun-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
- ),
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts
deleted file mode 100644
index 2b56d36de0ce..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('Should create a span for middleware', async ({ request }) => {
- const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === 'middleware GET' && span.is_segment;
- });
-
- const response = await request.get('/api/endpoint-behind-middleware');
- expect(await response.json()).toStrictEqual({ name: 'John Doe' });
-
- const middlewareSpan = await middlewareSpanPromise;
-
- expect(middlewareSpan.status).toBe('ok');
- expect(getSpanOp(middlewareSpan)).toBe('middleware');
- expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
-
- // Assert that isolation scope works properly
- expect(middlewareSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/nested-rsc-error.test.ts
deleted file mode 100644
index 7ca749c0b181..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/nested-rsc-error.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
- page,
-}) => {
- const errorEventPromise = waitForError('nextjs-16-bun', errorEvent => {
- return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable');
- });
-
- // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation.
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-bun', async span => {
- return (
- span.name === 'GET /nested-rsc-error/[param]' &&
- span.is_segment &&
- (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id
- );
- });
-
- await page.goto(`/nested-rsc-error/123`);
- const errorEvent = await errorEventPromise;
- const serverSpan = await serverSpanPromise;
-
- // error event is part of the same trace as the server span
- expect(errorEvent.contexts?.trace?.trace_id).toBe(serverSpan.trace_id);
-
- expect(errorEvent.request).toMatchObject({
- headers: expect.any(Object),
- method: 'GET',
- });
-
- expect(errorEvent.contexts?.nextjs).toEqual({
- route_type: 'render',
- router_kind: 'App Router',
- router_path: '/nested-rsc-error/[param]',
- request_path: '/nested-rsc-error/123',
- });
-
- expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
- handled: false,
- type: 'auto.function.nextjs.on_request_error',
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/pageload-tracing.test.ts
deleted file mode 100644
index 89ac76eadc1c..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/pageload-tracing.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('App router spans should be attached to the pageload request span', async ({ page }) => {
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === 'GET /pageload-tracing' && span.is_segment;
- });
-
- const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/pageload-tracing`);
-
- const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]);
-
- expect(pageloadSpan.trace_id).toBeTruthy();
- expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id);
-});
-
-// Bun runtime does not populate HTTP request headers as span attributes
-// because the OTel HTTP instrumentation does not extract headers when running on Bun.
-// This is a known behavioral difference from Node.js.
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts
deleted file mode 100644
index 27afcc6c3cf3..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('should create a parameterized pageload span when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/cappuccino`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- });
-});
-
-test('should create a span named after the static route when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/static`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- 'url.template': { value: '/parameterized/static', type: 'string' },
- });
-});
-
-test('should create a partially parameterized pageload span when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/cappuccino/beep`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- });
-});
-
-test('should create a nested parameterized pageload span when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/cappuccino/beep/espresso`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts
deleted file mode 100644
index da51193cb345..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import test, { expect } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('Should create a span for node route handlers', async ({ request }) => {
- const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
- return span.name === 'GET /route-handler/[xoxo]/node' && span.is_segment;
- });
-
- const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } });
- expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' });
-
- const routehandlerSpan = await routehandlerSpanPromise;
-
- expect(routehandlerSpan.status).toBe('ok');
- expect(getSpanOp(routehandlerSpan)).toBe('http.server');
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts
deleted file mode 100644
index c674845c4234..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils';
-
-// Next.js emits these spans itself. The SDK attaches no op, description or function name to
-// them, so asserting `undefined` pins that they stay untouched.
-const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined };
-
-test('Sends a span for a request to app router with URL', async ({ page }) => {
- const spansPromise = collectStreamedSpansUntilSegment(
- 'nextjs-16-bun',
- span =>
- span.name === 'GET /parameterized/[one]/beep/[two]' &&
- String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'),
- );
-
- await page.goto('/parameterized/1337/beep/42');
-
- const spans = await spansPromise;
- const segmentSpan = spans.find(
- span =>
- span.name === 'GET /parameterized/[one]/beep/[two]' &&
- span.is_segment &&
- String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'),
- )!;
-
- expect(segmentSpan.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(segmentSpan.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(segmentSpan.status).toBe('ok');
- expect(segmentSpan.attributes).toMatchObject({
- 'sentry.op': { value: 'http.server', type: 'string' },
- 'sentry.origin': { value: 'auto', type: 'string' },
- 'sentry.sample_rate': { value: 1, type: 'integer' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'http.method': { value: 'GET', type: 'string' },
- 'http.response.status_code': { value: 200, type: 'integer' },
- 'http.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' },
- 'http.status_code': { value: 200, type: 'integer' },
- 'http.target': { value: '/parameterized/1337/beep/42', type: 'string' },
- 'sentry.kind': { value: 'server', type: 'string' },
- 'next.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' },
- });
-
- // No child span should share the segment span's name
- expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0);
-});
-
-test('Will create spans for every server component and metadata generation functions when visiting a page', async ({
- page,
-}) => {
- const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-bun', 'GET /nested-layout');
-
- await page.goto('/nested-layout');
-
- const fullSpans = await spansPromise;
- const spans = fullSpans.map(span => ({
- name: span.name,
- op: getSpanOp(span),
- description: span.attributes['sentry.description']?.value,
- codeFunctionName: span.attributes['code.function.name']?.value,
- }));
-
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' });
- // Server component spans: the name is the low-cardinality `code.function.name`, and the
- // segment each one resolved for is on the description.
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve root layout server component',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "(nested-layout)"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "nested-layout"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Page',
- op: 'function',
- description: 'resolve page server component "/nested-layout"',
- codeFunctionName: 'Page',
- });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' });
-});
-
-test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({
- page,
-}) => {
- const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-bun', 'GET /nested-layout/[dynamic]');
-
- await page.goto('/nested-layout/123');
-
- const fullSpans = await spansPromise;
- const spans = fullSpans.map(span => ({
- name: span.name,
- op: getSpanOp(span),
- description: span.attributes['sentry.description']?.value,
- codeFunctionName: span.attributes['code.function.name']?.value,
- }));
-
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' });
- // Server component spans: the name is the low-cardinality `code.function.name`, and the
- // segment each one resolved for is on the description.
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve root layout server component',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "(nested-layout)"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "nested-layout"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "[dynamic]"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Page',
- op: 'function',
- description: 'resolve page server component "/nested-layout/[dynamic]"',
- codeFunctionName: 'Page',
- });
- expect(spans).toContainEqual({
- ...nextjsSpan,
- name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page',
- });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/streaming-rsc-error.test.ts
deleted file mode 100644
index 7b5482d4d8e2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/streaming-rsc-error.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
- page,
-}) => {
- const errorEventPromise = waitForError('nextjs-16-bun', errorEvent => {
- return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error');
- });
-
- // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation.
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-bun', async span => {
- return (
- span.name === 'GET /streaming-rsc-error/[param]' &&
- span.is_segment &&
- (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id
- );
- });
-
- // The streaming RSC error can interrupt the HTTP response, causing the navigation to reject
- // (e.g. net::ERR_ABORTED) even though the error and transaction are still captured.
- await page.goto(`/streaming-rsc-error/123`).catch(() => {});
- const errorEvent = await errorEventPromise;
- const serverSpan = await serverSpanPromise;
-
- // error event is part of the same trace as the server span
- expect(errorEvent.contexts?.trace?.trace_id).toBe(serverSpan.trace_id);
-
- expect(errorEvent.request).toMatchObject({
- headers: expect.any(Object),
- method: 'GET',
- });
-
- expect(errorEvent.contexts?.nextjs).toEqual({
- route_type: 'render',
- router_kind: 'App Router',
- router_path: '/streaming-rsc-error/[param]',
- request_path: '/streaming-rsc-error/123',
- });
-
- expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
- handled: false,
- type: 'auto.function.nextjs.on_request_error',
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/third-party-filter.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/third-party-filter.test.ts
deleted file mode 100644
index 4de1433597b5..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/third-party-filter.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import test, { expect } from '@playwright/test';
-import { waitForError } from '@sentry-internal/test-utils';
-
-const isWebpackDev = process.env.TEST_ENV === 'development-webpack';
-
-test('First-party error should not be tagged as third-party with exclusively-contains mode', async ({ page }) => {
- test.skip(isWebpackDev, 'Only relevant for Turbopack builds');
-
- const errorPromise = waitForError('nextjs-16-bun', errorEvent => {
- return errorEvent?.exception?.values?.some(value => value.value === 'first-party-error') ?? false;
- });
-
- await page.goto('/third-party-filter');
- await page.locator('#first-party-error-btn').click();
-
- const errorEvent = await errorPromise;
-
- expect(errorEvent.exception?.values?.[0]?.value).toBe('first-party-error');
-
- // In production, TEST_ENV=production is shared by both turbopack and webpack variants.
- // Only assert when the build is actually turbopack.
- if (errorEvent.tags?.turbopack) {
- // The integration uses `apply-tag-if-exclusively-contains-third-party-frames` which
- // only tags errors if ALL frames are third-party. A first-party error with React frames
- // should not be tagged because the first-party frames have metadata.
- expect(errorEvent.tags?.third_party_code).toBeUndefined();
- }
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tsconfig.json
deleted file mode 100644
index cc9ed39b5aa2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tsconfig.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
- "exclude": ["node_modules"]
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/.gitignore
deleted file mode 100644
index dd146b53d966..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/.gitignore
+++ /dev/null
@@ -1,44 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.*
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/versions
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-
-# env files (can opt-in for committing if needed)
-.env*
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
-
-# Sentry Config File
-.env.sentry-build-plugin
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/layout.tsx
deleted file mode 100644
index ace0c2f086b7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default function Layout({ children }: PropsWithChildren<{}>) {
- return (
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx
deleted file mode 100644
index dbdc60adadc2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default function Layout({ children }: PropsWithChildren<{}>) {
- return (
-
-
DynamicLayout
- {children}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/[dynamic]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/[dynamic]/page.tsx
deleted file mode 100644
index 3eaddda2a1df..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/[dynamic]/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- return (
-
- );
-}
-
-export async function generateMetadata() {
- return {
- title: 'I am dynamic page generated metadata',
- };
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/layout.tsx
deleted file mode 100644
index ace0c2f086b7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default function Layout({ children }: PropsWithChildren<{}>) {
- return (
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/page.tsx
deleted file mode 100644
index 8077c14d23ca..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/(nested-layout)/nested-layout/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default function Page() {
- return Hello World!
;
-}
-
-export async function generateMetadata() {
- return {
- title: 'I am generated metadata',
- };
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/endpoint-behind-middleware/route.ts
deleted file mode 100644
index 2733cc918f44..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/endpoint-behind-middleware/route.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function GET() {
- return Response.json({ name: 'John Doe' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts
deleted file mode 100644
index 26d85db4ac28..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-success/route.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { NextResponse } from 'next/server';
-
-export const dynamic = 'force-dynamic';
-
-export async function GET() {
- return NextResponse.json({ success: true });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/favicon.ico
deleted file mode 100644
index 718d6fea4835..000000000000
Binary files a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/favicon.ico and /dev/null differ
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/global-error.tsx
deleted file mode 100644
index 20c175015b03..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/global-error.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-'use client';
-
-import * as Sentry from '@sentry/nextjs';
-import NextError from 'next/error';
-import { useEffect } from 'react';
-
-export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
- useEffect(() => {
- Sentry.captureException(error);
- }, [error]);
-
- return (
-
-
- {/* `NextError` is the default Next.js error page component. Its type
- definition requires a `statusCode` prop. However, since the App Router
- does not expose status codes for errors, we simply pass 0 to render a
- generic error message. */}
-
-
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/isr-test/[product]/page.tsx
deleted file mode 100644
index cd1e085e2763..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/isr-test/[product]/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-export const revalidate = 60; // ISR: revalidate every 60 seconds
-export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams
-
-export async function generateStaticParams(): Promise> {
- return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }];
-}
-
-export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) {
- const { product } = await params;
-
- return (
-
-
ISR Product: {product}
-
{product}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/isr-test/static/page.tsx
deleted file mode 100644
index f49605bd9da4..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/isr-test/static/page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-export const revalidate = 60; // ISR: revalidate every 60 seconds
-export const dynamicParams = true;
-
-export async function generateStaticParams(): Promise {
- return [];
-}
-
-export default function ISRStaticPage() {
- return (
-
-
ISR Static Page
-
static-isr
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/layout.tsx
deleted file mode 100644
index c8f9cee0b787..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/layout.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function Layout({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/metrics/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/metrics/page.tsx
deleted file mode 100644
index fdb7bc0a40a7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/metrics/page.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-'use client';
-
-import * as Sentry from '@sentry/nextjs';
-
-export default function Page() {
- const handleClick = async () => {
- Sentry.metrics.count('test.page.count', 1, {
- attributes: {
- page: '/metrics',
- 'random.attribute': 'Apples',
- },
- });
- Sentry.metrics.distribution('test.page.distribution', 100, {
- attributes: {
- page: '/metrics',
- 'random.attribute': 'Manzanas',
- },
- });
- Sentry.metrics.gauge('test.page.gauge', 200, {
- attributes: {
- page: '/metrics',
- 'random.attribute': 'Mele',
- },
- });
- await fetch('/metrics/route-handler');
- };
-
- return (
-
-
Metrics page
-
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/metrics/route-handler/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/metrics/route-handler/route.ts
deleted file mode 100644
index 84e81960f9c9..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/metrics/route-handler/route.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-
-export const GET = async () => {
- Sentry.metrics.count('test.route.handler.count', 1, {
- attributes: {
- endpoint: '/metrics/route-handler',
- 'random.attribute': 'Potatoes',
- },
- });
- Sentry.metrics.distribution('test.route.handler.distribution', 100, {
- attributes: {
- endpoint: '/metrics/route-handler',
- 'random.attribute': 'Patatas',
- },
- });
- Sentry.metrics.gauge('test.route.handler.gauge', 200, {
- attributes: {
- endpoint: '/metrics/route-handler',
- 'random.attribute': 'Patate',
- },
- });
- return Response.json({ message: 'Bueno' });
-};
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/nested-rsc-error/[param]/page.tsx
deleted file mode 100644
index 675b248026be..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/nested-rsc-error/[param]/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Suspense } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- return (
- Loading...}>
- {/* @ts-ignore */}
- ;
-
- );
-}
-
-async function Crash() {
- throw new Error('I am technically uncatchable');
- return unreachable
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/non-isr-test/[item]/page.tsx
deleted file mode 100644
index e0bafdb24181..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/non-isr-test/[item]/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-// No generateStaticParams - this is NOT an ISR page
-export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) {
- const { item } = await params;
-
- return (
-
-
Non-ISR Dynamic Page: {item}
-
{item}
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/page.tsx
deleted file mode 100644
index 2bc0a407a355..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function Page() {
- return Next 16 test app
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/pageload-tracing/layout.tsx
deleted file mode 100644
index 1f0cbe478f88..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/pageload-tracing/layout.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { PropsWithChildren } from 'react';
-
-export const dynamic = 'force-dynamic';
-
-export default async function Layout({ children }: PropsWithChildren) {
- await new Promise(resolve => setTimeout(resolve, 500));
- return <>{children}>;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/pageload-tracing/page.tsx
deleted file mode 100644
index 689735d61ddf..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/pageload-tracing/page.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- await new Promise(resolve => setTimeout(resolve, 1000));
- return I am page 2
;
-}
-
-export async function generateMetadata() {
- (await fetch('https://example.com/', { cache: 'no-store' })).text();
-
- return {
- title: 'my title',
- };
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/beep/[two]/page.tsx
deleted file mode 100644
index f34461c2bb07..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/beep/[two]/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function ParameterizedPage() {
- return Dynamic page two
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/beep/page.tsx
deleted file mode 100644
index a7d9164c8c03..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/beep/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function BeepPage() {
- return Beep
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/page.tsx
deleted file mode 100644
index 9fa617a22381..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/[one]/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function ParameterizedPage() {
- return Dynamic page one
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/static/page.tsx
deleted file mode 100644
index 16ef0482d53b..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/parameterized/static/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function StaticPage() {
- return Static page
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/prefetching/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/prefetching/page.tsx
deleted file mode 100644
index 4cb811ecf1b4..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/prefetching/page.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import Link from 'next/link';
-
-export default function Page() {
- return (
-
- link
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/prefetching/to-be-prefetched/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/prefetching/to-be-prefetched/page.tsx
deleted file mode 100644
index 83aac90d65cf..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/prefetching/to-be-prefetched/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-export const dynamic = 'force-dynamic';
-
-export default function Page() {
- return Hello
;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/redirect/destination/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/redirect/destination/page.tsx
deleted file mode 100644
index 5583d36b04b0..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/redirect/destination/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function RedirectDestinationPage() {
- return (
-
-
Redirect Destination
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/redirect/origin/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/redirect/origin/page.tsx
deleted file mode 100644
index 52615e0a054b..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/redirect/origin/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { redirect } from 'next/navigation';
-
-async function redirectAction() {
- 'use server';
-
- redirect('/redirect/destination');
-}
-
-export default function RedirectOriginPage() {
- return (
- <>
- {/* @ts-ignore */}
-
- >
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/route-handler/[xoxo]/edge/route.ts
deleted file mode 100644
index 7cd1fc7e332c..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/route-handler/[xoxo]/edge/route.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { NextResponse } from 'next/server';
-
-export const runtime = 'edge';
-export const dynamic = 'force-dynamic';
-
-export async function GET() {
- return NextResponse.json({ message: 'Hello Edge Route Handler' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/route-handler/[xoxo]/node/route.ts
deleted file mode 100644
index 5bc418f077aa..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/route-handler/[xoxo]/node/route.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { NextResponse } from 'next/server';
-
-export const dynamic = 'force-dynamic';
-
-export async function GET() {
- return NextResponse.json({ message: 'Hello Node Route Handler' });
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/streaming-rsc-error/[param]/client-page.tsx
deleted file mode 100644
index 7b66c3fbdeef..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/streaming-rsc-error/[param]/client-page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-'use client';
-
-import { use } from 'react';
-
-export function RenderPromise({ stringPromise }: { stringPromise: Promise }) {
- const s = use(stringPromise);
- return <>{s}>;
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/streaming-rsc-error/[param]/page.tsx
deleted file mode 100644
index 9531f9a42139..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/streaming-rsc-error/[param]/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Suspense } from 'react';
-import { RenderPromise } from './client-page';
-
-export const dynamic = 'force-dynamic';
-
-export default async function Page() {
- const crashingPromise = new Promise((_, reject) => {
- setTimeout(() => {
- reject(new Error('I am a data streaming error'));
- }, 100);
- });
-
- return (
- Loading...}>
- ;
-
- );
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/eslint.config.mjs
deleted file mode 100644
index 60f7af38f6c2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/eslint.config.mjs
+++ /dev/null
@@ -1,19 +0,0 @@
-import { dirname } from 'path';
-import { fileURLToPath } from 'url';
-import { FlatCompat } from '@eslint/eslintrc';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-const compat = new FlatCompat({
- baseDirectory: __dirname,
-});
-
-const eslintConfig = [
- ...compat.extends('next/core-web-vitals', 'next/typescript'),
- {
- ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
- },
-];
-
-export default eslintConfig;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation-client.ts
deleted file mode 100644
index a170a388c465..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation-client.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-import type { Log } from '@sentry/nextjs';
-
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
- tunnel: `http://localhost:3031/`, // proxy server
- tracesSampleRate: 1.0,
- // Verify Log type is available
- beforeSendLog(log: Log) {
- return log;
- },
-});
-
-export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation.ts
deleted file mode 100644
index 964f937c439a..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-
-export async function register() {
- if (process.env.NEXT_RUNTIME === 'nodejs') {
- await import('./sentry.server.config');
- }
-
- if (process.env.NEXT_RUNTIME === 'edge') {
- await import('./sentry.edge.config');
- }
-}
-
-export const onRequestError = Sentry.captureRequestError;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/middleware.ts
deleted file mode 100644
index a6d31cc8c66f..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/middleware.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { getDefaultIsolationScope } from '@sentry/core';
-import * as Sentry from '@sentry/nextjs';
-import { NextResponse } from 'next/server';
-import type { NextRequest } from 'next/server';
-
-export async function middleware(request: NextRequest) {
- Sentry.setTag('my-isolated-tag', true);
- Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope
-
- // Streamed spans carry no scope tags, so the tests read the isolation state from this attribute instead
- const activeSpan = Sentry.getActiveSpan();
- if (activeSpan) {
- Sentry.getRootSpan(activeSpan).setAttribute(
- 'isolation_scope.is_default',
- Sentry.getIsolationScope() === getDefaultIsolationScope(),
- );
- }
-
- if (request.headers.has('x-should-throw')) {
- throw new Error('Middleware Error');
- }
-
- if (request.headers.has('x-should-make-request')) {
- await fetch('http://localhost:3030/');
- }
-
- return NextResponse.next();
-}
-
-// See "Matching Paths" below to learn more
-export const config = {
- matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'],
-};
-
-export const runtime = 'experimental-edge';
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/next.config.ts
deleted file mode 100644
index 28162396e80e..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/next.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { withSentryConfig } from '@sentry/nextjs/config';
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {};
-
-export default withSentryConfig(nextConfig, {
- silent: true,
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json
deleted file mode 100644
index 2bd1b3f77857..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "name": "nextjs-16-cf-workers",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "cf:build": "opennextjs-cloudflare build",
- "cf:preview": "opennextjs-cloudflare preview",
- "build": "next build",
- "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
- "start": "pnpm cf:preview",
- "lint": "eslint",
- "test:prod": "TEST_ENV=production playwright test",
- "test:build": "pnpm install && pnpm cf:build",
- "test:build-canary": "pnpm install && pnpm add next@canary && pnpm cf:build",
- "test:build-latest": "pnpm install && pnpm add next@16.3 && pnpm cf:build",
- "test:assert": "pnpm test:prod"
- },
- "dependencies": {
- "@opennextjs/cloudflare": "^1.14.9",
- "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
- "@sentry/core": "file:../../packed/sentry-core-packed.tgz",
- "next": "16.2.11",
- "react": "19.1.0",
- "react-dom": "19.1.0"
- },
- "devDependencies": {
- "@playwright/test": "~1.63.0",
- "@sentry-internal/test-utils": "link:../../../test-utils",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "eslint": "^9",
- "eslint-config-next": "^16",
- "typescript": "^5",
- "wrangler": "^4.61.0"
- },
- "volta": {
- "node": "24.15.0",
- "extends": "../../package.json"
- },
- "sentryTest": {
- "variants": [
- {
- "build-command": "pnpm test:build-latest",
- "label": "nextjs-16-cf-workers (latest)"
- }
- ]
- }
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/playwright.config.mjs
deleted file mode 100644
index 0f15639161dd..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/playwright.config.mjs
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getPlaywrightConfig } from '@sentry-internal/test-utils';
-const testEnv = process.env.TEST_ENV;
-
-if (!testEnv) {
- throw new Error('No test env defined');
-}
-
-const getStartCommand = () => {
- if (testEnv === 'production') {
- return 'pnpm cf:preview --port 3030';
- }
-
- throw new Error(`Unknown test env: ${testEnv}`);
-};
-
-const config = getPlaywrightConfig({
- startCommand: getStartCommand(),
- port: 3030,
-});
-
-export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/file.svg
deleted file mode 100644
index 004145cddf3f..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/globe.svg
deleted file mode 100644
index 567f17b0d7c7..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/next.svg
deleted file mode 100644
index 5174b28c565c..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/vercel.svg
deleted file mode 100644
index 77053960334e..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/window.svg
deleted file mode 100644
index b2b2a44f6ebc..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts
deleted file mode 100644
index d6aeeac195c8..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
- tunnel: `http://localhost:3031/`, // proxy server
- tracesSampler: samplingContext => {
- if (samplingContext.attributes?.['next.span_type'] === 'Middleware.execute') {
- // Only keep the middleware transaction when `normalizedRequest` was available at sampling time.
- // Test times out and fails when transaction is dropped
- const { normalizedRequest } = samplingContext;
- return Boolean(normalizedRequest?.url && normalizedRequest?.method);
- }
-
- return 1.0;
- },
- // debug: true,
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts
deleted file mode 100644
index f55707dea51a..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import * as Sentry from '@sentry/nextjs';
-import { Log } from '@sentry/nextjs';
-
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
- tunnel: `http://localhost:3031/`, // proxy server
- tracesSampleRate: 1.0,
- // debug: true,
- integrations: [Sentry.vercelAIIntegration()],
- // Verify Log type is available
- beforeSendLog(log: Log) {
- return log;
- },
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/start-event-proxy.mjs
deleted file mode 100644
index efb664370443..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/start-event-proxy.mjs
+++ /dev/null
@@ -1,14 +0,0 @@
-import * as fs from 'fs';
-import * as path from 'path';
-import { startEventProxyServer } from '@sentry-internal/test-utils';
-
-const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));
-
-startEventProxyServer({
- port: 3031,
- proxyServerName: 'nextjs-16-cf-workers',
- envelopeDumpPath: path.join(
- process.cwd(),
- `event-dumps/next-16-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
- ),
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/async-params.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/async-params.test.ts
deleted file mode 100644
index e8160d12aded..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/async-params.test.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { expect, test } from '@playwright/test';
-import fs from 'fs';
-import { isDevMode } from './isDevMode';
-
-test('should not print warning for async params', async ({ page }) => {
- test.skip(!isDevMode, 'should be skipped for non-dev mode');
- await page.goto('/');
-
- // If the server exits with code 1, the test will fail (see instrumentation.ts)
- const devStdout = fs.readFileSync('.tmp_dev_server_logs', 'utf-8');
- expect(devStdout).not.toContain('`params` should be awaited before using its properties.');
-
- await expect(page.getByText('Next 16 test app')).toBeVisible();
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isDevMode.ts
deleted file mode 100644
index d2be94232110..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isDevMode.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development');
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts
deleted file mode 100644
index fe61a6263913..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => {
- // Navigate to ISR page
- await page.goto('/isr-test/laptop');
-
- // Wait for page to be fully loaded
- await expect(page.locator('#isr-product-id')).toHaveText('laptop');
-
- // Check that sentry-trace and baggage meta tags are removed for ISR pages
- await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
- await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
-});
-
-test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => {
- // Navigate to ISR static page
- await page.goto('/isr-test/static');
-
- // Wait for page to be fully loaded
- await expect(page.locator('#isr-static-marker')).toHaveText('static-isr');
-
- // Check that sentry-trace and baggage meta tags are removed for ISR pages
- await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
- await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
-});
-
-test('should remove meta tags for different ISR dynamic route values', async ({ page }) => {
- // Test with 'phone' (one of the pre-generated static params)
- await page.goto('/isr-test/phone');
- await expect(page.locator('#isr-product-id')).toHaveText('phone');
-
- await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
- await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
-
- // Test with 'tablet'
- await page.goto('/isr-test/tablet');
- await expect(page.locator('#isr-product-id')).toHaveText('tablet');
-
- await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0);
- await expect(page.locator('meta[name="baggage"]')).toHaveCount(0);
-});
-
-test('should create unique traces for ISR pages on each visit', async ({ page }) => {
- const traceIds: string[] = [];
-
- // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed
- for (let i = 0; i < 5; i++) {
- const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- if (i === 0) {
- await page.goto('/isr-test/laptop');
- } else {
- await page.reload();
- }
-
- const span = await spanPromise;
- const traceId = span.trace_id;
-
- expect(traceId).toBeDefined();
- expect(traceId).toMatch(/[a-f0-9]{32}/);
- traceIds.push(traceId!);
- }
-
- // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags)
- const uniqueTraceIds = new Set(traceIds);
- expect(uniqueTraceIds.size).toBe(5);
-});
-
-test('ISR route should be identified correctly in the route manifest', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto('/isr-test/laptop');
- const span = await spanPromise;
-
- // Verify the span is properly parameterized
- expect(span.name).toBe('/isr-test/:product');
- expect(span.attributes['sentry.segment.name.source']?.value).toBe('route');
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts
deleted file mode 100644
index 6569c3d21890..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/metrics.test.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForMetric } from '@sentry-internal/test-utils';
-
-// Metrics are not currently supported on Cloudflare Workers
-// TODO: Investigate and enable when metrics support is added for CF Workers
-test.skip('Should emit metrics from server and client', async ({ request, page }) => {
- const clientCountPromise = waitForMetric('nextjs-16-cf-workers', async metric => {
- return metric.name === 'test.page.count';
- });
-
- const clientDistributionPromise = waitForMetric('nextjs-16-cf-workers', async metric => {
- return metric.name === 'test.page.distribution';
- });
-
- const clientGaugePromise = waitForMetric('nextjs-16-cf-workers', async metric => {
- return metric.name === 'test.page.gauge';
- });
-
- const serverCountPromise = waitForMetric('nextjs-16-cf-workers', async metric => {
- return metric.name === 'test.route.handler.count';
- });
-
- const serverDistributionPromise = waitForMetric('nextjs-16-cf-workers', async metric => {
- return metric.name === 'test.route.handler.distribution';
- });
-
- const serverGaugePromise = waitForMetric('nextjs-16-cf-workers', async metric => {
- return metric.name === 'test.route.handler.gauge';
- });
-
- await page.goto('/metrics');
- await page.getByText('Emit').click();
- const clientCount = await clientCountPromise;
- const clientDistribution = await clientDistributionPromise;
- const clientGauge = await clientGaugePromise;
- const serverCount = await serverCountPromise;
- const serverDistribution = await serverDistributionPromise;
- const serverGauge = await serverGaugePromise;
-
- expect(clientCount).toMatchObject({
- timestamp: expect.any(Number),
- trace_id: expect.any(String),
- span_id: expect.any(String),
- name: 'test.page.count',
- type: 'counter',
- value: 1,
- attributes: {
- page: { value: '/metrics', type: 'string' },
- 'random.attribute': { value: 'Apples', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' },
- 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
- },
- });
-
- expect(clientDistribution).toMatchObject({
- timestamp: expect.any(Number),
- trace_id: expect.any(String),
- span_id: expect.any(String),
- name: 'test.page.distribution',
- type: 'distribution',
- value: 100,
- attributes: {
- page: { value: '/metrics', type: 'string' },
- 'random.attribute': { value: 'Manzanas', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' },
- 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
- },
- });
-
- expect(clientGauge).toMatchObject({
- timestamp: expect.any(Number),
- trace_id: expect.any(String),
- span_id: expect.any(String),
- name: 'test.page.gauge',
- type: 'gauge',
- value: 200,
- attributes: {
- page: { value: '/metrics', type: 'string' },
- 'random.attribute': { value: 'Mele', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' },
- 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
- },
- });
-
- expect(serverCount).toMatchObject({
- timestamp: expect.any(Number),
- trace_id: expect.any(String),
- name: 'test.route.handler.count',
- type: 'counter',
- value: 1,
- attributes: {
- 'server.address': { value: expect.any(String), type: 'string' },
- 'random.attribute': { value: 'Potatoes', type: 'string' },
- endpoint: { value: '/metrics/route-handler', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' },
- 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
- },
- });
-
- expect(serverDistribution).toMatchObject({
- timestamp: expect.any(Number),
- trace_id: expect.any(String),
- name: 'test.route.handler.distribution',
- type: 'distribution',
- value: 100,
- attributes: {
- 'server.address': { value: expect.any(String), type: 'string' },
- 'random.attribute': { value: 'Patatas', type: 'string' },
- endpoint: { value: '/metrics/route-handler', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' },
- 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
- },
- });
-
- expect(serverGauge).toMatchObject({
- timestamp: expect.any(Number),
- trace_id: expect.any(String),
- name: 'test.route.handler.gauge',
- type: 'gauge',
- value: 200,
- attributes: {
- 'server.address': { value: expect.any(String), type: 'string' },
- 'random.attribute': { value: 'Patate', type: 'string' },
- endpoint: { value: '/metrics/route-handler', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' },
- 'sentry.sdk.version': { value: expect.any(String), type: 'string' },
- },
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts
deleted file mode 100644
index 78b27c8427d9..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-import { isDevMode } from './isDevMode';
-
-// TODO: Skipped until the Cloudflare Workers edge middleware setup emits middleware spans reliably.
-test.skip('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => {
- const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'middleware GET' && span.is_segment;
- });
-
- await request.get('/api/endpoint-behind-middleware');
-
- const middlewareSpan = await middlewareSpanPromise;
-
- expect(String(middlewareSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware');
- expect(middlewareSpan.attributes['http.request.method']?.value).toBe('GET');
-});
-
-// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
-test.skip('Should create a span for middleware', async ({ request }) => {
- const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'middleware GET' && span.is_segment;
- });
-
- const response = await request.get('/api/endpoint-behind-middleware');
- expect(await response.json()).toStrictEqual({ name: 'John Doe' });
-
- const middlewareSpan = await middlewareSpanPromise;
-
- expect(middlewareSpan.status).toBe('ok');
- expect(getSpanOp(middlewareSpan)).toBe('middleware');
- expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
-
- // Assert that isolation scope works properly
- // expect(middlewareSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' });
-});
-
-// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
-test.skip('Faulty middlewares', async ({ request }) => {
- test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261
- const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'middleware GET' && span.is_segment;
- });
-
- request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => {
- // Noop
- });
-
- await test.step('should record spans', async () => {
- const middlewareSpan = await middlewareSpanPromise;
- expect(middlewareSpan.status).toBe('error');
- expect(getSpanOp(middlewareSpan)).toBe('middleware');
- expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
- });
-});
-
-// TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime
-test.skip('Should trace outgoing fetch requests inside middleware', async ({ request }) => {
- test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm');
-
- // `http.client` span names are low cardinality under span streaming, hence `GET localhost` rather
- // than the full URL.
- const spansPromise = collectStreamedSpans('nextjs-16-cf-workers', spans =>
- spans.some(span => getSpanOp(span) === 'http.client' && span.name === 'GET localhost'),
- );
-
- request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => {
- // Noop
- });
-
- const spans = await spansPromise;
- const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client' && span.name === 'GET localhost')!;
-
- expect(fetchSpan.status).toBe('ok');
- expect(fetchSpan.attributes).toMatchObject({
- 'http.request.method': { value: 'GET', type: 'string' },
- 'http.response.status_code': { value: 200, type: 'integer' },
- 'url.full': { value: 'http://localhost:3030/', type: 'string' },
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts
deleted file mode 100644
index 509dc49cf364..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-// TODO: Flakey on CI
-test.skip('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
- page,
-}) => {
- const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => {
- return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable');
- });
-
- // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation.
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', async span => {
- return (
- span.name === 'GET /nested-rsc-error/[param]' &&
- span.is_segment &&
- (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id
- );
- });
-
- await page.goto(`/nested-rsc-error/123`);
- const errorEvent = await errorEventPromise;
- const serverSpan = await serverSpanPromise;
-
- // error event is part of the same trace as the server span
- expect(errorEvent.contexts?.trace?.trace_id).toBe(serverSpan.trace_id);
-
- expect(errorEvent.request).toMatchObject({
- headers: expect.any(Object),
- method: 'GET',
- });
-
- expect(errorEvent.contexts?.nextjs).toEqual({
- route_type: 'render',
- router_kind: 'App Router',
- router_path: '/nested-rsc-error/[param]',
- request_path: '/nested-rsc-error/123',
- });
-
- expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
- handled: false,
- type: 'auto.function.nextjs.on_request_error',
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts
deleted file mode 100644
index 19879c4e6853..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-// TODO: Flakey on CI
-test.skip('App router spans should be attached to the pageload request span', async ({ page }) => {
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'GET /pageload-tracing' && span.is_segment;
- });
-
- const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/pageload-tracing`);
-
- const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]);
-
- expect(pageloadSpan.trace_id).toBeTruthy();
- expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id);
-});
-
-// TODO: HTTP request headers are not extracted as span attributes on Cloudflare Workers
-test.skip('extracts HTTP request headers as span attributes', async ({ baseURL }) => {
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- const requestId = span.attributes['http.request.header.x-request-id'];
- return (
- span.name === 'GET /pageload-tracing' &&
- span.is_segment &&
- requestId?.type === 'array' &&
- requestId.value[0] === 'nextjs-789'
- );
- });
-
- await fetch(`${baseURL}/pageload-tracing`, {
- headers: {
- 'User-Agent': 'Custom-NextJS-Agent/15.0',
- 'Content-Type': 'text/html',
- 'X-NextJS-Test': 'nextjs-header-value',
- Accept: 'text/html, application/xhtml+xml',
- 'X-Framework': 'Next.js',
- 'X-Request-ID': 'nextjs-789',
- },
- });
-
- const serverSpan = await serverSpanPromise;
-
- expect(serverSpan.attributes).toMatchObject({
- 'http.request.header.user-agent': { value: ['Custom-NextJS-Agent/15.0'], type: 'array' },
- 'http.request.header.content-type': { value: ['text/html'], type: 'array' },
- 'http.request.header.x-nextjs-test': { value: ['nextjs-header-value'], type: 'array' },
- 'http.request.header.accept': { value: ['text/html, application/xhtml+xml'], type: 'array' },
- 'http.request.header.x-framework': { value: ['Next.js'], type: 'array' },
- 'http.request.header.x-request-id': { value: ['nextjs-789'], type: 'array' },
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts
deleted file mode 100644
index 777130edc5b9..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test('should create a parameterized pageload span when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/cappuccino`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- });
-});
-
-test('should create a span named after the static route when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/static`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- 'url.template': { value: '/parameterized/static', type: 'string' },
- });
-});
-
-test('should create a partially parameterized pageload span when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/cappuccino/beep`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- });
-});
-
-test('should create a nested parameterized pageload span when the `app` directory is used', async ({ page }) => {
- const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment;
- });
-
- await page.goto(`/parameterized/cappuccino/beep/espresso`);
-
- const span = await spanPromise;
-
- expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(span.attributes).toMatchObject({
- 'sentry.op': { value: 'pageload', type: 'string' },
- 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'sentry.environment': { value: 'qa', type: 'string' },
- 'react.version': { value: expect.any(String), type: 'string' },
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts
deleted file mode 100644
index 53cebde1ff21..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
-import { isDevMode } from './isDevMode';
-
-test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => {
- test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode");
-
- // The prefetch span is a child of the pageload segment span, which ends last.
- const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', '/prefetching');
-
- await page.goto(`/prefetching`);
-
- // Make it more likely that nextjs prefetches
- await page.hover('#prefetch-link');
-
- const spans = await spansPromise;
-
- expect(spans).toContainEqual(
- expect.objectContaining({
- attributes: expect.objectContaining({
- 'sentry.op': { value: 'http.client', type: 'string' },
- 'http.request.prefetch': { value: true, type: 'boolean' },
- }),
- }),
- );
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts
deleted file mode 100644
index 7623d1c29e31..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import test, { expect } from '@playwright/test';
-import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test.skip('Should create a span for node route handlers', async ({ request }) => {
- const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'GET /route-handler/[xoxo]/node' && span.is_segment;
- });
-
- const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } });
- expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' });
-
- const routehandlerSpan = await routehandlerSpanPromise;
-
- expect(routehandlerSpan.status).toBe('ok');
- expect(getSpanOp(routehandlerSpan)).toBe('http.server');
-
- // Custom headers are not captured on Cloudflare Workers
- // This assertion is skipped for CF Workers environment
-});
-
-test('Should create a span for edge route handlers', async ({ request }) => {
- // This test only works for webpack builds on non-async param extraction
- // todo: check if we can set request headers for edge on sdkProcessingMetadata
- test.skip();
- const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'GET /route-handler/[xoxo]/edge' && span.is_segment;
- });
-
- const response = await request.get('/route-handler/123/edge', { headers: { 'x-charly': 'gomez' } });
- expect(await response.json()).toStrictEqual({ message: 'Hello Edge Route Handler' });
-
- const routehandlerSpan = await routehandlerSpanPromise;
-
- expect(routehandlerSpan.status).toBe('ok');
- expect(getSpanOp(routehandlerSpan)).toBe('http.server');
- expect(routehandlerSpan.attributes['http.request.header.x-charly']?.value).toEqual(['gomez']);
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts
deleted file mode 100644
index 7d7f75e5eb7b..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-test.skip('Should handle server action redirect without capturing errors', async ({ page }) => {
- // Wait for the initial pageload span
- const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === '/redirect/origin' && span.is_segment;
- });
-
- // Navigate to the origin page
- await page.goto('/redirect/origin');
-
- const pageLoadSpan = await pageLoadSpanPromise;
- expect(pageLoadSpan).toBeDefined();
-
- // Wait for the redirect span
- const redirectSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => {
- return span.name === 'GET /redirect/destination' && span.is_segment;
- });
-
- // No error should be captured
- const redirectErrorPromise = waitForError('nextjs-16-cf-workers', async errorEvent => {
- return !!errorEvent;
- });
-
- // Click the redirect button
- await page.click('button[type="submit"]');
-
- await redirectSpanPromise;
-
- // Verify we got redirected to the destination page
- await expect(page).toHaveURL('/redirect/destination');
-
- // Wait for potential errors with a 2 second timeout
- const errorTimeout = new Promise((_, reject) =>
- setTimeout(() => reject(new Error('No error captured (timeout)')), 2000),
- );
-
- // We expect this to timeout since no error should be captured during the redirect
- try {
- await Promise.race([redirectErrorPromise, errorTimeout]);
- throw new Error('Expected no error to be captured, but an error was found');
- } catch (e) {
- // If we get a timeout error (as expected), no error was captured
- expect((e as Error).message).toBe('No error captured (timeout)');
- }
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts
deleted file mode 100644
index 7a34986062dc..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils';
-
-// Next.js emits these spans itself. The SDK attaches no op, description or function name to
-// them, so asserting `undefined` pins that they stay untouched.
-const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined };
-
-// TODO: Server component tests need SDK adjustments for Cloudflare Workers
-test.skip('Sends a span for a request to app router with URL', async ({ page }) => {
- const spansPromise = collectStreamedSpansUntilSegment(
- 'nextjs-16-cf-workers',
- span =>
- span.name === 'GET /parameterized/[one]/beep/[two]' &&
- String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'),
- );
-
- await page.goto('/parameterized/1337/beep/42');
-
- const spans = await spansPromise;
- const segmentSpan = spans.find(
- span =>
- span.name === 'GET /parameterized/[one]/beep/[two]' &&
- span.is_segment &&
- String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'),
- )!;
-
- expect(segmentSpan.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
- expect(segmentSpan.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
- expect(segmentSpan.status).toBe('ok');
- expect(segmentSpan.attributes).toMatchObject({
- 'sentry.op': { value: 'http.server', type: 'string' },
- 'sentry.origin': { value: 'auto', type: 'string' },
- 'sentry.sample_rate': { value: 1, type: 'integer' },
- 'sentry.segment.name.source': { value: 'route', type: 'string' },
- 'http.method': { value: 'GET', type: 'string' },
- 'http.response.status_code': { value: 200, type: 'integer' },
- 'http.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' },
- 'http.status_code': { value: 200, type: 'integer' },
- 'http.target': { value: '/parameterized/1337/beep/42', type: 'string' },
- 'sentry.kind': { value: 'server', type: 'string' },
- 'next.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' },
- });
-
- // No child span should share the segment span's name
- expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0);
-});
-
-// TODO: Server component span tests need SDK adjustments for Cloudflare Workers
-test.skip('Will create spans for every server component and metadata generation functions when visiting a page', async ({
- page,
-}) => {
- const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout');
-
- await page.goto('/nested-layout');
-
- const fullSpans = await spansPromise;
- const spans = fullSpans.map(span => ({
- name: span.name,
- op: getSpanOp(span),
- description: span.attributes['sentry.description']?.value,
- codeFunctionName: span.attributes['code.function.name']?.value,
- }));
-
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' });
- // Server component spans: the name is the low-cardinality `code.function.name`, and the
- // segment each one resolved for is on the description.
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve root layout server component',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "(nested-layout)"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "nested-layout"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Page',
- op: 'function',
- description: 'resolve page server component "/nested-layout"',
- codeFunctionName: 'Page',
- });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' });
-});
-
-// TODO: Server component span tests need SDK adjustments for Cloudflare Workers
-test.skip('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({
- page,
-}) => {
- const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout/[dynamic]');
-
- await page.goto('/nested-layout/123');
-
- const fullSpans = await spansPromise;
- const spans = fullSpans.map(span => ({
- name: span.name,
- op: getSpanOp(span),
- description: span.attributes['sentry.description']?.value,
- codeFunctionName: span.attributes['code.function.name']?.value,
- }));
-
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' });
- // Server component spans: the name is the low-cardinality `code.function.name`, and the
- // segment each one resolved for is on the description.
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve root layout server component',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "(nested-layout)"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "nested-layout"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Layout',
- op: 'function',
- description: 'resolve layout server component "[dynamic]"',
- codeFunctionName: 'Layout',
- });
- expect(spans).toContainEqual({
- name: 'Page',
- op: 'function',
- description: 'resolve page server component "/nested-layout/[dynamic]"',
- codeFunctionName: 'Page',
- });
- expect(spans).toContainEqual({
- ...nextjsSpan,
- name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page',
- });
- expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts
deleted file mode 100644
index 296d485e8644..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { expect, test } from '@playwright/test';
-import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
-
-// TODO: Under span streaming the Workers runtime emits no server segment span for a request
-// interrupted by a streaming RSC error, so the correlation below cannot be satisfied.
-// See https://github.com/getsentry/sentry-javascript/issues/23932
-test.skip('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
- page,
-}) => {
- const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => {
- return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error');
- });
-
- // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation.
- const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', async span => {
- return (
- span.name === 'GET /streaming-rsc-error/[param]' &&
- span.is_segment &&
- (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id
- );
- });
-
- // The streaming RSC error can interrupt the HTTP response, causing the navigation to reject
- // (e.g. net::ERR_ABORTED) even though the error and transaction are still captured.
- await page.goto(`/streaming-rsc-error/123`).catch(() => {});
- const errorEvent = await errorEventPromise;
- const serverSpan = await serverSpanPromise;
-
- // error event is part of the same trace as the server span
- expect(errorEvent.contexts?.trace?.trace_id).toBe(serverSpan.trace_id);
-
- expect(errorEvent.request).toMatchObject({
- headers: expect.any(Object),
- method: 'GET',
- });
-
- expect(errorEvent.contexts?.nextjs).toEqual({
- route_type: 'render',
- router_kind: 'App Router',
- router_path: '/streaming-rsc-error/[param]',
- request_path: '/streaming-rsc-error/123',
- });
-
- expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
- handled: false,
- type: 'auto.function.nextjs.on_request_error',
- });
-});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tsconfig.json
deleted file mode 100644
index cc9ed39b5aa2..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tsconfig.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
- "exclude": ["node_modules"]
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc
deleted file mode 100644
index 4bf131c387e9..000000000000
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/wrangler.jsonc
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * For more details on how to configure Wrangler, refer to:
- * https://developers.cloudflare.com/workers/wrangler/configuration/
- */
-/**
- * For more details on how to configure Wrangler, refer to:
- * https://developers.cloudflare.com/workers/wrangler/configuration/
- */
-{
- "$schema": "node_modules/wrangler/config-schema.json",
- "name": "next-cf",
- "main": ".open-next/worker.js",
- "compatibility_date": "2025-12-01",
- "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
- "assets": {
- "binding": "ASSETS",
- "directory": ".open-next/assets",
- },
- "images": {
- // Enable image optimization
- // see https://opennext.js.org/cloudflare/howtos/image
- "binding": "IMAGES",
- },
- "services": [
- {
- // Self-reference service binding, the service name must match the worker name
- // see https://opennext.js.org/cloudflare/caching
- "binding": "WORKER_SELF_REFERENCE",
- "service": "next-cf",
- },
- ],
- "observability": {
- "enabled": true,
- },
- /**
- * Smart Placement
- * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
- */
- // "placement": { "mode": "smart" }
- /**
- * Bindings
- * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
- * databases, object storage, AI inference, real-time communication and more.
- * https://developers.cloudflare.com/workers/runtime-apis/bindings/
- */
- /**
- * Environment Variables
- * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
- */
- // "vars": { "MY_VARIABLE": "production_value" }
- /**
- * Note: Use secrets to store sensitive data.
- * https://developers.cloudflare.com/workers/configuration/secrets/
- */
- /**
- * Static Assets
- * https://developers.cloudflare.com/workers/static-assets/binding/
- */
- // "assets": { "directory": "./public/", "binding": "ASSETS" }
- /**
- * Service Bindings (communicate between multiple Workers)
- * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
- */
- // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
-}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/ai-mock-server.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/ai-mock-server.mjs
index b97e0cb162dc..50060d37c309 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/ai-mock-server.mjs
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/ai-mock-server.mjs
@@ -24,11 +24,12 @@ function sendJson(res, status, obj) {
res.end(JSON.stringify(obj));
}
-let serverPromise;
+/** Port of the mock server. The Playwright global setup starts it, so the app reaches it on every runtime. */
+export const MOCK_AI_PORT = 3032;
-/** Lazily starts the shared mock server and resolves to its port. */
-export function getMockAiPort() {
- serverPromise ??= new Promise(resolve => {
+/** Starts the mock server on `MOCK_AI_PORT` and resolves to a function that stops it. */
+export function startMockAiServer() {
+ return new Promise(resolve => {
const server = createServer(async (req, res) => {
const url = req.url || '';
@@ -84,10 +85,8 @@ export function getMockAiPort() {
res.writeHead(404).end();
});
- server.listen(0, () => {
- resolve(server.address().port);
+ server.listen(MOCK_AI_PORT, () => {
+ resolve(() => new Promise(done => server.close(done)));
});
});
-
- return serverPromise;
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/d1/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/d1/route.ts
new file mode 100644
index 000000000000..244a4d21942b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/d1/route.ts
@@ -0,0 +1,12 @@
+import { getCloudflareContext } from '@opennextjs/cloudflare';
+import { NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+type Env = { DB: { prepare(query: string): { all(): Promise<{ results: unknown[] }> } } };
+
+export async function GET() {
+ const { env } = await getCloudflareContext({ async: true });
+ const { results } = await (env as unknown as Env).DB.prepare('SELECT 1 AS one').all();
+ return NextResponse.json(results);
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/logs/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/logs/route.ts
new file mode 100644
index 000000000000..b289a2365f2a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/logs/route.ts
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/nextjs';
+
+// Without this Next.js prerenders the route at build time, where `init` creates no client and the log is dropped.
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ Sentry.logger.info('e2e server log', { 'e2e.attr': 'value' });
+ return Response.json({ ok: true });
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/openai/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/openai/route.ts
index 9315f9d079ce..628b7a239697 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/openai/route.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/openai/route.ts
@@ -1,13 +1,12 @@
import { NextResponse } from 'next/server';
import OpenAI from 'openai';
-import { getMockAiPort } from '../../../ai-mock-server.mjs';
+import { MOCK_AI_PORT } from '../../../ai-mock-server.mjs';
export const dynamic = 'force-dynamic';
export async function GET() {
- const port = await getMockAiPort();
const client = new OpenAI({
- baseURL: `http://localhost:${port}/openai`,
+ baseURL: `http://localhost:${MOCK_AI_PORT}/openai`,
apiKey: 'mock-api-key',
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/otel-context/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/otel-context/route.ts
new file mode 100644
index 000000000000..26199762ea55
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/otel-context/route.ts
@@ -0,0 +1,26 @@
+// The app does not install `@opentelemetry/api`: OpenNext resolves the ESM build of an installed copy when it bundles
+// the Node.js middleware for Workers, but its file tracing only copies the CommonJS build. The copy in Next.js uses the
+// same global OpenTelemetry API, but has no types.
+// @ts-expect-error `next/dist/compiled/@opentelemetry/api` ships no type declarations
+import { context, createContextKey, trace } from 'next/dist/compiled/@opentelemetry/api';
+import { NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+const E2E_CONTEXT_KEY = createContextKey('e2e.context.key');
+
+export async function GET() {
+ const tracer = trace.getTracer('e2e');
+
+ context.with(context.active().setValue(E2E_CONTEXT_KEY, 'e2e-value'), () => {
+ tracer.startActiveSpan('otel-context-outer', (outer: { end(): void }) => {
+ // An explicit context, as OpenTelemetry instrumentations pass it.
+ const inner = tracer.startSpan('otel-context-inner', {}, context.active());
+ inner.setAttribute('e2e.context.value', String(context.active().getValue(E2E_CONTEXT_KEY)));
+ inner.end();
+ outer.end();
+ });
+ });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/test-error/route.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/app/api/test-error/route.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/app/api/test-error/route.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch-external-disallowed/check/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch-external-disallowed/check/route.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch-external-disallowed/check/route.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch-external-disallowed/check/route.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch-external-disallowed/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch-external-disallowed/route.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch-external-disallowed/route.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch-external-disallowed/route.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch/check/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch/check/route.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch/check/route.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch/check/route.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch/route.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/test-outgoing-fetch/route.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/test-outgoing-fetch/route.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/utils.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/utils.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-bun/app/propagation/utils.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/app/propagation/utils.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/global-setup.mjs
index dc7ec8736faf..41ec97defa8f 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/global-setup.mjs
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/global-setup.mjs
@@ -1,6 +1,7 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
+import { startMockAiServer } from './ai-mock-server.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -11,4 +12,8 @@ export default async function globalSetup() {
cwd: __dirname,
stdio: 'inherit',
});
+
+ // The mock AI server runs here and not in the app, because a Worker can not start a `node:http` server. Playwright
+ // runs the returned function as teardown.
+ return startMockAiServer();
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
index 9278da016887..6893bd77fdf7 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
@@ -5,6 +5,9 @@ import type { NextConfig } from 'next';
process.env.VERCEL = '1';
const nextConfig: NextConfig = {
+ // `pg` requires `pg-cloudflare` on Workers. The file tracing only copies its Node.js build, and OpenNext copies the
+ // whole package (with its `workerd` build) only for packages listed here.
+ serverExternalPackages: ['pg-cloudflare'],
experimental: {
authInterrupts: true,
sri: {
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/open-next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/open-next.config.ts
similarity index 100%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/open-next.config.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/open-next.config.ts
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json
index 408cd3be5a38..80f92437f500 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json
@@ -20,10 +20,16 @@
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
"test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack",
"test:assert": "pnpm test:prod && pnpm test:dev",
- "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack"
+ "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack",
+ "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build",
+ "test:build-latest:cloudflare": "pnpm install && pnpm add next@16.3 && opennextjs-cloudflare build",
+ "start:cloudflare": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\""
},
"//": "Pin `ioredis` to 5.10.1: it's the last version before the driver publishes its own native diagnostics channels; orchestrion's config covers `ioredis <5.11.0`.",
"dependencies": {
+ "@opennextjs/cloudflare": "^1.14.9",
+ "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz",
+ "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz",
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"@vercel/queue": "^0.1.3",
@@ -47,7 +53,8 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^16",
- "typescript": "^5"
+ "typescript": "^5",
+ "wrangler": "^4.61.0"
},
"volta": {
"extends": "../../package.json"
@@ -67,6 +74,17 @@
{
"build-command": "pnpm test:build-latest",
"label": "nextjs-16 (latest, turbopack)"
+ },
+ {
+ "runtime": "bun",
+ "assert-command": "pnpm test:prod",
+ "label": "nextjs-16 (bun)"
+ },
+ {
+ "runtime": "cloudflare",
+ "build-command": "pnpm test:build:cloudflare",
+ "assert-command": "pnpm test:prod",
+ "label": "nextjs-16 (cloudflare)"
}
],
"optionalVariants": [
@@ -79,6 +97,18 @@
"build-command": "pnpm test:build-canary-webpack",
"label": "nextjs-16 (canary, webpack)",
"assert-command": "pnpm test:assert-webpack"
+ },
+ {
+ "runtime": "cloudflare",
+ "build-command": "pnpm test:build-latest:cloudflare",
+ "assert-command": "pnpm test:prod",
+ "label": "nextjs-16 (cloudflare, latest)"
+ },
+ {
+ "runtime": "deno",
+ "assert-command": "pnpm test:prod",
+ "deno-version": "v2.9.0",
+ "label": "nextjs-16 (deno)"
}
]
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs
index cbac80f5b7e7..7e356d81488b 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs
@@ -1,10 +1,17 @@
-import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils';
const testEnv = process.env.TEST_ENV;
if (!testEnv) {
throw new Error('No test env defined');
}
+const productionStartCommands = {
+ node: 'pnpm next start -p 3030',
+ bun: 'bun --bun ./node_modules/next/dist/bin/next start -p 3030',
+ deno: 'deno run -A ./node_modules/next/dist/bin/next start -p 3030',
+ cloudflare: 'pnpm start:cloudflare --port 3030',
+};
+
const getStartCommand = () => {
if (testEnv === 'development-webpack') {
return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs';
@@ -15,7 +22,7 @@ const getStartCommand = () => {
}
if (testEnv === 'production') {
- return 'pnpm next start -p 3030';
+ return productionStartCommands[getRuntime()];
}
throw new Error(`Unknown test env: ${testEnv}`);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts
index 4507c799f9f3..960f49a15533 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts
@@ -1,3 +1,5 @@
+import { bunHttpServerIntegration, fetchIntegration as bunFetchIntegration } from '@sentry/bun';
+import { fetchIntegration as denoFetchIntegration } from '@sentry/deno';
import * as Sentry from '@sentry/nextjs';
import { Log } from '@sentry/nextjs';
@@ -6,8 +8,17 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
+ tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'],
// debug: true,
- integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })],
+ integrations: [
+ Sentry.vercelAIIntegration(),
+ Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }),
+ // Bun does not emit the `node:http` diagnostics channel the Node SDK uses to isolate incoming requests, so each
+ // request would otherwise share one trace. Next.js emits its own server spans, hence `spans: false`.
+ ...(process.versions.bun ? [bunHttpServerIntegration({ spans: false }), bunFetchIntegration()] : []),
+ // The `fetch` of Deno is not undici, so the Node SDK does not see outgoing requests.
+ ...(process.versions.deno ? [denoFetchIntegration()] : []),
+ ],
// Verify Log type is available
beforeSendLog(log: Log) {
return log;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/cloudflare-runtime.test.ts
similarity index 75%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/tests/cloudflare-runtime.test.ts
index cba53fa1970d..bf1c773190a7 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/cloudflare-runtime.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/cloudflare-runtime.test.ts
@@ -1,9 +1,11 @@
import { expect, test } from '@playwright/test';
-import { waitForError } from '@sentry-internal/test-utils';
+import { getRuntime, waitForError } from '@sentry-internal/test-utils';
+
+test.skip(getRuntime() !== 'cloudflare', 'Checks the runtime context of the Cloudflare Worker');
test.describe('Cloudflare Runtime', () => {
test('Should report cloudflare as the runtime in API route error events', async ({ request }) => {
- const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => {
+ const errorEventPromise = waitForError('nextjs-16', errorEvent => {
return !!errorEvent?.exception?.values?.some(value =>
value.value?.includes('This is a test error from an API route'),
);
@@ -18,6 +20,7 @@ test.describe('Cloudflare Runtime', () => {
expect(errorEvent.contexts?.runtime).toEqual({
name: 'cloudflare',
});
+ expect(errorEvent.tags?.turbopack).toBe(true);
// The SDK info should include cloudflare in the packages
expect(errorEvent.sdk?.packages).toEqual(
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/d1-binding.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/d1-binding.test.ts
new file mode 100644
index 000000000000..e86279c27c23
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/d1-binding.test.ts
@@ -0,0 +1,23 @@
+import { expect, test } from '@playwright/test';
+import { collectStreamedSpansUntilSegment, getRuntime, getSpanOp } from '@sentry-internal/test-utils';
+
+test.skip(getRuntime() !== 'cloudflare', 'D1 is a binding of Cloudflare Workers');
+
+// `withSentry` passes an instrumented `env` to the OpenNext handler, which `getCloudflareContext()` returns.
+test('Should create a span for a D1 query through the `env` of `getCloudflareContext`', async ({ request }) => {
+ const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /api/d1');
+
+ const response = await request.get('/api/d1');
+ expect(await response.json()).toStrictEqual([{ one: 1 }]);
+
+ const spans = await spansPromise;
+ const d1Span = spans.find(span => getSpanOp(span) === 'db.query')!;
+
+ expect(spans.filter(span => getSpanOp(span) === 'db.query')).toEqual([d1Span]);
+ expect(d1Span.name).toBe('SELECT');
+ expect(d1Span.is_segment).toBe(false);
+ expect(d1Span.status).toBe('ok');
+ expect(d1Span.attributes['db.system.name']?.value).toBe('cloudflare-d1');
+ expect(d1Span.attributes['db.query.text']?.value).toBe('SELECT ? AS one');
+ expect(d1Span.attributes['sentry.origin']?.value).toBe('auto.db.cloudflare.d1');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts
index 968891a085c4..e155b1bed5a1 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts
@@ -1,18 +1,18 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
+import { collectStreamedSpansUntilSegment, getRuntime } from '@sentry-internal/test-utils';
+
+// pg is externalized, so the runtime module hook instruments it.
+test('Instruments pg calls made during server-side rendering of a page', async ({ page }) => {
+ test.skip(getRuntime() === 'bun' || getRuntime() === 'cloudflare', 'Bun and Workers have no runtime module hook');
-test('Instruments DB calls made during server-side rendering of a page', async ({ page }) => {
// The db spans are children of the segment span, which ends last.
const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /db-page');
await page.goto('/db-page');
await expect(page.locator('#answer')).toHaveText('answer: 42');
- await expect(page.locator('#cached')).toHaveText('cached: 42');
const spans = await spansPromise;
- // One page render produces spans from both injection paths: pg (externalized → runtime module
- // hook) and ioredis (bundle-safe allowlisted → build-time loader).
expect(spans).toContainEqual(
expect.objectContaining({
name: 'SELECT',
@@ -25,6 +25,18 @@ test('Instruments DB calls made during server-side rendering of a page', async (
}),
}),
);
+});
+
+// ioredis is bundle-safe and allowlisted, so the build-time loader instruments it.
+test('Instruments ioredis calls made during server-side rendering of a page', async ({ page }) => {
+ // The db spans are children of the segment span, which ends last.
+ const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /db-page');
+
+ await page.goto('/db-page');
+ await expect(page.locator('#cached')).toHaveText('cached: 42');
+
+ const spans = await spansPromise;
+
expect(spans).toContainEqual(
expect.objectContaining({
name: 'set localhost:6379',
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/logs.test.ts
new file mode 100644
index 000000000000..25dbd2a909e6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/logs.test.ts
@@ -0,0 +1,39 @@
+import { expect, test } from '@playwright/test';
+import { getRuntime, waitForEnvelopeItem, waitForStreamedSpan } from '@sentry-internal/test-utils';
+import type { SerializedLogContainer } from '@sentry/core';
+
+test('Sends a log of a route handler in the trace of the request', async ({ request }) => {
+ // Workers have no hostname, so the client of `@sentry/cloudflare` has no `serverName` and its logs no
+ // `server.address`. The other runtimes take `os.hostname()`.
+ const serverAddressType = getRuntime() === 'cloudflare' ? undefined : 'string';
+
+ const segmentSpanPromise = waitForStreamedSpan('nextjs-16', span => {
+ return span.name === 'GET /api/logs' && span.is_segment;
+ });
+
+ const response = await request.get('/api/logs');
+ expect(await response.json()).toStrictEqual({ ok: true });
+
+ const segmentSpan = await segmentSpanPromise;
+ // The log can arrive before the segment, so the search starts at the first envelope of the proxy.
+ const logEnvelopeItem = await waitForEnvelopeItem(
+ 'nextjs-16',
+ envelopeItem =>
+ envelopeItem[0].type === 'log' &&
+ (envelopeItem[1] as SerializedLogContainer).items.some(log => log.trace_id === segmentSpan.trace_id),
+ 0,
+ );
+ const logs = (logEnvelopeItem[1] as SerializedLogContainer).items.filter(
+ log => log.trace_id === segmentSpan.trace_id,
+ );
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0]!.body).toBe('e2e server log');
+ expect(logs[0]!.level).toBe('info');
+ expect(logs[0]!.severity_number).toBe(9);
+ expect(logs[0]!.attributes?.['e2e.attr']).toEqual({ value: 'value', type: 'string' });
+ expect(logs[0]!.attributes?.['sentry.environment']).toEqual({ value: 'qa', type: 'string' });
+ expect(logs[0]!.attributes?.['sentry.sdk.name']).toEqual({ value: 'sentry.javascript.nextjs', type: 'string' });
+ expect(logs[0]!.attributes?.['sentry.trace.parent_span_id']?.value).toMatch(/^[a-f0-9]{16}$/);
+ expect(logs[0]!.attributes?.['server.address']?.type).toBe(serverAddressType);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts
index 43edb917d526..85e7c2635f12 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts
@@ -1,7 +1,11 @@
import { expect, test } from '@playwright/test';
-import { waitForMetric } from '@sentry-internal/test-utils';
+import { getRuntime, waitForMetric } from '@sentry-internal/test-utils';
test('Should emit metrics from server and client', async ({ request, page }) => {
+ // Workers have no hostname, so the client of `@sentry/cloudflare` has no `serverName` and its metrics no
+ // `server.address`. The other runtimes take `os.hostname()`.
+ const serverAddressType = getRuntime() === 'cloudflare' ? undefined : 'string';
+
const clientCountPromise = waitForMetric('nextjs-16', async metric => {
return metric.name === 'test.page.count';
});
@@ -90,7 +94,6 @@ test('Should emit metrics from server and client', async ({ request, page }) =>
type: 'counter',
value: 1,
attributes: {
- 'server.address': { value: expect.any(String), type: 'string' },
'random.attribute': { value: 'Potatoes', type: 'string' },
endpoint: { value: '/metrics/route-handler', type: 'string' },
'sentry.environment': { value: 'qa', type: 'string' },
@@ -106,7 +109,6 @@ test('Should emit metrics from server and client', async ({ request, page }) =>
type: 'distribution',
value: 100,
attributes: {
- 'server.address': { value: expect.any(String), type: 'string' },
'random.attribute': { value: 'Patatas', type: 'string' },
endpoint: { value: '/metrics/route-handler', type: 'string' },
'sentry.environment': { value: 'qa', type: 'string' },
@@ -122,7 +124,6 @@ test('Should emit metrics from server and client', async ({ request, page }) =>
type: 'gauge',
value: 200,
attributes: {
- 'server.address': { value: expect.any(String), type: 'string' },
'random.attribute': { value: 'Patate', type: 'string' },
endpoint: { value: '/metrics/route-handler', type: 'string' },
'sentry.environment': { value: 'qa', type: 'string' },
@@ -130,4 +131,7 @@ test('Should emit metrics from server and client', async ({ request, page }) =>
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
},
});
+ expect(serverCount.attributes?.['server.address']?.type).toBe(serverAddressType);
+ expect(serverDistribution.attributes?.['server.address']?.type).toBe(serverAddressType);
+ expect(serverGauge.attributes?.['server.address']?.type).toBe(serverAddressType);
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts
index f91168e54038..d3b3b3dcd05c 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts
@@ -2,12 +2,15 @@ import { expect, test } from '@playwright/test';
import {
collectStreamedSpans,
collectStreamedSpansUntilSegment,
+ getRuntime,
getSpanOp,
waitForStreamedSpan,
} from '@sentry-internal/test-utils';
import { isDevMode } from './isDevMode';
test('Should create a span for middleware', async ({ request }) => {
+ test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span');
+
const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'middleware GET');
const routeSpanPromise = waitForStreamedSpan('nextjs-16', span => {
@@ -44,8 +47,33 @@ test('Should create a span for middleware', async ({ request }) => {
expect(routeSpan.attributes['isolation_scope.has_proxy_marker']).toEqual({ value: false, type: 'boolean' });
});
+// `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so middleware runs inside the request's
+// `http.server` segment and its span is a child of it rather than a segment of its own.
+test('Should create a span for middleware inside the request segment of the Worker', async ({ request }) => {
+ test.skip(getRuntime() !== 'cloudflare', 'Only on Workers the request span of `withSentry` is the segment');
+
+ const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /api/endpoint-behind-middleware');
+
+ const response = await request.get('/api/endpoint-behind-middleware');
+ expect(await response.json()).toStrictEqual({ name: 'John Doe' });
+
+ const spans = await spansPromise;
+ const segmentSpan = spans.find(span => span.is_segment)!;
+ const middlewareSpan = spans.find(span => span.name === 'middleware GET')!;
+
+ expect(spans.filter(span => getSpanOp(span) === 'middleware')).toEqual([middlewareSpan]);
+ expect(segmentSpan.attributes['url.path']?.value).toBe('/api/endpoint-behind-middleware');
+ expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
+ expect(segmentSpan.attributes['isolation_scope.is_default']).toEqual({ value: false, type: 'boolean' });
+ expect(middlewareSpan.is_segment).toBe(false);
+ expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id);
+ expect(middlewareSpan.status).toBe('ok');
+ expect(getSpanOp(middlewareSpan)).toBe('middleware');
+});
+
test('Faulty middlewares', async ({ request }) => {
test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261
+ test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span');
const middlewareSpanPromise = waitForStreamedSpan('nextjs-16', span => {
return span.name === 'middleware GET' && span.is_segment;
});
@@ -65,9 +93,39 @@ test('Faulty middlewares', async ({ request }) => {
// await test.step('should record exceptions', async () => { ... });
});
+test('Faulty middlewares inside the request segment of the Worker', async ({ request }) => {
+ test.skip(getRuntime() !== 'cloudflare', 'Only on Workers the request span of `withSentry` is the segment');
+ // The middleware throws, so the request span of `withSentry` keeps the middleware name, like the middleware segment
+ // of Node.js.
+ const spansPromise = collectStreamedSpans(
+ 'nextjs-16',
+ spans =>
+ spans.some(span => span.is_segment && span.name === 'middleware GET') &&
+ spans.some(span => !span.is_segment && span.name === 'middleware GET'),
+ );
+
+ request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => {
+ // Noop
+ });
+
+ const spans = await spansPromise;
+ const segmentSpan = spans.find(span => span.is_segment)!;
+ const middlewareSpan = spans.find(span => !span.is_segment && span.name === 'middleware GET')!;
+
+ expect(segmentSpan.status).toBe('error');
+ expect(segmentSpan.attributes['sentry.segment.name.source']?.value).toBe('route');
+ expect(middlewareSpan.parent_span_id).toBe(segmentSpan.span_id);
+ expect(middlewareSpan.status).toBe('error');
+ expect(getSpanOp(middlewareSpan)).toBe('middleware');
+});
+
test('Should trace outgoing fetch requests inside middleware', async ({ request }) => {
test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm');
+ // Only Node.js has the undici diagnostics channel. On the other runtimes the fetch integration of the runtime
+ // SDK creates this span, which has no `sentry.kind`, `url.path` and `url.scheme`.
+ const isNode = getRuntime() === 'node';
+
// In some builds (especially webpack) the fetch span is not a child of the middleware segment but a
// segment of its own, so this waits for either. `http.client` span names are low cardinality under
// span streaming, hence `GET localhost` rather than the full URL.
@@ -83,17 +141,15 @@ test('Should trace outgoing fetch requests inside middleware', async ({ request
const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client' && span.name === 'GET localhost')!;
expect(fetchSpan.status).toBe('ok');
- expect(fetchSpan.attributes).toMatchObject({
- 'http.request.method': { value: 'GET', type: 'string' },
- 'http.response.status_code': { value: 200, type: 'integer' },
- 'sentry.kind': { value: 'client', type: 'string' },
- 'sentry.op': { value: 'http.client', type: 'string' },
- 'sentry.origin': { value: 'auto.http.node_fetch', type: 'string' },
- 'server.address': { value: 'localhost', type: 'string' },
- 'server.port': { value: 3030, type: 'integer' },
- 'url.domain': { value: 'localhost', type: 'string' },
- 'url.full': { value: 'http://localhost:3030/', type: 'string' },
- 'url.path': { value: '/', type: 'string' },
- 'url.scheme': { value: 'http', type: 'string' },
- });
+ expect(fetchSpan.attributes['http.request.method']?.value).toBe('GET');
+ expect(fetchSpan.attributes['http.response.status_code']?.value).toBe(200);
+ expect(fetchSpan.attributes['sentry.op']?.value).toBe('http.client');
+ expect(fetchSpan.attributes['sentry.origin']?.value).toBe(isNode ? 'auto.http.node_fetch' : 'auto.http.fetch');
+ expect(fetchSpan.attributes['server.address']?.value).toBe('localhost');
+ expect(fetchSpan.attributes['server.port']?.value).toBe(3030);
+ expect(fetchSpan.attributes['url.domain']?.value).toBe('localhost');
+ expect(fetchSpan.attributes['url.full']?.value).toBe('http://localhost:3030/');
+ expect(fetchSpan.attributes['sentry.kind']?.value).toBe(isNode ? 'client' : undefined);
+ expect(fetchSpan.attributes['url.path']?.value).toBe(isNode ? '/' : undefined);
+ expect(fetchSpan.attributes['url.scheme']?.value).toBe(isNode ? 'http' : undefined);
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts
index 5295101f1e20..7f611cf3daa9 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
-import { waitForMetric } from '@sentry-internal/test-utils';
+import { getRuntime, waitForMetric } from '@sentry-internal/test-utils';
+
+test.skip(getRuntime() === 'cloudflare', 'Node.js runtime metrics do not exist on Workers');
const EXPECTED_ATTRIBUTES = {
'sentry.environment': { value: 'qa', type: 'string' },
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/otel-context.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/otel-context.test.ts
new file mode 100644
index 000000000000..1bc31009a6b1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/otel-context.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from '@playwright/test';
+import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
+
+test('Keeps OpenTelemetry context values and parents', async ({ request }) => {
+ const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /api/otel-context');
+
+ const response = await request.get('/api/otel-context');
+ expect(await response.json()).toStrictEqual({ ok: true });
+
+ const spans = await spansPromise;
+ const segmentSpan = spans.find(span => span.is_segment)!;
+ const outerSpan = spans.find(span => span.name === 'otel-context-outer')!;
+ const innerSpan = spans.find(span => span.name === 'otel-context-inner')!;
+
+ expect(spans.some(span => span.span_id === outerSpan.parent_span_id)).toBe(true);
+ expect(innerSpan.parent_span_id).toBe(outerSpan.span_id);
+ expect(outerSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id);
+ expect(innerSpan.attributes['e2e.context.value']?.value).toBe('e2e-value');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts
similarity index 73%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts
index e82fb6453474..e758cd769551 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts
@@ -5,12 +5,14 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }
// Inbound span, outbound span and the http.client span in between all share one trace, and
// `collectStreamedSpans` evaluates a single trace at a time, so requiring all three together
// keeps them paired.
- const spansPromise = collectStreamedSpans('nextjs-16-bun', spans => {
+ const spansPromise = collectStreamedSpans('nextjs-16', spans => {
return (
spans.some(span => span.name === 'GET /propagation/test-outgoing-fetch' && span.is_segment) &&
spans.some(span => span.name === 'GET /propagation/test-outgoing-fetch/check' && span.is_segment) &&
spans.some(
- span => getSpanOp(span) === 'http.client' && span.attributes['sentry.origin']?.value === 'auto.http.fetch',
+ span =>
+ getSpanOp(span) === 'http.client' &&
+ String(span.attributes['url.full']?.value).endsWith('/propagation/test-outgoing-fetch/check'),
)
);
});
@@ -23,7 +25,9 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }
span => span.name === 'GET /propagation/test-outgoing-fetch/check' && span.is_segment,
)!;
const httpClientSpan = spans.find(
- span => getSpanOp(span) === 'http.client' && span.attributes['sentry.origin']?.value === 'auto.http.fetch',
+ span =>
+ getSpanOp(span) === 'http.client' &&
+ String(span.attributes['url.full']?.value).endsWith('/propagation/test-outgoing-fetch/check'),
);
expect(inboundSpan.trace_id).toStrictEqual(expect.any(String));
@@ -33,10 +37,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }
expect(httpClientSpan?.span_id).toStrictEqual(expect.any(String));
expect(inboundSpan.parent_span_id).toBe(httpClientSpan?.span_id);
- expect(headers).toMatchObject({
- baggage: expect.any(String),
- 'sentry-trace': `${outboundSpan.trace_id}-${httpClientSpan?.span_id}-1`,
- });
+ expect(headers['sentry-trace']).toBe(`${outboundSpan.trace_id}-${httpClientSpan?.span_id}-1`);
+ expect(headers.baggage).toContain(`sentry-trace_id=${outboundSpan.trace_id}`);
+ expect(headers.baggage).toContain('sentry-sampled=true');
+ expect(headers.baggage).toContain('sentry-environment=qa');
});
test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({
@@ -44,11 +48,11 @@ test('Does not propagate outgoing fetch requests not covered by tracePropagation
request,
}) => {
// These two spans are deliberately in different traces, so they are matched by their unique names.
- const inboundSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
+ const inboundSpanPromise = waitForStreamedSpan('nextjs-16', span => {
return span.name === 'GET /propagation/test-outgoing-fetch-external-disallowed/check' && span.is_segment;
});
- const outboundSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => {
+ const outboundSpanPromise = waitForStreamedSpan('nextjs-16', span => {
return span.name === 'GET /propagation/test-outgoing-fetch-external-disallowed' && span.is_segment;
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts
index 27b82ae35c61..3ebe985701f4 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
-test('Should handle server action redirect without capturing errors', async ({ page }) => {
+test('Should handle server action redirect without capturing errors', async ({ page, request }) => {
// Wait for the initial pageload span
const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16', span => {
return span.name === '/redirect/origin' && span.is_segment;
@@ -18,10 +18,9 @@ test('Should handle server action redirect without capturing errors', async ({ p
return span.name === 'GET /redirect/destination' && span.is_segment;
});
- // No error should be captured
- const redirectErrorPromise = waitForError('nextjs-16', async errorEvent => {
- return !!errorEvent;
- });
+ // The error of `/api/test-error`, requested after the redirect, is the sentinel: an error of the redirect would
+ // arrive before it.
+ const firstErrorPromise = waitForError('nextjs-16', () => true);
// Click the redirect button
await page.click('button[type="submit"]');
@@ -31,17 +30,8 @@ test('Should handle server action redirect without capturing errors', async ({ p
// Verify we got redirected to the destination page
await expect(page).toHaveURL('/redirect/destination');
- // Wait for potential errors with a 2 second timeout
- const errorTimeout = new Promise((_, reject) =>
- setTimeout(() => reject(new Error('No error captured (timeout)')), 2000),
- );
-
- // We expect this to timeout since no error should be captured during the redirect
- try {
- await Promise.race([redirectErrorPromise, errorTimeout]);
- throw new Error('Expected no error to be captured, but an error was found');
- } catch (e) {
- // If we get a timeout error (as expected), no error was captured
- expect((e as Error).message).toBe('No error captured (timeout)');
- }
+ await request.get('/api/test-error');
+
+ const firstError = await firstErrorPromise;
+ expect(firstError.exception?.values?.[0]?.value).toBe('This is a test error from an API route');
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts
index b271cfd1ac52..e7e0de8b1202 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils';
+import { collectStreamedSpansUntilSegment, getRuntime, getSpanOp } from '@sentry-internal/test-utils';
import { isTurbopackDevMode } from './isDevMode';
// Next.js emits these spans itself. The SDK attaches no op, description or function name to
@@ -8,6 +8,7 @@ const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: un
test('Sends a span for a request to app router with URL', async ({ page }) => {
test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode');
+ test.skip(getRuntime() === 'cloudflare', 'On Workers the segment is the request span of `withSentry`');
const spansPromise = collectStreamedSpansUntilSegment(
'nextjs-16',
@@ -45,6 +46,49 @@ test('Sends a span for a request to app router with URL', async ({ page }) => {
// No child span should share the segment span's name
expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0);
+ expect(spans.filter(span => getSpanOp(span) === 'http.server')).toEqual([segmentSpan]);
+});
+
+// `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so the segment is its `http.server` span with
+// the attributes of `@sentry/cloudflare`, named after the route Next.js resolved.
+test('Sends the request span of the Worker for a request to app router with URL', async ({ page }) => {
+ test.skip(getRuntime() !== 'cloudflare', 'Only on Workers the request span of `withSentry` is the segment');
+
+ const spansPromise = collectStreamedSpansUntilSegment(
+ 'nextjs-16',
+ span =>
+ span.name === 'GET /parameterized/[one]/beep/[two]' &&
+ span.attributes['url.path']?.value === '/parameterized/1337/beep/42',
+ );
+
+ await page.goto('/parameterized/1337/beep/42');
+
+ const spans = await spansPromise;
+ const segmentSpan = spans.find(
+ span =>
+ span.name === 'GET /parameterized/[one]/beep/[two]' &&
+ span.is_segment &&
+ span.attributes['url.path']?.value === '/parameterized/1337/beep/42',
+ )!;
+
+ expect(segmentSpan.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/));
+ expect(segmentSpan.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/));
+ expect(segmentSpan.status).toBe('ok');
+ expect(segmentSpan.attributes).toMatchObject({
+ 'sentry.op': { value: 'http.server', type: 'string' },
+ 'sentry.origin': { value: 'auto.http.cloudflare', type: 'string' },
+ 'sentry.sample_rate': { value: 1, type: 'integer' },
+ 'sentry.segment.name.source': { value: 'route', type: 'string' },
+ 'http.request.method': { value: 'GET', type: 'string' },
+ 'http.response.status_code': { value: 200, type: 'integer' },
+ 'http.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' },
+ 'url.path': { value: '/parameterized/1337/beep/42', type: 'string' },
+ 'next.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' },
+ });
+
+ // No child span should share the segment span's name
+ expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0);
+ expect(spans.filter(span => getSpanOp(span) === 'http.server')).toEqual([segmentSpan]);
});
test('Will create spans for every server component and metadata generation functions when visiting a page', async ({
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/trace-continuation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/trace-continuation.test.ts
new file mode 100644
index 000000000000..88ebdc16d554
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/trace-continuation.test.ts
@@ -0,0 +1,37 @@
+import { expect, test } from '@playwright/test';
+import { collectStreamedSpans, getRuntime } from '@sentry-internal/test-utils';
+
+test('Continues an incoming trace in the segment of the request', async ({ request }) => {
+ // On Workers the segment is the request span of `withSentry`, on the other runtimes the request span of Next.js.
+ const segmentOrigin = getRuntime() === 'cloudflare' ? 'auto.http.cloudflare' : 'auto';
+ const traceId = crypto.randomUUID().replace(/-/g, '');
+ const parentSpanId = traceId.slice(0, 16);
+
+ const spansPromise = collectStreamedSpans(
+ 'nextjs-16',
+ spans =>
+ spans.some(span => span.trace_id === traceId && span.is_segment) &&
+ spans.some(
+ span => span.trace_id === traceId && span.name === 'render route (app) /parameterized/[one]/beep/[two]',
+ ),
+ );
+
+ await request.get('/parameterized/1337/beep/42', {
+ headers: {
+ 'sentry-trace': `${traceId}-${parentSpanId}-1`,
+ baggage: `sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`,
+ },
+ });
+
+ const spans = await spansPromise;
+ const segmentSpan = spans.find(span => span.trace_id === traceId && span.is_segment)!;
+ const renderSpan = spans.find(
+ span => span.trace_id === traceId && span.name === 'render route (app) /parameterized/[one]/beep/[two]',
+ )!;
+
+ expect(spans.filter(span => span.trace_id === traceId && span.is_segment)).toEqual([segmentSpan]);
+ expect(segmentSpan.parent_span_id).toBe(parentSpanId);
+ expect(segmentSpan.attributes['sentry.origin']?.value).toBe(segmentOrigin);
+ expect(segmentSpan.name).toBe('GET /parameterized/[one]/beep/[two]');
+ expect(renderSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/worker-bundle.test.ts
similarity index 95%
rename from dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts
rename to dev-packages/e2e-tests/test-applications/nextjs-16/tests/worker-bundle.test.ts
index dbd927e2053b..af16d1add37d 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/worker-bundle.test.ts
@@ -1,9 +1,12 @@
import { expect, test } from '@playwright/test';
+import { getRuntime } from '@sentry-internal/test-utils';
import * as fs from 'fs';
import { createRequire } from 'module';
import * as path from 'path';
import { isDevMode } from './isDevMode';
+test.skip(getRuntime() !== 'cloudflare', 'Checks the worker bundle of OpenNext');
+
/**
* The orchestrion bundler plugins are build-time-only, and their module-scope side effects break
* on Workers (an unawaited `WebAssembly.compile()` crashed every cold start, issue #22794). The
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/worker.ts
new file mode 100644
index 000000000000..fb5708783e58
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/worker.ts
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/nextjs/cloudflare';
+// @ts-ignore the file exists only after `opennextjs-cloudflare build`
+import handler from './.open-next/worker.js';
+
+// @ts-ignore the file exists only after `opennextjs-cloudflare build`
+export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from './.open-next/worker.js';
+
+export default Sentry.withSentry(
+ (env: { E2E_TEST_DSN: string }) => ({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: env.E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'],
+ integrations: [Sentry.vercelAIIntegration()],
+ }),
+ handler,
+);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.jsonc
new file mode 100644
index 000000000000..f6bfe6a4ec38
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.jsonc
@@ -0,0 +1,36 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "nextjs-16",
+ "main": "./worker.ts",
+ // From 2026-02-19 on, wrangler uses the native `process` of workerd, whose `cwd()` is `/bundle` during a request.
+ // Next.js then misses its router server context and extracts the incoming trace again, see trace-continuation.test.ts.
+ "compatibility_date": "2026-02-19",
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
+ "assets": {
+ "binding": "ASSETS",
+ "directory": ".open-next/assets",
+ },
+ "images": {
+ // Enable image optimization
+ // see https://opennext.js.org/cloudflare/howtos/image
+ "binding": "IMAGES",
+ },
+ "services": [
+ {
+ // Self-reference service binding, the service name must match the worker name
+ // see https://opennext.js.org/cloudflare/caching
+ "binding": "WORKER_SELF_REFERENCE",
+ "service": "nextjs-16",
+ },
+ ],
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "e2e",
+ "database_id": "00000000-0000-0000-0000-000000000000",
+ },
+ ],
+ "observability": {
+ "enabled": true,
+ },
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json
index ee79cb9ff340..ddfd68285ec3 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-sourcemaps/package.json
@@ -10,7 +10,7 @@
},
"dependencies": {
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
- "next": "16.2.11",
+ "next": "16.3.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "~5.0.0"
diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json
index 20340b7e81a6..592a00860591 100644
--- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json
@@ -15,11 +15,11 @@
"test:dev": "TEST_ENV=development playwright test"
},
"dependencies": {
- "@mastra/core": "~1.65.0",
+ "@mastra/core": "~1.74.0",
"@mastra/libsql": "~1.22.4",
"@mastra/memory": "~1.28.3",
"@mastra/observability": "~1.17.6",
- "@openrouter/ai-sdk-provider": "~3.0.0",
+ "@openrouter/ai-sdk-provider": "~3.1.0",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"ai": "~7.0.97",
"dataloader": "~2.2.3",
@@ -28,6 +28,7 @@
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
+ "@sentry/conventions": "0.26.0",
"@types/node": "24.x",
"mastra": "~1.28.0",
"typescript": "~5.9.0"
diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts
index 8b33b4cc4d5e..2b56591e4ec5 100644
--- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts
@@ -1,19 +1,10 @@
-import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
import { countItemsTool } from '../tools/count-items.js';
import { failNowTool } from '../tools/fail-now.js';
import { getWeatherTool } from '../tools/get-weather.js';
-
-const apiKey = process.env.E2E_OPENROUTER_API_KEY;
-if (!apiKey) {
- throw new Error('E2E_OPENROUTER_API_KEY is not set');
-}
-
-// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the
-// e2e test needs only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY`.
-const openrouter = createOpenRouter({ apiKey });
+import { openrouter } from '../openrouter.js';
// An in-memory libsql store is enough for the test: Mastra requires a storage
// provider before `generate(..., { memory: { thread, resource } })` is accepted,
diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/classifiers/ticket-classifier.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/classifiers/ticket-classifier.ts
new file mode 100644
index 000000000000..6a66654bb7e9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/classifiers/ticket-classifier.ts
@@ -0,0 +1,17 @@
+import { Classifier } from '@mastra/core/classifier';
+import { openrouter } from '../openrouter.js';
+
+export const TICKET_CLASSIFIER = 'ticketClassifier';
+
+// A Jev evaluation through OpenRouter's Decisions API. `Classifier` calls the model's `doEvaluate()`
+// directly, so its span comes from the Mastra exporter, not from the Vercel AI integration.
+export const ticketClassifier = new Classifier({
+ id: TICKET_CLASSIFIER,
+ model: openrouter.evaluationModel('typesafe/jev-1.13'),
+ questions: {
+ is_bug: {
+ type: 'boolean',
+ instructions: 'Is the customer reporting a software defect?',
+ },
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts
index b6f2dd975b39..c5528dbd6ba9 100644
--- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts
@@ -2,6 +2,7 @@ import { Mastra } from '@mastra/core';
import { registerApiRoute } from '@mastra/core/server';
import { LibSQLStore } from '@mastra/libsql';
import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js';
+import { TICKET_CLASSIFIER, ticketClassifier } from './classifiers/ticket-classifier.js';
// The agent is driven through Mastra's built-in `POST /api/agents/:id/generate`
// endpoint (see tests/utils.ts). `dataloader` (orchestrion-instrumented) is exercised
@@ -19,12 +20,24 @@ const manualRoute = registerApiRoute('/manual-route', {
},
});
+// Evaluates the registered classifier outside of any agent run. Registration lets Mastra start a root
+// `classifier_evaluation` span when no Mastra span is active.
+const classifyRoute = registerApiRoute('/classify', {
+ method: 'POST',
+ async handler(c) {
+ const { ticket } = await c.req.json<{ ticket: string }>();
+ const result = await ticketClassifier.evaluate({ state: { ticket } });
+ return c.json({ answers: result.answers });
+ },
+});
+
export const mastra = new Mastra({
agents: { [WEATHER_AGENT]: weatherAgent },
+ classifiers: { [TICKET_CLASSIFIER]: ticketClassifier },
storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }),
server: {
port: 4111,
- apiRoutes: [manualRoute],
+ apiRoutes: [manualRoute, classifyRoute],
},
// No `bundler.externals` override: `mastra build` defaults to externalizing all non-workspace deps,
// so Hono (and the other instrumented modules) stay unbundled and the `--import` orchestrion hook
diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/openrouter.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/openrouter.ts
new file mode 100644
index 000000000000..b04e3e9e5358
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/openrouter.ts
@@ -0,0 +1,10 @@
+import { createOpenRouter } from '@openrouter/ai-sdk-provider';
+
+const apiKey = process.env.E2E_OPENROUTER_API_KEY;
+if (!apiKey) {
+ throw new Error('E2E_OPENROUTER_API_KEY is not set');
+}
+
+// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the
+// e2e test needs only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY`.
+export const openrouter = createOpenRouter({ apiKey });
diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/classifier.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/classifier.test.ts
new file mode 100644
index 000000000000..6eee9bb68359
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/classifier.test.ts
@@ -0,0 +1,65 @@
+import { expect, test } from '@playwright/test';
+import {
+ GEN_AI_INPUT_MESSAGES,
+ GEN_AI_OPERATION_NAME,
+ GEN_AI_OUTPUT_MESSAGES,
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ SENTRY_ORIGIN,
+ URL_FULL,
+} from '@sentry/conventions/attributes';
+import { GEN_AI_EVALUATE, HTTP_SERVER } from '@sentry/conventions/op';
+import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry-internal/test-utils';
+
+const APP = 'node-mastra';
+const TICKET = 'My checkout page shows a blank screen after I click Pay.';
+
+const isEvaluateSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === GEN_AI_EVALUATE;
+const isClassifyServerSpan = (span: SerializedStreamedSpan): boolean =>
+ getSpanOp(span) === HTTP_SERVER && String(span.attributes?.[URL_FULL]?.value ?? '').includes('/classify');
+
+test('captures a Mastra classifier (Jev) evaluation as a gen_ai.evaluate span', async ({ baseURL }) => {
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace => spansOfTrace.some(isEvaluateSpan) && spansOfTrace.some(isClassifyServerSpan),
+ );
+
+ const res = await fetch(`${baseURL}/classify`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ticket: TICKET }),
+ });
+ expect(res.status).toBe(200);
+ await res.json();
+
+ const spans = await spansPromise;
+ const serverSpan = spans.find(isClassifyServerSpan)!;
+
+ // Only the Mastra exporter reports the evaluation: `Classifier` calls `doEvaluate()` directly, so the
+ // Vercel AI integration does not see it.
+ const evaluateSpans = spans.filter(isEvaluateSpan);
+ expect(evaluateSpans.map(span => ({ name: span.name, origin: span.attributes?.[SENTRY_ORIGIN]?.value }))).toEqual([
+ { name: 'evaluate typesafe/jev-1.13', origin: 'auto.ai.mastra' },
+ ]);
+
+ const evaluateSpan = evaluateSpans[0]!;
+ expect(evaluateSpan.parent_span_id).toBe(serverSpan.span_id);
+ expect(evaluateSpan.attributes?.[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate');
+ expect(evaluateSpan.attributes?.[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe/jev-1.13');
+ expect(evaluateSpan.attributes?.[GEN_AI_PROVIDER_NAME]?.value).toBe('openrouter');
+ expect(evaluateSpan.attributes?.[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
+ expect(JSON.parse(String(evaluateSpan.attributes?.[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
+ {
+ type: 'evaluation',
+ state: { ticket: TICKET },
+ questions: {
+ is_bug: { type: 'boolean', instructions: 'Is the customer reporting a software defect?' },
+ },
+ },
+ ]);
+ // The probability comes from the live model, so only its shape is stable.
+ expect(JSON.parse(String(evaluateSpan.attributes?.[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([
+ { type: 'evaluation', answers: { is_bug: { type: 'boolean', probability: expect.any(Number) } } },
+ ]);
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/.gitignore b/dev-packages/e2e-tests/test-applications/node-pi-durable/.gitignore
new file mode 100644
index 000000000000..6054d28d34f9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.data
+results.junit.xml
+test-results
+playwright-report
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/instrument.mjs
new file mode 100644
index 000000000000..b774d9306950
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/instrument.mjs
@@ -0,0 +1,8 @@
+import * as Sentry from '@sentry/node';
+
+Sentry.init({
+ environment: 'qa',
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+ tracesSampleRate: 1.0,
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json
new file mode 100644
index 000000000000..baadb930e355
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "node-pi-durable",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node src/start.mjs",
+ "clean": "npx rimraf node_modules .data pnpm-lock.yaml",
+ "test:build": "pnpm install",
+ "test:build-latest": "pnpm install && pnpm add @earendil-works/pi-durable@latest @earendil-works/pi-ai@latest @earendil-works/chord@latest",
+ "test:assert": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test"
+ },
+ "dependencies": {
+ "@earendil-works/chord": "1.0.0",
+ "@earendil-works/pi-ai": "1.0.0",
+ "@earendil-works/pi-durable": "1.0.0",
+ "@sentry/node": "file:../../packed/sentry-node-packed.tgz"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.63.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@sentry/conventions": "0.26.0",
+ "@sentry/core": "file:../../packed/sentry-core-packed.tgz",
+ "@types/node": "24.x"
+ },
+ "engines": {
+ "node": "24.x"
+ },
+ "volta": {
+ "node": "24.15.0",
+ "extends": "../../package.json"
+ },
+ "sentryTest": {
+ "optional": true,
+ "optionalVariants": [
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "node-pi-durable (latest)"
+ }
+ ]
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/playwright.config.mjs
new file mode 100644
index 000000000000..ad0caf7da2c9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/playwright.config.mjs
@@ -0,0 +1,10 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig(
+ { startCommand: 'pnpm start' },
+ // Each test drives real OpenRouter tool-calling turns, and one restarts the server mid-run, which
+ // does not fit the default 30s timeout when the provider is slow.
+ { timeout: 120_000 },
+);
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs
new file mode 100644
index 000000000000..79892991f03d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs
@@ -0,0 +1,162 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { fileURLToPath } from 'node:url';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
+import {
+ AssistantEntry,
+ createRegistry,
+ defineExtension,
+ defineTool,
+ Harness,
+ section,
+} from '@earendil-works/pi-durable';
+import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node';
+import * as Sentry from '@sentry/node';
+
+const context = BACKGROUND_CONTEXT;
+const MODEL = { provider: 'openrouter', modelId: 'anthropic/claude-haiku-4.5' };
+const CRASH_EXIT_CODE = 75;
+
+if (!process.env.OPENROUTER_API_KEY) {
+ throw new Error('OPENROUTER_API_KEY is not set (E2E_OPENROUTER_API_KEY in the e2e-tests .env)');
+}
+
+const dataDir = fileURLToPath(new URL('../.data/', import.meta.url));
+mkdirSync(dataDir, { recursive: true });
+
+const models = createModels();
+models.setProvider(openrouterProvider()); // reads OPENROUTER_API_KEY
+
+const tools = [
+ defineTool({
+ name: 'get_weather',
+ description: 'Get the current weather for a city.',
+ parameters: Type.Object({ city: Type.String() }),
+ // The manual span should nest under the SDK's `execute_tool` span.
+ execute: async args =>
+ Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({
+ content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }],
+ })),
+ }),
+ defineTool({
+ name: 'fail_now',
+ description: 'Always throws an error. Call this when the user asks to trigger a failure.',
+ parameters: Type.Object({}),
+ execute: async () => {
+ throw new Error('Intentional pi-durable tool failure');
+ },
+ }),
+ defineTool({
+ name: 'crash_once',
+ description: 'Runs one step of a job. Call this when the user asks for crash_once.',
+ parameters: Type.Object({ job: Type.String() }),
+ // Replay-safe, so pi-durable reruns the call after the crash instead of failing it.
+ replay: 'safe',
+ execute: async args => {
+ // The marker is created once, atomically; its presence means the first attempt already crashed.
+ try {
+ writeFileSync(`${dataDir}crashed-${args.job}`, 'crashed once', { flag: 'wx' });
+ } catch {
+ return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] };
+ }
+ process.exit(CRASH_EXIT_CODE);
+ },
+ }),
+ defineTool({
+ name: 'delegate',
+ description: 'Delegate a self-contained task to a subagent and get its answer back.',
+ parameters: Type.Object({ task: Type.String() }),
+ replay: 'safe',
+ execute: async (args, api, toolContext) => {
+ const childId = await api.commit(async tx => {
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
+ if (existing !== undefined) return existing.id;
+ return (await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } })).id;
+ }, toolContext);
+ const child = await api.conversation(childId, toolContext);
+ const request = { type: 'input', content: args.task, requestId: `delegate:${api.taskId}` };
+ const settled = await (await child.submit(request, toolContext)).wait(toolContext);
+ if (settled.status !== 'done') {
+ return { isError: true, content: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] };
+ }
+ const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), toolContext);
+ const text = (answer?.model?.[0]?.content ?? [])
+ .filter(part => part.type === 'text')
+ .map(part => part.text)
+ .join('');
+ return { content: [{ type: 'text', text }] };
+ },
+ }),
+];
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'e2e',
+ sections: [
+ section('preamble', () => 'You are a test assistant. Use the tools exactly as asked. Keep answers short.', {
+ tag: false,
+ }),
+ ],
+ tools,
+ }),
+);
+
+const storage = await openNodeSqliteStorage(`${dataDir}pi.sqlite`);
+const harness = await Harness.open(storage, { models, registry }, context);
+// Continue whatever the previous process left unfinished, such as a run interrupted by `crash_once`.
+harness.resume();
+
+async function readJson(req) {
+ let body = '';
+ for await (const chunk of req) body += chunk;
+ return body ? JSON.parse(body) : {};
+}
+
+async function handle(req, res) {
+ const url = new URL(req.url, 'http://localhost');
+ const parts = url.pathname.split('/').filter(Boolean);
+
+ if (req.method === 'POST' && url.pathname === '/conversations') {
+ const conversation = await harness.createConversation(
+ { ownership: { kind: 'ownerless' }, agent: { model: MODEL } },
+ context,
+ );
+ return { id: conversation.id };
+ }
+
+ // Returns as soon as the input is admitted. The run continues in the background, possibly in a
+ // later process, so clients poll `GET /submissions/:id` for the outcome.
+ if (req.method === 'POST' && parts[0] === 'conversations' && parts[2] === 'messages') {
+ const conversation = await harness.conversation(Number(parts[1]), context);
+ if (!conversation) return undefined;
+ const { content } = await readJson(req);
+ const submission = await conversation.submit({ type: 'input', content }, context);
+ return { submissionId: submission.id };
+ }
+
+ if (req.method === 'GET' && parts[0] === 'submissions') {
+ const submission = await harness.submission(Number(parts[1]), context);
+ if (!submission) return undefined;
+ const record = await submission.status(context);
+ return { status: record.status, reason: record.reason, detail: record.detail };
+ }
+
+ return undefined;
+}
+
+createServer((req, res) => {
+ handle(req, res).then(
+ result => {
+ res.writeHead(result ? 200 : 404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(result ?? { error: 'not found' }));
+ },
+ error => {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: String(error) }));
+ },
+ );
+}).listen(3030);
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/start.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/start.mjs
new file mode 100644
index 000000000000..55406c81b107
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/start.mjs
@@ -0,0 +1,35 @@
+import { spawn } from 'node:child_process';
+import { rmSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+// The `crash_once` tool exits the server mid-call with this code to simulate a crash. Restarting the
+// server on the same storage, as a process manager would, is what lets the run resume.
+const CRASH_EXIT_CODE = 75;
+
+const appDir = fileURLToPath(new URL('..', import.meta.url));
+rmSync(new URL('../.data', import.meta.url), { recursive: true, force: true });
+
+let server;
+
+function startServer() {
+ server = spawn(process.execPath, ['--import', './instrument.mjs', 'src/server.mjs'], {
+ cwd: appDir,
+ stdio: 'inherit',
+ });
+ server.on('exit', code => {
+ if (code === CRASH_EXIT_CODE) {
+ startServer();
+ } else {
+ process.exit(code ?? 1);
+ }
+ });
+}
+
+for (const signal of ['SIGINT', 'SIGTERM']) {
+ process.on(signal, () => {
+ server.kill(signal);
+ process.exit(0);
+ });
+}
+
+startServer();
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/start-event-proxy.mjs
new file mode 100644
index 000000000000..dd9d4d2b9da8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'node-pi-durable',
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts
new file mode 100644
index 000000000000..b00011096879
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts
@@ -0,0 +1,199 @@
+import { expect, test } from '@playwright/test';
+import {
+ GEN_AI_CONVERSATION_ID,
+ GEN_AI_COST_TOTAL_TOKENS,
+ GEN_AI_INPUT_MESSAGES,
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_RESPONSE_FINISH_REASONS,
+ GEN_AI_TOOL_CALL_ARGUMENTS,
+ GEN_AI_TOOL_CALL_RESULT,
+ GEN_AI_TOOL_NAME,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+} from '@sentry/conventions/attributes';
+import { GEN_AI_CHAT, GEN_AI_INVOKE_AGENT, HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op';
+import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
+import { createConversation, sendMessage } from './utils';
+
+const APP = 'node-pi-durable';
+
+test('traces a run as invoke_agent with chat, execute_tool and provider spans', async ({ baseURL }) => {
+ const conversationId = await createConversation(baseURL!);
+ // A run is its own trace, and its `invoke_agent` segment ends last.
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(
+ span =>
+ span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`),
+ ) && spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'get_weather'),
+ );
+
+ const settled = await sendMessage(
+ baseURL!,
+ conversationId,
+ 'What is the weather in Vienna? Use the get_weather tool.',
+ );
+ expect(settled.status, settled.detail).toBe('done');
+
+ const spans = await spansPromise;
+ const agent = spans.find(span => span.is_segment)!;
+ const chats = spans.filter(span => getSpanOp(span) === GEN_AI_CHAT);
+ // The manual span of the tool is the one way to pick the call that ran, should the model call twice.
+ const manualSpan = spans.find(span => span.name === 'resolve-weather')!;
+ const tool = spans.find(span => span.span_id === manualSpan.parent_span_id)!;
+ const providerCalls = spans.filter(span => getSpanOp(span) === HTTP_CLIENT);
+
+ expect(getSpanOp(agent)).toBe(GEN_AI_INVOKE_AGENT);
+ expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
+ // The request that submitted the input is a trace of its own: the scheduler runs the work later.
+ expect(spans.some(span => getSpanOp(span) === HTTP_SERVER)).toBe(false);
+ // pi-ai sends the requests through `@anthropic-ai/sdk`, whose own integration must stay out so
+ // each request is reported once.
+ expect(spans.filter(span => String(span.attributes[SENTRY_ORIGIN]?.value).startsWith('auto.ai.'))).toEqual(
+ spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable'),
+ );
+
+ // One tool-calling response, then the answer.
+ expect(chats.length).toBeGreaterThanOrEqual(2);
+ for (const chat of chats) {
+ expect(chat.parent_span_id).toBe(agent.span_id);
+ expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
+ expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('openrouter');
+ expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('anthropic/claude-haiku-4.5');
+ // pi-durable retries a provider error inside the run; such a request has no usage and no HTTP span
+ // of its own to assert on.
+ if (chat.status === 'ok') {
+ expect(typeof chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe('number');
+ expect(typeof chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe('number');
+ expect(typeof chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toBe('number');
+ expect(providerCalls.some(providerCall => providerCall.parent_span_id === chat.span_id)).toBe(true);
+ }
+ }
+
+ // The request after the tool round carries the tool call and its result in the conventions shape.
+ const answerInput = JSON.parse(
+ String(
+ chats.find(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["stop"]')!.attributes[
+ GEN_AI_INPUT_MESSAGES
+ ]?.value,
+ ),
+ ) as { role: string; parts: { type: string }[] }[];
+ expect(answerInput.map(message => message.role)).toContain('tool');
+ expect(answerInput.flatMap(message => message.parts.map(part => part.type))).toEqual(
+ expect.arrayContaining(['tool_call', 'tool_call_response']),
+ );
+
+ expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
+ expect(tool.parent_span_id).toBe(agent.span_id);
+ expect(tool.status).toBe('ok');
+ expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toContain('Vienna');
+ expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('21 degrees and sunny in');
+
+ // The provider's HTTP calls nest inside the `chat` span that sent them.
+ expect(providerCalls.length).toBeGreaterThan(0);
+ for (const providerCall of providerCalls) {
+ expect(chats.map(chat => chat.span_id)).toContain(providerCall.parent_span_id);
+ expect(providerCall.attributes[SERVER_ADDRESS]?.value).toBe('openrouter.ai');
+ }
+});
+
+test('reports a throwing tool as an error on its execute_tool span', async ({ baseURL }) => {
+ const conversationId = await createConversation(baseURL!);
+ const errorPromise = waitForError(
+ APP,
+ event => event.exception?.values?.[0]?.value === 'Intentional pi-durable tool failure',
+ );
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(
+ span =>
+ span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`),
+ ) && spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'fail_now'),
+ );
+
+ const settled = await sendMessage(baseURL!, conversationId, 'Call the fail_now tool, then tell me what happened.');
+ expect(settled.status, settled.detail).toBe('done');
+
+ const [error, spans] = await Promise.all([errorPromise, spansPromise]);
+ // The error is reported on the span of the call that threw.
+ const tool = spans.find(span => span.span_id === error.contexts?.trace?.span_id)!;
+
+ expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('fail_now');
+ expect(tool.status).toBe('error');
+ expect(tool.trace_id).toBe(error.contexts?.trace?.trace_id);
+ expect(error.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+});
+
+test('nests a subagent run under the tool call that delegated to it', async ({ baseURL }) => {
+ const conversationId = await createConversation(baseURL!);
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(
+ span =>
+ span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`),
+ ) && spansOfTrace.some(span => !span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT),
+ );
+
+ const settled = await sendMessage(
+ baseURL!,
+ conversationId,
+ 'Use the delegate tool to ask a subagent: "What is 2 + 2? Reply with only the number." Then reply with its answer.',
+ );
+ expect(settled.status, settled.detail).toBe('done');
+
+ const spans = await spansPromise;
+ const agent = spans.find(span => span.is_segment)!;
+ const delegate = spans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'delegate')!;
+ const subagent = spans.find(span => !span.is_segment && getSpanOp(span) === GEN_AI_INVOKE_AGENT)!;
+ const subagentChat = spans.find(span => getSpanOp(span) === GEN_AI_CHAT && span.parent_span_id === subagent.span_id);
+
+ expect(delegate.parent_span_id).toBe(agent.span_id);
+ expect(subagent.parent_span_id).toBe(delegate.span_id);
+ expect(subagentChat).toBeDefined();
+
+ // The subagent has a conversation of its own, in the same Harness.
+ const [agentHarness, agentConversation] = String(agent.attributes[GEN_AI_CONVERSATION_ID]?.value).split(':');
+ const [subagentHarness, subagentConversation] = String(subagent.attributes[GEN_AI_CONVERSATION_ID]?.value).split(':');
+ expect(subagentHarness).toBe(agentHarness);
+ expect(subagentConversation).not.toBe(agentConversation);
+});
+
+test('resumes a run in a new trace after the server crashes during a tool call', async ({ baseURL }) => {
+ const conversationId = await createConversation(baseURL!);
+ // The tool span of the crashed process never ends, so a finished `crash_once` span can only come
+ // from the rerun in the restarted server.
+ const spansPromise = collectStreamedSpans(
+ APP,
+ spansOfTrace =>
+ spansOfTrace.some(
+ span =>
+ span.is_segment && String(span.attributes[GEN_AI_CONVERSATION_ID]?.value).endsWith(`:${conversationId}`),
+ ) && spansOfTrace.some(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once' && span.status === 'ok'),
+ );
+
+ const settled = await sendMessage(
+ baseURL!,
+ conversationId,
+ 'Run the crash_once tool for job "nightly", then reply with the word DONE.',
+ );
+ expect(settled.status, settled.detail).toBe('done');
+
+ const spans = await spansPromise;
+ const agent = spans.find(span => span.is_segment)!;
+ const tool = spans.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'crash_once')!;
+ const chats = spans.filter(span => getSpanOp(span) === GEN_AI_CHAT);
+ const answer = chats.find(span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["stop"]');
+
+ expect(getSpanOp(agent)).toBe(GEN_AI_INVOKE_AGENT);
+ expect(tool.parent_span_id).toBe(agent.span_id);
+ expect(answer?.parent_span_id).toBe(agent.span_id);
+ // The request that called the tool ran in the crashed process, so this trace starts with the rerun
+ // of the tool. A run that never crashed would start with that request.
+ expect(chats.every(chat => chat.start_timestamp >= tool.start_timestamp)).toBe(true);
+});
diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts
new file mode 100644
index 000000000000..8f2754169431
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/utils.ts
@@ -0,0 +1,47 @@
+import { expect } from '@playwright/test';
+
+export async function createConversation(baseURL: string): Promise {
+ const res = await fetch(`${baseURL}/conversations`, { method: 'POST' });
+ expect(res.status).toBe(200);
+ return ((await res.json()) as { id: number }).id;
+}
+
+/**
+ * Send a message and wait until pi-durable settles it.
+ *
+ * The server only admits the input; the run continues in the background, and in the crash test in a
+ * second server process. Polling the submission until it settles keeps each test to its own run, and
+ * failed requests while the server restarts are retried.
+ */
+export async function sendMessage(
+ baseURL: string,
+ conversationId: number,
+ content: string,
+): Promise<{ status: string; reason?: string; detail?: string }> {
+ const res = await fetch(`${baseURL}/conversations/${conversationId}/messages`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content }),
+ });
+ expect(res.status).toBe(200);
+ const { submissionId } = (await res.json()) as { submissionId: number };
+
+ const deadline = Date.now() + 90_000;
+ while (Date.now() < deadline) {
+ try {
+ const submission = (await (await fetch(`${baseURL}/submissions/${submissionId}`)).json()) as {
+ status: string;
+ reason?: string;
+ detail?: string;
+ };
+ if (submission.status === 'done' || submission.status === 'unanswered') {
+ return submission;
+ }
+ } catch {
+ // The server is restarting.
+ }
+ await new Promise(resolve => setTimeout(resolve, 250));
+ }
+
+ throw new Error(`Submission ${submissionId} did not settle within 90s`);
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts
index 0b9ff087dfde..b35b9ea1c498 100644
--- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/soft-navigation-web-vitals.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
+import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils';
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
// that triggered it, so it only holds while the navigation span is started before the interaction's
@@ -17,6 +17,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea
await page.goto('/');
await page.locator('#navigation').click();
+ await waitForSoftNavigation(page);
+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
// is reported for it until the page goes away.
await hidePage(page);
diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts
index a508443ca3f0..ee333b3c8820 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/soft-navigation-web-vitals.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
+import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils';
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
// that triggered it, so it only holds while the navigation span is started before the interaction's
@@ -16,6 +16,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea
await page.goto('/');
await page.click('#navigation');
+ await waitForSoftNavigation(page);
+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
// is reported for it until the page goes away.
await hidePage(page);
diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts
index 70f77fb7627f..393a3e8e3195 100644
--- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts
+++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts
@@ -15,6 +15,25 @@ test('sends a pageload span', async ({ page }) => {
expect(span.attributes?.['sentry.segment.name.source']?.value).toBe('route');
});
+test('continues the server trace on a page load', async ({ page }) => {
+ const serverSpanPromise = waitForStreamedSpan(
+ APP_NAME,
+ span =>
+ getSpanOp(span) === 'http.server' && span.is_segment === true && span.attributes?.['http.route']?.value === '/',
+ );
+ const pageloadPromise = waitForStreamedSpan(
+ APP_NAME,
+ span => getSpanOp(span) === 'pageload' && span.is_segment === true,
+ );
+
+ await page.goto('/');
+
+ const [serverSpan, pageload] = await Promise.all([serverSpanPromise, pageloadPromise]);
+ // The document's response carried the trace in `Server-Timing`, which the browser SDK picks up.
+ expect(pageload.trace_id).toBe(serverSpan.trace_id);
+ expect(pageload.parent_span_id).toBe(serverSpan.span_id);
+});
+
test('parameterizes a page load on a route with params', async ({ page }) => {
const spanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment === true);
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/.gitignore
new file mode 100644
index 000000000000..4096cd11d279
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+
+# Mock Sentry server artifacts
+.tmp_*
+/tmp_mock_chunks
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts
new file mode 100644
index 000000000000..697c4437532d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/assert-build.ts
@@ -0,0 +1,100 @@
+import * as assert from 'node:assert/strict';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import {
+ findInjectedDebugIds,
+ findSourceMapFiles,
+ findSourceMappingUrlComments,
+ getArtifactBundles,
+ getAssembleRequests,
+ getChunkUploadPosts,
+ getDebugIdPairs,
+ getSourcemaps,
+ loadMockServerResults,
+} from '@sentry-internal/test-utils';
+
+const BUILD_OUTPUT = 'build';
+const CLIENT_OUTPUT = path.join(BUILD_OUTPUT, 'client');
+const SERVER_OUTPUT = path.join(BUILD_OUTPUT, 'server');
+
+/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
+const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
+const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
+
+/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. */
+const debugIdsWithoutSourceMap = new Set(
+ fs.existsSync('.tmp_debug_ids_without_source_map')
+ ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean)
+ : [],
+);
+
+const requests = loadMockServerResults();
+
+console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
+for (const request of requests) {
+ console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
+}
+console.log('');
+
+assert.ok(
+ requests.some(r => r.authorization.includes('fake-auth-token')),
+ 'Expected requests with the configured auth token',
+);
+
+assert.ok(
+ requests.some(r => r.url?.includes('/releases') && r.method === 'POST'),
+ 'Expected a POST to create the release',
+);
+
+assert.ok(
+ getChunkUploadPosts(requests).some(r => r.bodySize > 0),
+ 'Expected at least one chunk upload POST with a non-empty body',
+);
+
+const assembleRequests = getAssembleRequests(requests);
+assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
+for (const request of assembleRequests) {
+ assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
+ assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release');
+}
+
+const bundles = getArtifactBundles(requests);
+assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
+
+const sourcemaps = getSourcemaps(bundles);
+
+const containsMarker = (marker: string): boolean =>
+ sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker)));
+
+assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source');
+assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source');
+
+// The auto-instrument plugin loads wrapped modules under this suffix. It must be stripped before
+// upload, or the uploaded sources don't match the frames Sentry receives.
+const wrappedSources = sourcemaps
+ .flatMap(map => map.sourcemap.sources ?? [])
+ .filter(source => source.includes('?sentry-auto-wrap'));
+assert.deepEqual(wrappedSources, [], 'Expected no uploaded source to carry the auto-wrap query suffix');
+
+const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
+assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');
+
+for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) {
+ const injectedDebugIds = findInjectedDebugIds({ outputDir });
+ assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);
+
+ const unuploaded = injectedDebugIds.filter(
+ debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId),
+ );
+ assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
+}
+
+// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload.
+assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`);
+assert.deepEqual(
+ findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }),
+ [],
+ `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`,
+);
+
+console.log('All sourcemap assertions passed!');
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json
new file mode 100644
index 000000000000..93da7029d773
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "sveltekit-3-sourcemaps",
+ "description": "E2E test app asserting what the SvelteKit SDK uploads to Sentry and what it leaves behind in `build`.",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "node assert-build.ts"
+ },
+ "dependencies": {
+ "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz"
+ },
+ "devDependencies": {
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@sveltejs/adapter-node": "^6.0.0",
+ "@sveltejs/kit": "^3.0.0",
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
+ "@types/node": "^22.20.0",
+ "svelte": "^5.57.1",
+ "typescript": "^6.0.0",
+ "vite": "^8.0.12"
+ },
+ "volta": {
+ "node": "22.20.0",
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.d.ts
new file mode 100644
index 000000000000..ede601ab93e2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/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-3-sourcemaps/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.html
new file mode 100644
index 000000000000..023060c2d49d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.client.ts
new file mode 100644
index 000000000000..f3bbf5d7b179
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.client.ts
@@ -0,0 +1,7 @@
+import * as Sentry from '@sentry/sveltekit';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+});
+
+export const handleError = Sentry.handleErrorWithSentry();
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.server.ts
new file mode 100644
index 000000000000..0865342abcf1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/hooks.server.ts
@@ -0,0 +1,5 @@
+import * as Sentry from '@sentry/sveltekit';
+
+export const handleError = Sentry.handleErrorWithSentry();
+
+export const handle = Sentry.sentryHandle();
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/instrumentation.server.ts
new file mode 100644
index 000000000000..372d3cceff73
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/instrumentation.server.ts
@@ -0,0 +1,6 @@
+import * as Sentry from '@sentry/sveltekit';
+
+// SvelteKit 3 loads this file before the app, which is where `Sentry.init` belongs.
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+});
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/+page.svelte
new file mode 100644
index 000000000000..dec5cf04b490
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/+page.svelte
@@ -0,0 +1,8 @@
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.server.ts
new file mode 100644
index 000000000000..131d44a2f2a0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.server.ts
@@ -0,0 +1,4 @@
+// SOURCEMAP_MARKER_SERVER
+export const load = async () => {
+ throw new Error('Server load error');
+};
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.svelte
new file mode 100644
index 000000000000..089965d0419f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/src/routes/server-error/+page.svelte
@@ -0,0 +1 @@
+Server error
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/start-mock-sentry-server.mjs
new file mode 100644
index 000000000000..7d2ade5cb226
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/start-mock-sentry-server.mjs
@@ -0,0 +1,4 @@
+import { startMockSentryServer } from '@sentry-internal/test-utils';
+
+// Not a hidden dir, which the SDK's default `./.*/**/*.map` cleanup would empty after the upload
+startMockSentryServer({ org: 'test-org', outputDir: 'tmp_mock_chunks' });
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/static/favicon.png b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/static/favicon.png
new file mode 100644
index 000000000000..825b9e65af7c
Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/static/favicon.png differ
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/tsconfig.json
new file mode 100644
index 000000000000..fb327998a0ba
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "$app/tsconfig",
+ "include": ["src"],
+ "compilerOptions": {
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/vite.config.js
new file mode 100644
index 000000000000..c826e74cce72
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/vite.config.js
@@ -0,0 +1,44 @@
+import adapter from '@sveltejs/adapter-node';
+import { sentrySvelteKit } from '@sentry/sveltekit/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+import * as fs from 'node:fs';
+import { defineConfig } from 'vite';
+
+// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug
+// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload.
+function recordChunksWithoutSourceMap() {
+ return {
+ name: 'record-chunks-without-source-map',
+ apply: 'build',
+ enforce: 'post',
+ writeBundle(_options, bundle) {
+ for (const output of Object.values(bundle)) {
+ if (output.type !== 'chunk' || bundle[`${output.fileName}.map`]) {
+ continue;
+ }
+ for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
+ fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1].toLowerCase()}\n`);
+ }
+ }
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [
+ sentrySvelteKit({
+ sentryUrl: 'http://localhost:3032',
+ authToken: 'fake-auth-token',
+ org: 'test-org',
+ project: 'test-project',
+ release: { name: 'test-release' },
+ debug: true,
+ }),
+ sveltekit({
+ preprocess: vitePreprocess(),
+ adapter: adapter(),
+ }),
+ recordChunksWithoutSourceMap(),
+ ],
+});
diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts
index d9d388ec6657..f451b532c597 100644
--- a/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts
+++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/soft-navigation-web-vitals.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
+import { collectStreamedSpans, getSpanOp, hidePage, waitForSoftNavigation } from '@sentry-internal/test-utils';
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
// that triggered it, so it only holds while the navigation span is started before the interaction's
@@ -15,6 +15,8 @@ test('attributes soft navigation web vitals to the navigation span they were mea
await page.goto('/');
await page.locator('#navLink').click();
+ await waitForSoftNavigation(page);
+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
// is reported for it until the page goes away.
await hidePage(page);
diff --git a/dev-packages/node-integration-tests/README.md b/dev-packages/node-integration-tests/README.md
index e8e9aedf5620..ac35f0ea54c0 100644
--- a/dev-packages/node-integration-tests/README.md
+++ b/dev-packages/node-integration-tests/README.md
@@ -34,6 +34,35 @@ Nock interceptors are internally used to capture envelope requests by `getEnvelo
be used inside the test cases to intercept requests but should be removed before the test ends, as not to cause
flakiness.
+### Collecting streamed spans
+
+Register collectors before starting the scenario. `collectStreamedSpansUntilSegment` collects spans across envelopes
+until it sees a segment matching a name or predicate, then returns the collected spans from that trace:
+
+```ts
+const runner = createRunner(__dirname, 'scenario.ts');
+const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+await runner.start().completed();
+
+const spans = await spansPromise;
+expect(spans.filter(span => !span.is_segment)).toHaveLength(2);
+```
+
+A segment does not guarantee that all its children have arrived. If a scenario sends children later, use
+`collectStreamedSpans` with a predicate describing which spans must be present:
+
+```ts
+const spansPromise = runner.collectStreamedSpans(
+ spans => spans.some(span => span.is_segment && span.name === 'main') && spans.length >= 3,
+);
+```
+
+The predicate is evaluated separately for each trace. Returning `false` keeps waiting; throwing fails the runner
+immediately, including in unordered mode. Keep assertions after collection so incorrect attributes produce assertion
+failures. A predicate that never matches still fails on scenario exit or timeout. Neither helper waits for spans
+arriving after its condition is satisfied.
+
## Other Runtimes
`dev-packages/bun-integration-tests` and `dev-packages/deno-integration-tests` run every suite of this package on Bun
diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts
index e12bf1ecaff2..951a0e437e38 100644
--- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts
+++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts
@@ -258,18 +258,12 @@ describe('awsIntegration', () => {
'instrument.mjs',
(createTestRunner, test) => {
test('auto-instruments aws-sdk service operations', { timeout: 90_000 }, async () => {
- const spans: SerializedStreamedSpanContainer['items'] = [];
- await createTestRunner()
- .ignore('event')
- .unordered()
- .expect({
- span: container => {
- spans.push(...container.items);
- assertAwsServiceSpans(spans);
- },
- })
- .start()
- .completed();
+ const runner = createTestRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('Test Transaction');
+
+ await runner.start().completed();
+
+ assertAwsServiceSpans(await spansPromise);
});
},
{ additionalDependencies },
diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs
index 14dbd52b3824..dcfc5fc614ed 100644
--- a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/aws-serverless';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts
index af7e80bd10b2..a7bc374499b4 100644
--- a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts
+++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts
@@ -1,3 +1,16 @@
+import {
+ GEN_AI_OPERATION_NAME,
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_REQUEST_MAX_TOKENS,
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_REQUEST_TEMPERATURE,
+ GEN_AI_REQUEST_TOP_P,
+ GEN_AI_RESPONSE_FINISH_REASONS,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -5,37 +18,38 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn
const MODEL_ID = 'anthropic.claude-3-5-sonnet-20240620-v1:0';
function assertBedrockSpans(container: SerializedStreamedSpanContainer): void {
+ const segment = container.items.find(span => span.is_segment && span.name === 'Test Transaction');
+ expect(segment).toBeDefined();
+
// Converse (non-streaming)
const converseSpan = container.items.find(span => span.name === `chat ${MODEL_ID}`);
- expect(converseSpan).toBeDefined();
expect(converseSpan!.status).toBe('ok');
- expect(converseSpan!.attributes['sentry.origin'].value).toBe('auto.aws.aws_sdk');
- expect(converseSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(converseSpan!.attributes['gen_ai.provider.name'].value).toBe('aws.bedrock');
- expect(converseSpan!.attributes['gen_ai.operation.name'].value).toBe('chat');
- expect(converseSpan!.attributes['gen_ai.request.model'].value).toBe(MODEL_ID);
- expect(converseSpan!.attributes['gen_ai.request.max_tokens'].value).toBe(100);
- expect(converseSpan!.attributes['gen_ai.request.temperature'].value).toBe(0.5);
- expect(converseSpan!.attributes['gen_ai.request.top_p'].value).toBe(0.9);
- expect(converseSpan!.attributes['gen_ai.usage.input_tokens'].value).toBe(12);
- expect(converseSpan!.attributes['gen_ai.usage.output_tokens'].value).toBe(8);
- expect(converseSpan!.attributes['gen_ai.response.finish_reasons'].value).toEqual(['end_turn']);
+ expect(converseSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.aws.aws_sdk');
+ expect(converseSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(converseSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('aws.bedrock');
+ expect(converseSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(converseSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe(MODEL_ID);
+ expect(converseSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(converseSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.5);
+ expect(converseSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(converseSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(12);
+ expect(converseSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(8);
+ expect(converseSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toEqual(['end_turn']);
// InvokeModel (non-streaming, anthropic.claude request/response body)
const invokeModelSpan = container.items.find(span => span.name === `generate_content ${MODEL_ID}`);
- expect(invokeModelSpan).toBeDefined();
expect(invokeModelSpan!.status).toBe('ok');
- expect(invokeModelSpan!.attributes['sentry.origin'].value).toBe('auto.aws.aws_sdk');
- expect(invokeModelSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(invokeModelSpan!.attributes['gen_ai.provider.name'].value).toBe('aws.bedrock');
- expect(invokeModelSpan!.attributes['gen_ai.operation.name'].value).toBe('generate_content');
- expect(invokeModelSpan!.attributes['gen_ai.request.model'].value).toBe(MODEL_ID);
- expect(invokeModelSpan!.attributes['gen_ai.request.max_tokens'].value).toBe(100);
- expect(invokeModelSpan!.attributes['gen_ai.request.temperature'].value).toBe(0.5);
- expect(invokeModelSpan!.attributes['gen_ai.request.top_p'].value).toBe(0.9);
- expect(invokeModelSpan!.attributes['gen_ai.usage.input_tokens'].value).toBe(15);
- expect(invokeModelSpan!.attributes['gen_ai.usage.output_tokens'].value).toBe(9);
- expect(invokeModelSpan!.attributes['gen_ai.response.finish_reasons'].value).toEqual(['end_turn']);
+ expect(invokeModelSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.aws.aws_sdk');
+ expect(invokeModelSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(invokeModelSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('aws.bedrock');
+ expect(invokeModelSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(invokeModelSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe(MODEL_ID);
+ expect(invokeModelSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(invokeModelSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.5);
+ expect(invokeModelSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(invokeModelSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
+ expect(invokeModelSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(9);
+ expect(invokeModelSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toEqual(['end_turn']);
}
describe('awsIntegration - Bedrock', () => {
@@ -49,12 +63,7 @@ describe('awsIntegration - Bedrock', () => {
'instrument.mjs',
(createTestRunner, test) => {
test('auto-instruments Bedrock Converse and InvokeModel', { timeout: 90_000 }, async () => {
- await createTestRunner()
- .ignore('event')
- .expect({ transaction: transaction => expect(transaction.transaction).toBe('Test Transaction') })
- .expect({ span: assertBedrockSpans })
- .start()
- .completed();
+ await createTestRunner().ignore('event').expect({ span: assertBedrockSpans }).start().completed();
});
},
{ additionalDependencies: { '@aws-sdk/client-bedrock-runtime': '^3.1046.0' } },
diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts
index 965985ea78d0..07d5b7a82de2 100644
--- a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts
+++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/traces-sampler-throws/test.ts
@@ -24,14 +24,12 @@ test('records a client report and no error event when tracesSampler throws', asy
});
test('sends the span when tracesSampler throws but tracesSampleRate is 1', async () => {
- await createRunner(__dirname, 'scenario-fallback.ts')
- .unordered()
- .expect({
- span: container => {
- const segment = container.items.find(span => span.is_segment);
- expect(segment?.name).toBe('sampled via tracesSampleRate fallback');
- },
- })
- .start()
- .completed();
+ const runner = createRunner(__dirname, 'scenario-fallback.ts');
+ const spansPromise = runner.collectStreamedSpansUntilSegment();
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ const segment = spans.find(span => span.is_segment);
+ expect(segment?.name).toBe('sampled via tracesSampleRate fallback');
});
diff --git a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts
index b62c330ce81e..e2caf9ed4fc1 100644
--- a/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts
+++ b/dev-packages/node-integration-tests/suites/featureFlags/featureFlagsIntegration/onSpan/test.ts
@@ -1,4 +1,4 @@
-import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN, type SerializedStreamedSpanContainer } from '@sentry/core';
+import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core';
import { afterAll, expect, test } from 'vitest';
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
@@ -12,29 +12,25 @@ test('Flags captured on span attributes with max limit', async () => {
for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) {
expectedFlags[`flag.evaluation.feat${i}`] = { type: 'boolean', value: i === 3 };
}
- const spans: SerializedStreamedSpanContainer['items'] = [];
- await createRunner(__dirname, 'scenario.ts')
- .unordered()
- .expect({
- span: container => {
- spans.push(...container.items);
- const children = spans.filter(span => !span.is_segment);
- expect(children).toHaveLength(2);
- expect(children).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- name: 'test-span',
- attributes: expect.objectContaining({}),
- }),
- expect.objectContaining({
- name: 'test-nested-span',
- attributes: expect.objectContaining(expectedFlags),
- }),
- ]),
- );
- },
- })
- .start()
- .completed();
+ const runner = createRunner(__dirname, 'scenario.ts');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('test-root-span');
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ const children = spans.filter(span => !span.is_segment);
+ expect(children).toHaveLength(2);
+ expect(children).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'test-span',
+ attributes: expect.objectContaining({}),
+ }),
+ expect.objectContaining({
+ name: 'test-nested-span',
+ attributes: expect.objectContaining(expectedFlags),
+ }),
+ ]),
+ );
});
diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts
index 8f5d4fdbdf9b..75a920581eec 100644
--- a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts
+++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts
@@ -6,29 +6,27 @@ afterAll(() => {
});
test('GrowthBook flags are added to active span attributes on span end', async () => {
- await createRunner(__dirname, 'scenario.ts')
- .unordered()
- .expect({
- span: container => {
- expect(container.items).toHaveLength(1);
- const span = container.items.find(item => item.is_segment);
- expect(span).toMatchObject({
- name: 'test-span',
- status: 'ok',
- span_id: expect.stringMatching(/[a-f\d]{16}/),
- trace_id: expect.stringMatching(/[a-f\d]{32}/),
- attributes: {
- 'flag.evaluation.feat1': { type: 'boolean', value: true },
- 'flag.evaluation.feat2': { type: 'boolean', value: false },
- 'flag.evaluation.bool-feat': { type: 'boolean', value: true },
- 'sentry.op': { type: 'string', value: 'function' },
- 'sentry.origin': { type: 'string', value: 'manual' },
- },
- });
- // string-feat should NOT be here since it's not boolean
- expect(span?.attributes['flag.evaluation.string-feat']).toBeUndefined();
- },
- })
- .start()
- .completed();
+ const runner = createRunner(__dirname, 'scenario.ts');
+ const spansPromise = runner.collectStreamedSpansUntilSegment();
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ expect(spans).toHaveLength(1);
+ const span = spans.find(item => item.is_segment);
+ expect(span).toMatchObject({
+ name: 'test-span',
+ status: 'ok',
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ trace_id: expect.stringMatching(/[a-f\d]{32}/),
+ attributes: {
+ 'flag.evaluation.feat1': { type: 'boolean', value: true },
+ 'flag.evaluation.feat2': { type: 'boolean', value: false },
+ 'flag.evaluation.bool-feat': { type: 'boolean', value: true },
+ 'sentry.op': { type: 'string', value: 'function' },
+ 'sentry.origin': { type: 'string', value: 'manual' },
+ },
+ });
+ // string-feat should NOT be here since it's not boolean
+ expect(span?.attributes['flag.evaluation.string-feat']).toBeUndefined();
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts
index fade84b952a4..9e162e07490c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts
@@ -1,4 +1,19 @@
-import type { TransactionEvent } from '@sentry/core';
+import {
+ MESSAGING_DESTINATION_NAME,
+ MESSAGING_OPERATION_NAME,
+ MESSAGING_OPERATION_TYPE,
+ MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY,
+ MESSAGING_SYSTEM,
+ NETWORK_PROTOCOL_NAME,
+ NETWORK_PROTOCOL_VERSION,
+ SENTRY_KIND,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+ URL_FULL,
+} from '@sentry/conventions/attributes';
+import { QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
@@ -8,37 +23,35 @@ import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose
// default (empty) exchange with the queue name as the routing key.
const expectedProducerSpan = (routingKey: string) =>
expect.objectContaining({
- op: 'queue.publish',
- data: expect.objectContaining({
- 'messaging.system': 'rabbitmq',
- 'messaging.operation.name': 'send',
- 'messaging.operation.type': 'send',
- 'messaging.destination.name': routingKey,
- 'messaging.rabbitmq.destination.routing_key': routingKey,
- 'network.protocol.name': 'AMQP',
- 'network.protocol.version': '0.9.1',
- 'server.address': 'localhost',
- 'server.port': 5672,
- 'url.full': 'amqp://sentry:***@localhost:5672/',
- 'sentry.kind': 'producer',
- 'sentry.op': 'queue.publish',
- 'sentry.origin': 'auto.amqplib.publisher',
+ attributes: expect.objectContaining({
+ [MESSAGING_SYSTEM]: { type: 'string', value: 'rabbitmq' },
+ [MESSAGING_OPERATION_NAME]: { type: 'string', value: 'send' },
+ [MESSAGING_OPERATION_TYPE]: { type: 'string', value: 'send' },
+ [MESSAGING_DESTINATION_NAME]: { type: 'string', value: routingKey },
+ [MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY]: { type: 'string', value: routingKey },
+ [NETWORK_PROTOCOL_NAME]: { type: 'string', value: 'AMQP' },
+ [NETWORK_PROTOCOL_VERSION]: { type: 'string', value: '0.9.1' },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5672 },
+ [URL_FULL]: { type: 'string', value: 'amqp://sentry:***@localhost:5672/' },
+ [SENTRY_KIND]: { type: 'string', value: 'producer' },
+ [SENTRY_OP]: { type: 'string', value: QUEUE_PUBLISH },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.publisher' },
}),
status: 'ok',
});
const EXPECTED_MESSAGE_SPAN_CONSUMER = expect.objectContaining({
- op: 'queue.process',
- data: expect.objectContaining({
- 'messaging.system': 'rabbitmq',
+ attributes: expect.objectContaining({
+ [MESSAGING_SYSTEM]: { type: 'string', value: 'rabbitmq' },
// The delivery carries the default exchange (''), so the routing key is the destination.
- 'messaging.destination.name': 'queue1',
- 'messaging.rabbitmq.destination.routing_key': 'queue1',
- 'messaging.operation.name': 'process',
- 'messaging.operation.type': 'process',
- 'sentry.kind': 'consumer',
- 'sentry.op': 'queue.process',
- 'sentry.origin': 'auto.amqplib.consumer',
+ [MESSAGING_DESTINATION_NAME]: { type: 'string', value: 'queue1' },
+ [MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY]: { type: 'string', value: 'queue1' },
+ [MESSAGING_OPERATION_NAME]: { type: 'string', value: 'process' },
+ [MESSAGING_OPERATION_TYPE]: { type: 'string', value: 'process' },
+ [SENTRY_KIND]: { type: 'string', value: 'consumer' },
+ [SENTRY_OP]: { type: 'string', value: QUEUE_PROCESS },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.consumer' },
}),
status: 'ok',
});
@@ -59,40 +72,27 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_
'instrument.mjs',
(createTestRunner, test) => {
test('should be able to send and receive messages', { timeout: 60_000 }, async () => {
- // The producer ('root span') and consumer ('queue1 process') transactions can
- // arrive in any order, so we collect them and assert after both are received.
- const receivedTransactions: TransactionEvent[] = [];
-
await createTestRunner()
.expect({
- transaction: (transaction: TransactionEvent) => {
- receivedTransactions.push(transaction);
- },
- })
- .expect({
- transaction: (transaction: TransactionEvent) => {
- receivedTransactions.push(transaction);
-
- // The producer span is a child of the manually-started 'root span' transaction, so we
- // identify it by its origin rather than by transaction name. The consumer span is its
- // own transaction, identified by the origin on its trace context.
- const producer = receivedTransactions.find(t =>
- t.spans?.some(s => s.data?.['sentry.origin'] === 'auto.amqplib.publisher'),
- );
- const consumer = receivedTransactions.find(
- t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.amqplib.consumer',
+ span: container => {
+ const producer = container.items.find(span => span.is_segment && span.name === 'root span');
+ const consumer = container.items.find(
+ t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer',
);
expect(producer).toBeDefined();
expect(consumer).toBeDefined();
- expect(producer!.transaction).toBe('root span');
- expect(consumer!.transaction).toBe('queue1 process');
+ expect(producer!.name).toBe('root span');
+ expect(consumer!.name).toBe('process queue1');
- const producerSpan = producer!.spans?.find(s => s.data?.['sentry.origin'] === 'auto.amqplib.publisher');
+ const producerSpan = container.items.find(
+ s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher',
+ );
+ expect(producerSpan?.name).toBe('send queue1');
expect(producerSpan).toMatchObject(expectedProducerSpan('queue1'));
- expect(consumer!.contexts?.trace).toMatchObject(EXPECTED_MESSAGE_SPAN_CONSUMER);
+ expect(consumer!).toMatchObject(EXPECTED_MESSAGE_SPAN_CONSUMER);
},
})
.start()
@@ -108,35 +108,23 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_
'instrument.mjs',
(createTestRunner, test) => {
test('marks the consumer span as errored when the message is rejected', { timeout: 60_000 }, async () => {
- // The error scenario emits the producer ('root span') and the rejected consumer
- // ('queue1 process') transactions in any order, so we collect both and assert on the consumer.
- const receivedTransactions: TransactionEvent[] = [];
-
await createTestRunner()
.expect({
- transaction: (transaction: TransactionEvent) => {
- receivedTransactions.push(transaction);
- },
- })
- .expect({
- transaction: (transaction: TransactionEvent) => {
- receivedTransactions.push(transaction);
-
- const consumer = receivedTransactions.find(
- t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.amqplib.consumer',
+ span: container => {
+ const consumer = container.items.find(
+ t => t.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.consumer',
);
expect(consumer).toBeDefined();
- expect(consumer!.transaction).toBe('queue-error process');
- expect(consumer!.contexts?.trace).toMatchObject(
+ expect(consumer!.name).toBe('process queue-error');
+ expect(consumer!).toMatchObject(
expect.objectContaining({
- op: 'queue.process',
- status: 'internal_error',
- data: expect.objectContaining({
- 'messaging.system': 'rabbitmq',
- 'sentry.kind': 'consumer',
- 'sentry.op': 'queue.process',
- 'sentry.origin': 'auto.amqplib.consumer',
+ status: 'error',
+ attributes: expect.objectContaining({
+ [MESSAGING_SYSTEM]: { type: 'string', value: 'rabbitmq' },
+ [SENTRY_KIND]: { type: 'string', value: 'consumer' },
+ [SENTRY_OP]: { type: 'string', value: QUEUE_PROCESS },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.amqplib.consumer' },
}),
}),
);
@@ -160,11 +148,11 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_
async () => {
await createTestRunner()
.expect({
- transaction: (transaction: TransactionEvent) => {
- expect(transaction.transaction).toBe('root span');
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('root span');
- const producerSpans = transaction.spans?.filter(
- s => s.data?.['sentry.origin'] === 'auto.amqplib.publisher',
+ const producerSpans = container.items.filter(
+ s => s.attributes[SENTRY_ORIGIN]?.value === 'auto.amqplib.publisher',
);
// The confirm channel internally calls the base publish; the instrumentation must not
@@ -181,35 +169,4 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_
{ additionalDependencies },
);
});
-
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createTestRunner, test) => {
- test('names streamed spans after the messaging conventions', { timeout: 60_000 }, async () => {
- await createTestRunner()
- .ignore('event')
- .expect({
- span: container => {
- // `sendToQueue` publishes to the default exchange, which has no name. Its routing key is the
- // queue name, so it is the destination rather than per-message data.
- for (const origin of ['auto.amqplib.publisher', 'auto.amqplib.consumer']) {
- const span = container.items.find(item => item.attributes['sentry.origin']?.value === origin);
- expect(span).toBeDefined();
- expect(span!.attributes['messaging.destination.name']?.value).toBe('queue1');
- expect(span!.attributes['messaging.rabbitmq.destination.routing_key']?.value).toBe('queue1');
- }
-
- const producerSpan = container.items.find(
- span => span.attributes['sentry.origin']?.value === 'auto.amqplib.publisher',
- );
- expect(producerSpan!.name).toBe('send queue1');
-
- const consumerSpan = container.items.find(
- span => span.attributes['sentry.origin']?.value === 'auto.amqplib.consumer',
- );
- expect(consumerSpan!.name).toBe('process queue1');
- },
- })
- .start()
- .completed();
- });
- });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-span-streaming.mjs
deleted file mode 100644
index 657bed0a3a8c..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: true, outputs: true } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs
index 1046a4b66191..286186a48620 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
@@ -14,11 +13,4 @@ Sentry.init({
recordOutputs: true,
}),
],
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/anthropic/v1/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs
index fc8f4e266829..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/anthropic/v1/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs
index 9b15a0c635e8..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/anthropic/v1/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts
index 937ae389ca30..ad9ee334d927 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts
@@ -1,3 +1,4 @@
+import type { Event, SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import {
GEN_AI_INPUT_MESSAGES,
@@ -17,6 +18,8 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -33,15 +36,12 @@ describe('Anthropic integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => {
test('preserves .withResponse() and .asResponse() for non-streaming and streaming', async () => {
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
.expect({
span: container => {
- expect(container.items).toHaveLength(6);
- const nonStreamingSpans = container.items.filter(
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(6);
+ const nonStreamingSpans = spans.filter(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_withresponse',
);
expect(nonStreamingSpans).toHaveLength(1);
@@ -52,15 +52,14 @@ describe('Anthropic integration', () => {
expect(span.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
}
- const streamingSpan = container.items.find(
+ const streamingSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream_withresponse',
);
- expect(streamingSpan).toBeDefined();
expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
expect(streamingSpan!.status).toBe('ok');
expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- const rawResponseSpans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined);
+ const rawResponseSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined);
expect(rawResponseSpans).toHaveLength(4);
for (const span of rawResponseSpans) {
expect(span.name).toBe(`chat ${span.attributes[GEN_AI_REQUEST_MODEL].value}`);
@@ -83,158 +82,166 @@ describe('Anthropic integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates anthropic related spans with genAI recording disabled', async () => {
- const runner = createRunner();
+ let receivedEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
+ const runner = createRunner().unordered();
await runner
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const completionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123',
- );
- expect(completionSpan).toBeDefined();
- expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(completionSpan!.status).toBe('ok');
-
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
-
- const streamingSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream123',
- );
- expect(streamingSpan).toBeDefined();
- expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(streamingSpan!.status).toBe('ok');
- expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
+ },
+ })
+ .expect({
+ event: event => {
+ receivedEvent = event;
},
})
- .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE })
.start()
.completed();
+
+ expect(receivedEvent).toMatchObject({ event_id: expect.any(String), ...EXPECTED_STREAM_EVENT_HANDLER_MESSAGE });
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(3);
+ const completionSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123');
+ expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
+ expect(completionSpan!.status).toBe('ok');
+
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
+ expect(errorSpan!.status).toBe('error');
+
+ const streamingSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream123');
+ expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
+ expect(streamingSpan!.status).toBe('ok');
+ expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
});
});
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates anthropic related spans with genAI recording enabled', async () => {
- const runner = createRunner();
+ let receivedEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
+ const runner = createRunner().unordered();
await runner
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const completionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123',
- );
- expect(completionSpan).toBeDefined();
- expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(completionSpan!.status).toBe('ok');
- expect(completionSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(completionSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(completionSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
- expect(completionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(completionSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('claude-3-haiku-20240307');
- expect(completionSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('msg_mock123');
- expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('Hello from Anthropic mock!');
- expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"What is the capital of France?"}]',
- );
- expect(completionSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(completionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(completionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
- expect(completionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
- expect(completionSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(completionSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic');
-
- const errorSpan = container.items.find(
- span => span.attributes[GEN_AI_INPUT_MESSAGES]?.value === '[{"role":"user","content":"This will fail"}]',
- );
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.name).toBe('chat error-model');
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
-
- // TODO: messages.stream() should produce its own distinct gen_ai span, but it
- // currently does not (pre-existing bug). Once fixed, add an additional indexed span assertion.
- const streamingSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream123',
- );
- expect(streamingSpan).toBeDefined();
- expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(streamingSpan!.status).toBe('ok');
- expect(streamingSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(streamingSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
- expect(streamingSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE].value).toBe(true);
- expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- expect(streamingSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('Hello from stream!');
- expect(streamingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(streamingSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
- expect(streamingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
+ },
+ })
+ .expect({
+ event: event => {
+ receivedEvent = event;
},
})
- .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE })
.start()
.completed();
+
+ expect(receivedEvent).toMatchObject({ event_id: expect.any(String), ...EXPECTED_STREAM_EVENT_HANDLER_MESSAGE });
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(3);
+ const completionSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123');
+ expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
+ expect(completionSpan!.status).toBe('ok');
+ expect(completionSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(completionSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(completionSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
+ expect(completionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(completionSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('claude-3-haiku-20240307');
+ expect(completionSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('msg_mock123');
+ expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('Hello from Anthropic mock!');
+ expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
+ '[{"role":"user","content":"What is the capital of France?"}]',
+ );
+ expect(completionSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(completionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(completionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
+ expect(completionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
+ expect(completionSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(completionSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.anthropic');
+
+ const errorSpan = spans.find(
+ span => span.attributes[GEN_AI_INPUT_MESSAGES]?.value === '[{"role":"user","content":"This will fail"}]',
+ );
+ expect(errorSpan!.name).toBe('chat error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
+
+ // TODO: messages.stream() should produce its own distinct gen_ai span, but it
+ // currently does not (pre-existing bug). Once fixed, add an additional indexed span assertion.
+ const streamingSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream123');
+ expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
+ expect(streamingSpan!.status).toBe('ok');
+ expect(streamingSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(streamingSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
+ expect(streamingSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE].value).toBe(true);
+ expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(streamingSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('Hello from stream!');
+ expect(streamingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(streamingSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
+ expect(streamingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
});
});
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => {
test('creates anthropic related spans with custom options', async () => {
- const runner = createRunner();
+ let receivedEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
+ const runner = createRunner().unordered();
await runner
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const completionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123',
- );
- expect(completionSpan).toBeDefined();
- expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(completionSpan!.status).toBe('ok');
- expect(completionSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
-
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
-
- const streamingSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream123',
- );
- expect(streamingSpan).toBeDefined();
- expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(streamingSpan!.status).toBe('ok');
- expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
+ },
+ })
+ .expect({
+ event: event => {
+ receivedEvent = event;
},
})
- .expect({ event: EXPECTED_STREAM_EVENT_HANDLER_MESSAGE })
.start()
.completed();
+
+ expect(receivedEvent).toMatchObject({ event_id: expect.any(String), ...EXPECTED_STREAM_EVENT_HANDLER_MESSAGE });
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(3);
+ const completionSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123');
+ expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
+ expect(completionSpan!.status).toBe('ok');
+ expect(completionSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ expect(completionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
+
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+
+ const streamingSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream123');
+ expect(streamingSpan!.name).toBe('chat claude-3-haiku-20240307');
+ expect(streamingSpan!.status).toBe('ok');
+ expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
});
});
- const EXPECTED_STREAM_SPANS_PII_FALSE = {
- transaction: 'main',
- };
-
- const EXPECTED_STREAM_SPANS_PII_TRUE = {
- transaction: 'main',
- };
-
createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument.mjs', (createRunner, test) => {
test('streams produce spans with token usage and metadata (PII false)', async () => {
await createRunner()
- .expect({ transaction: EXPECTED_STREAM_SPANS_PII_FALSE })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const requestStreamSpans = container.items.filter(
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(3);
+ const requestStreamSpans = spans.filter(
span => span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
);
expect(requestStreamSpans).toHaveLength(2);
@@ -249,7 +256,6 @@ describe('Anthropic integration', () => {
const detailedStreamSpan = requestStreamSpans.find(
span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["end_turn"]',
);
- expect(detailedStreamSpan).toBeDefined();
expect(detailedStreamSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(detailedStreamSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
expect(detailedStreamSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
@@ -258,10 +264,9 @@ describe('Anthropic integration', () => {
expect(detailedStreamSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
expect(detailedStreamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
- const messagesStreamSpan = container.items.find(
+ const messagesStreamSpan = spans.find(
span => span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
);
- expect(messagesStreamSpan).toBeDefined();
expect(messagesStreamSpan!.name).toBe('chat claude-3-haiku-20240307');
expect(messagesStreamSpan!.status).toBe('ok');
expect(messagesStreamSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
@@ -283,11 +288,12 @@ describe('Anthropic integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('streams record response text when PII true', async () => {
await createRunner()
- .expect({ transaction: EXPECTED_STREAM_SPANS_PII_TRUE })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const requestStreamSpans = container.items.filter(
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(3);
+ const requestStreamSpans = spans.filter(
span => span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
);
expect(requestStreamSpans).toHaveLength(2);
@@ -299,10 +305,9 @@ describe('Anthropic integration', () => {
expect(span.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('Hello from stream!');
}
- const messagesStreamSpan = container.items.find(
+ const messagesStreamSpan = spans.find(
span => span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
);
- expect(messagesStreamSpan).toBeDefined();
expect(messagesStreamSpan!.name).toBe('chat claude-3-haiku-20240307');
expect(messagesStreamSpan!.status).toBe('ok');
expect(messagesStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
@@ -333,20 +338,16 @@ describe('Anthropic integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-stream-nested-create.mjs', 'instrument.mjs', (createRunner, test) => {
test('traces a create() invoked from a stream event handler (dedup does not over-suppress)', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- const nestedSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_nested',
- );
- expect(nestedSpan).toBeDefined();
- expect(nestedSpan.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ const nestedSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_nested');
+ expect(nestedSpan.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
// The helper's own internal `create` delegation must be deduped: exactly one span
// for the streamed response, not a duplicate child span.
- const streamingSpans = container.items.filter(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream_1',
- );
+ const streamingSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream_1');
expect(streamingSpans).toHaveLength(1);
},
})
@@ -363,18 +364,17 @@ describe('Anthropic integration', () => {
const EXPECTED_TOOL_CALLS_JSON =
'[{"type":"tool_use","id":"tool_weather_1","name":"weather","input":{"city":"Paris"}}]';
await createRunner()
- .expect({
- transaction: {},
- })
.expect({
span: container => {
- expect(container.items).toHaveLength(1);
- const [firstSpan] = container.items;
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(1);
+ const [firstSpan] = spans;
// [0] messages.create with tools — available tools + tool calls recorded with PII
expect(firstSpan!.name).toBe('chat claude-3-haiku-20240307');
expect(firstSpan!.status).toBe('ok');
- expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(firstSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
expect(firstSpan!.attributes[GEN_AI_TOOL_DEFINITIONS].value).toBe(EXPECTED_TOOLS_JSON);
expect(firstSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS].value).toBe(EXPECTED_TOOL_CALLS_JSON);
},
@@ -392,16 +392,15 @@ describe('Anthropic integration', () => {
const EXPECTED_TOOL_CALLS_JSON =
'[{"type":"tool_use","id":"tool_weather_2","name":"weather","input":{"city":"Paris"}}]';
await createRunner()
- .expect({
- transaction: {},
- })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
- for (const span of container.items) {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(2);
+ for (const span of spans) {
expect(span.name).toBe('chat claude-3-haiku-20240307');
expect(span.status).toBe('ok');
- expect(span.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(span.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
expect(span.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
expect(span.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["tool_use"]');
expect(span.attributes[GEN_AI_TOOL_DEFINITIONS].value).toBe(EXPECTED_TOOLS_JSON);
@@ -409,11 +408,11 @@ describe('Anthropic integration', () => {
}
// messages.create({ stream: true }) carries the request stream param; messages.stream() does not.
- const createStreamSpan = container.items.find(
+ const createStreamSpan = spans.find(
span => span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
);
expect(createStreamSpan).toBeDefined();
- const messagesStreamSpan = container.items.find(
+ const messagesStreamSpan = spans.find(
span => span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
);
expect(messagesStreamSpan).toBeDefined();
@@ -425,10 +424,6 @@ describe('Anthropic integration', () => {
});
// Additional error scenarios - Streaming errors
- const EXPECTED_STREAM_ERROR_SPANS = {
- transaction: 'main',
- };
-
createEsmAndCjsTests(__dirname, 'scenario-stream-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('handles streaming errors correctly', async () => {
await createRunner()
@@ -436,41 +431,38 @@ describe('Anthropic integration', () => {
// being raised as an unhandled rejection, so the instrumentation captures it. This test only
// asserts the spans.
.ignore('event')
- .expect({ transaction: EXPECTED_STREAM_ERROR_SPANS })
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- const createInitErrorSpan = container.items.find(
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(4);
+ const createInitErrorSpan = spans.find(
span =>
span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-stream-init' &&
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
);
- expect(createInitErrorSpan).toBeDefined();
expect(createInitErrorSpan!.name).toBe('chat error-stream-init');
expect(createInitErrorSpan!.status).toBe('error');
- const streamInitErrorSpan = container.items.find(
+ const streamInitErrorSpan = spans.find(
span =>
span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-stream-init' &&
span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
);
- expect(streamInitErrorSpan).toBeDefined();
expect(streamInitErrorSpan!.name).toBe('chat error-stream-init');
expect(streamInitErrorSpan!.status).toBe('error');
- const createMidwayErrorSpan = container.items.find(
+ const createMidwayErrorSpan = spans.find(
span => span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-stream-midway' && span.status === 'ok',
);
- expect(createMidwayErrorSpan).toBeDefined();
expect(createMidwayErrorSpan!.name).toBe('chat error-stream-midway');
expect(createMidwayErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE].value).toBe(true);
expect(createMidwayErrorSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
expect(createMidwayErrorSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('This stream will ');
- const streamMidwayErrorSpan = container.items.find(
+ const streamMidwayErrorSpan = spans.find(
span => span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-stream-midway' && span.status === 'error',
);
- expect(streamMidwayErrorSpan).toBeDefined();
expect(streamMidwayErrorSpan!.name).toBe('chat error-stream-midway');
expect(streamMidwayErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
},
@@ -483,18 +475,17 @@ describe('Anthropic integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('handles tool errors correctly', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
- const invalidFormatSpan = container.items.find(span => span.name === 'chat invalid-format');
- expect(invalidFormatSpan).toBeDefined();
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic');
+ expect(spans).toHaveLength(2);
+ const invalidFormatSpan = spans.find(span => span.name === 'chat invalid-format');
expect(invalidFormatSpan!.status).toBe('error');
expect(invalidFormatSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('invalid-format');
- expect(invalidFormatSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(invalidFormatSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
- const toolSuccessSpan = container.items.find(span => span.name === 'chat claude-3-haiku-20240307');
- expect(toolSuccessSpan).toBeDefined();
+ const toolSuccessSpan = spans.find(span => span.name === 'chat claude-3-haiku-20240307');
expect(toolSuccessSpan!.status).toBe('ok');
expect(toolSuccessSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS].value).toContain('tool_ok_1');
},
@@ -512,15 +503,14 @@ describe('Anthropic integration', () => {
test('extracts system instructions from messages', async () => {
const expectedInstructions = JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]);
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
.expect({
span: container => {
- expect(container.items).toHaveLength(1);
- const [firstSpan] = container.items;
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic',
+ );
+ expect(spans).toHaveLength(1);
+ const [firstSpan] = spans;
// [0] messages.create — system instructions extracted into dedicated attribute
expect(firstSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe(expectedInstructions);
@@ -532,53 +522,39 @@ describe('Anthropic integration', () => {
},
);
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('creates anthropic related spans with span streaming enabled', async () => {
- await createRunner()
- .expect({
- span: container => {
- const completionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123',
- );
- expect(completionSpan).toBeDefined();
- expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307');
- expect(completionSpan!.status).toBe('ok');
- expect(completionSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(completionSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307');
- expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"What is the capital of France?"}]',
- );
- expect(completionSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(completionSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(completionSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic');
- },
- })
- .start()
- .completed();
- });
- });
-
createEsmAndCjsTests(__dirname, 'scenario-response-error.mjs', 'instrument.mjs', (createRunner, test) => {
test('captures error-shaped responses returned as data', async () => {
+ let receivedEvent: Event | undefined;
+
await createRunner()
// The API returns the error as data on a 200 response, never as a thrown error to the caller,
// so the instrumentation intentionally captures it as an event.
.unordered()
.expect({
- event: {
- exception: {
- values: [
- {
- value: 'Overloaded',
- mechanism: { type: 'auto.ai.anthropic.anthropic_error', handled: false },
- },
- ],
- },
+ event: event => {
+ receivedEvent = event;
+ },
+ })
+ .expect({
+ span: container => {
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
},
})
- .expect({ transaction: { transaction: 'main' } })
.start()
.completed();
+
+ expect(receivedEvent).toMatchObject({
+ event_id: expect.any(String),
+ exception: {
+ values: [
+ {
+ value: 'Overloaded',
+ mechanism: { type: 'auto.ai.anthropic.anthropic_error', handled: false },
+ },
+ ],
+ },
+ });
});
});
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs
index 9b15a0c635e8..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/anthropic/v1/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts
index 04f9cf75d98a..05eac304d0e1 100644
--- a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts
@@ -1,4 +1,4 @@
-import { GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING } from '@sentry/conventions/attributes';
+import { GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING, SENTRY_OP } from '@sentry/conventions/attributes';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
@@ -18,14 +18,14 @@ describe('Anthropic integration (0.129)', () => {
(createRunner, test) => {
test('emits one span for messages.stream(), not a second one for its internal create', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
const streamingSpans = container.items.filter(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream_1',
);
expect(streamingSpans).toHaveLength(1);
- expect(streamingSpans[0]!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' });
+ expect(streamingSpans[0]!.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'gen_ai.chat' });
expect(streamingSpans[0]!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
type: 'boolean',
value: true,
@@ -48,16 +48,17 @@ describe('Anthropic integration (0.129)', () => {
createEsmAndCjsTests(
__dirname,
'scenario-beta-stream-helpers.mjs',
- 'instrument-default-lifecycle.mjs',
+ 'instrument.mjs',
(createRunner, test) => {
test('emits one span each for beta.messages.stream() and the eager streaming tool runner', async () => {
await createRunner()
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
for (const id of ['msg_beta_stream', 'msg_tool_runner_eager']) {
const spans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === id);
expect(spans, id).toHaveLength(1);
- expect(spans[0]!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' });
+ expect(spans[0]!.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'gen_ai.chat' });
}
},
})
diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs
index 22bf57f14364..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts
index b49004cf02a9..620a4311d083 100644
--- a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts
@@ -1,4 +1,13 @@
-import type { SerializedStreamedSpanContainer } from '@sentry/core';
+import {
+ CACHE_KEY,
+ CACHE_OPERATION,
+ DB_COLLECTION_NAME,
+ DB_OPERATION_NAME,
+ SENTRY_KIND,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -6,8 +15,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn
// enabled (via INJECT_ORCHESTRION) the OTel `Dataloader` integration is swapped for the
// diagnostics-channel one, which stamps a different origin.
const ORIGIN = 'auto.db.dataloader';
-const CACHE_GET_OP = 'cache.get';
-const CACHE_MUTATION_OPS = { prime: 'cache.put', clear: 'cache.remove', clearAll: 'cache.remove' } as const;
+const CACHE_MUTATION_OPS = { prime: CACHE_PUT, clear: CACHE_REMOVE, clearAll: CACHE_REMOVE } as const;
describe('dataloader auto-instrumentation', () => {
afterAll(() => {
@@ -18,30 +26,28 @@ describe('dataloader auto-instrumentation', () => {
test('instruments load, loadMany, batch, prime, clear and clearAll', async () => {
const runner = createRunner()
.expect({
- transaction: event => {
- expect(event.transaction).toBe('GET /load');
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('GET /load');
- const spans = event.spans || [];
+ const spans = container.items;
- const loadSpan = spans.find(span => span.description === 'dataloader.load');
+ const loadSpan = spans.find(span => span.attributes[DB_OPERATION_NAME]?.value === 'load');
expect(loadSpan).toBeDefined();
- expect(loadSpan?.op).toBe(CACHE_GET_OP);
- expect(loadSpan?.origin).toBe(ORIGIN);
+ expect(loadSpan?.attributes[SENTRY_OP]?.value).toBe(CACHE_GET);
+ expect(loadSpan?.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
expect(loadSpan?.status).toBe('ok');
- expect(loadSpan?.data?.['sentry.origin']).toBe(ORIGIN);
- expect(loadSpan?.data?.['sentry.op']).toBe(CACHE_GET_OP);
- expect(loadSpan?.data?.['cache.key']).toEqual(['user-1']);
- expect(loadSpan?.data?.['db.operation.name']).toBe('load');
+ expect(loadSpan?.attributes[CACHE_KEY]?.value).toEqual(['user-1']);
+ expect(loadSpan?.attributes[DB_OPERATION_NAME]?.value).toBe('load');
// A direct operation is a client call; the deferred `batch` below gets no kind
- expect(loadSpan?.data?.['sentry.kind']).toBe('client');
+ expect(loadSpan?.attributes[SENTRY_KIND]?.value).toBe('client');
- const batchSpan = spans.find(span => span.description === 'dataloader.batch');
+ const batchSpan = spans.find(span => span.attributes[DB_OPERATION_NAME]?.value === 'batch');
expect(batchSpan).toBeDefined();
- expect(batchSpan?.op).toBe(CACHE_GET_OP);
- expect(batchSpan?.origin).toBe(ORIGIN);
+ expect(batchSpan?.attributes[SENTRY_OP]?.value).toBe(CACHE_GET);
+ expect(batchSpan?.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
expect(batchSpan?.status).toBe('ok');
- expect(batchSpan?.data?.['cache.key']).toEqual(['user-1']);
- expect(batchSpan?.data?.['sentry.kind']).toBeUndefined();
+ expect(batchSpan?.attributes[CACHE_KEY]?.value).toEqual(['user-1']);
+ expect(batchSpan?.attributes[SENTRY_KIND]?.value).toBeUndefined();
// The batch span links back to the load span that triggered it
expect(batchSpan?.links).toEqual([
@@ -54,57 +60,62 @@ describe('dataloader auto-instrumentation', () => {
// Locks down the async behavior: `load` encloses the deferred `batch` span
expect(batchSpan?.parent_span_id).toBe(loadSpan?.span_id);
expect(loadSpan?.start_timestamp).toBeLessThanOrEqual(batchSpan?.start_timestamp ?? 0);
- expect(loadSpan?.timestamp).toBeGreaterThanOrEqual(batchSpan?.timestamp ?? 0);
+ expect(loadSpan?.end_timestamp).toBeGreaterThanOrEqual(batchSpan?.end_timestamp ?? 0);
},
})
.expect({
- transaction: event => {
- expect(event.transaction).toBe('GET /load-many');
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('GET /load-many');
- const loadManySpan = (event.spans || []).find(span => span.description === 'dataloader.loadMany');
+ const loadManySpan = container.items.find(span => span.attributes[DB_OPERATION_NAME]?.value === 'loadMany');
expect(loadManySpan).toBeDefined();
- expect(loadManySpan?.op).toBe(CACHE_GET_OP);
- expect(loadManySpan?.origin).toBe(ORIGIN);
+ expect(loadManySpan?.attributes[SENTRY_OP]?.value).toBe(CACHE_GET);
+ expect(loadManySpan?.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
expect(loadManySpan?.status).toBe('ok');
- expect(loadManySpan?.data?.['sentry.origin']).toBe(ORIGIN);
- expect(loadManySpan?.data?.['sentry.op']).toBe(CACHE_GET_OP);
- expect(loadManySpan?.data?.['cache.key']).toEqual(['user-1', 'user-2']);
+ expect(loadManySpan?.attributes[CACHE_KEY]?.value).toEqual(['user-1', 'user-2']);
},
})
.expect({
- transaction: event => {
- expect(event.transaction).toBe('GET /cache-ops');
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('GET /cache-ops');
- const spans = event.spans || [];
+ const spans = container.items;
// prime writes to the cache, clear/clearAll remove from it
for (const [operation, op] of Object.entries(CACHE_MUTATION_OPS)) {
- const span = spans.find(s => s.description === `dataloader.${operation}`);
+ const span = spans.find(s => s.attributes[DB_OPERATION_NAME]?.value === operation);
expect(span, `expected a dataloader.${operation} span`).toBeDefined();
- expect(span?.origin).toBe(ORIGIN);
+ expect(span?.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
expect(span?.status).toBe('ok');
- expect(span?.op).toBe(op);
- expect(span?.data?.['sentry.origin']).toBe(ORIGIN);
- expect(span?.data?.['db.operation.name']).toBe(operation);
+ expect(span?.name).toBe(op);
+ expect(span?.attributes[SENTRY_OP]?.value).toBe(op);
+ expect(span?.attributes[DB_OPERATION_NAME]?.value).toBe(operation);
}
// `clearAll` takes no key, the other two act on a single key
- expect(spans.find(s => s.description === 'dataloader.prime')?.data?.['cache.key']).toEqual(['user-1']);
- expect(spans.find(s => s.description === 'dataloader.clear')?.data?.['cache.key']).toEqual(['user-1']);
- expect(spans.find(s => s.description === 'dataloader.clearAll')?.data?.['cache.key']).toBeUndefined();
+ expect(
+ spans.find(s => s.attributes[DB_OPERATION_NAME]?.value === 'prime')?.attributes[CACHE_KEY]?.value,
+ ).toEqual(['user-1']);
+ expect(
+ spans.find(s => s.attributes[DB_OPERATION_NAME]?.value === 'clear')?.attributes[CACHE_KEY]?.value,
+ ).toEqual(['user-1']);
+ expect(
+ spans.find(s => s.attributes[DB_OPERATION_NAME]?.value === 'clearAll')?.attributes[CACHE_KEY]?.value,
+ ).toBeUndefined();
},
})
.expect({
- transaction: event => {
- expect(event.transaction).toBe('GET /named');
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('GET /named');
- // A named dataloader includes its name in the span description
- const namedLoadSpan = (event.spans || []).find(span => span.description === 'dataloader.load usersLoader');
+ const namedLoadSpan = container.items.find(span => span.attributes[DB_OPERATION_NAME]?.value === 'load');
expect(namedLoadSpan).toBeDefined();
- expect(namedLoadSpan?.op).toBe(CACHE_GET_OP);
- expect(namedLoadSpan?.origin).toBe(ORIGIN);
+ expect(namedLoadSpan?.attributes[SENTRY_OP]?.value).toBe(CACHE_GET);
+ expect(namedLoadSpan?.attributes[SENTRY_ORIGIN]?.value).toBe(ORIGIN);
expect(namedLoadSpan?.status).toBe('ok');
- expect(namedLoadSpan?.data?.['db.collection.name']).toBe('usersLoader');
+ expect(namedLoadSpan?.name).toBe(CACHE_GET);
+ expect(namedLoadSpan?.attributes[CACHE_OPERATION]?.value).toBe('get');
+ expect(namedLoadSpan?.attributes[DB_COLLECTION_NAME]?.value).toBe('usersLoader');
},
})
.start();
@@ -115,37 +126,5 @@ describe('dataloader auto-instrumentation', () => {
await runner.makeRequest('get', '/named');
await runner.completed();
}, 30_000);
-
- test('names spans after the cache operation when streamed', async () => {
- const runner = createRunner()
- .withEnv({ STREAMED: 'true' })
- .expect({
- span: (container: SerializedStreamedSpanContainer) => {
- const namedLoadSpan = container.items.find(
- span => span.attributes?.['db.operation.name']?.value === 'load',
- );
- expect(namedLoadSpan?.name).toBe('cache.get');
- expect(namedLoadSpan?.attributes?.['sentry.op']?.value).toBe(CACHE_GET_OP);
- expect(namedLoadSpan?.attributes?.['cache.operation']?.value).toBe('get');
- // The loader name is no longer part of the span name, it moved to `db.collection.name`.
- expect(namedLoadSpan?.attributes?.['db.collection.name']?.value).toBe('usersLoader');
- },
- })
- .expect({
- span: (container: SerializedStreamedSpanContainer) => {
- for (const [operation, op] of Object.entries(CACHE_MUTATION_OPS)) {
- const span = container.items.find(item => item.attributes?.['db.operation.name']?.value === operation);
- expect(span, `expected a ${operation} span`).toBeDefined();
- expect(span?.name).toBe(op);
- expect(span?.attributes?.['sentry.op']?.value).toBe(op);
- }
- },
- })
- .start();
-
- await runner.makeRequest('get', '/named');
- await runner.makeRequest('get', '/cache-ops');
- await runner.completed();
- }, 30_000);
});
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts b/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts
index 5f744edbbf3f..08f30f1545ef 100644
--- a/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/genericPool-v2/test.ts
@@ -1,3 +1,5 @@
+import { ERROR_TYPE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -12,32 +14,30 @@ describe('genericPool v2 auto instrumentation', () => {
'instrument.mjs',
(createRunner, test) => {
test('should auto-instrument `generic-pool` v2 when calling pool.acquire()', async () => {
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
expect.objectContaining({
- description: 'generic-pool.acquire',
- op: 'db',
- origin: 'auto.db.generic_pool',
- data: expect.objectContaining({
- 'sentry.origin': 'auto.db.generic_pool',
+ name: 'generic-pool.acquire',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.generic_pool' },
}),
status: 'ok',
}),
expect.objectContaining({
- description: 'generic-pool.acquire',
- op: 'db',
- origin: 'auto.db.generic_pool',
- data: expect.objectContaining({
- 'sentry.origin': 'auto.db.generic_pool',
+ name: 'generic-pool.acquire',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.generic_pool' },
}),
status: 'ok',
}),
]),
};
- await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
+ await createRunner().expect({ span: EXPECTED_SPANS }).start().completed();
});
},
{ additionalDependencies: { 'generic-pool': '^2.5.0' } },
@@ -49,20 +49,22 @@ describe('genericPool v2 auto instrumentation', () => {
'instrument.mjs',
(createRunner, test) => {
test('marks the `generic-pool.acquire` span as errored when acquiring fails', async () => {
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
expect.objectContaining({
- description: 'generic-pool.acquire',
- op: 'db',
- origin: 'auto.db.generic_pool',
- data: expect.objectContaining({ 'sentry.origin': 'auto.db.generic_pool', 'error.type': 'Error' }),
- status: 'internal_error',
+ name: 'generic-pool.acquire',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.generic_pool' },
+ [ERROR_TYPE]: { type: 'string', value: 'Error' },
+ }),
+ status: 'error',
}),
]),
};
- await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
+ await createRunner().expect({ span: EXPECTED_SPANS }).start().completed();
});
},
{ additionalDependencies: { 'generic-pool': '^2.5.0' } },
diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts b/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts
index 2c607666691a..bce47d5f779f 100644
--- a/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts
@@ -1,3 +1,5 @@
+import { ERROR_TYPE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -8,54 +10,51 @@ describe('genericPool auto instrumentation', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('should auto-instrument `genericPool` package when calling pool.require()', async () => {
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
expect.objectContaining({
- description: 'generic-pool.acquire',
- op: 'db',
- origin: 'auto.db.generic_pool',
- data: expect.objectContaining({
- 'sentry.origin': 'auto.db.generic_pool',
+ name: 'generic-pool.acquire',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.generic_pool' },
}),
status: 'ok',
}),
expect.objectContaining({
- description: 'generic-pool.acquire',
- op: 'db',
- origin: 'auto.db.generic_pool',
- data: expect.objectContaining({
- 'sentry.origin': 'auto.db.generic_pool',
+ name: 'generic-pool.acquire',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.generic_pool' },
}),
status: 'ok',
}),
]),
};
- await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
+ await createRunner().expect({ span: EXPECTED_SPANS }).start().completed();
});
});
createEsmAndCjsTests(__dirname, 'scenario-error.mjs', 'instrument.mjs', (createRunner, test) => {
test('marks the `generic-pool.acquire` span as errored when acquiring fails', async () => {
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
expect.objectContaining({
- description: 'generic-pool.acquire',
- op: 'db',
- origin: 'auto.db.generic_pool',
- data: expect.objectContaining({
- 'sentry.origin': 'auto.db.generic_pool',
- 'error.type': 'TimeoutError',
+ name: 'generic-pool.acquire',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.generic_pool' },
+ [ERROR_TYPE]: { type: 'string', value: 'TimeoutError' },
}),
- status: 'internal_error',
+ status: 'error',
}),
]),
};
- await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
+ await createRunner().expect({ span: EXPECTED_SPANS }).start().completed();
});
});
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/instrument.mjs
index c83310d623f6..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/instrument.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1beta')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/test.ts
index 97f65eeb0680..1025569ac5d4 100644
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/google-genai-v2/test.ts
@@ -9,6 +9,8 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -30,54 +32,51 @@ describe('Google GenAI integration (v2)', () => {
'instrument.mjs',
(createRunner, test) => {
test('auto-instruments chat and generateContent on @google/genai v2', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat gemini-1.5-pro',
- 'generate_content error-model',
- 'generate_content gemini-1.5-flash',
- ]);
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
- const chatSpan = container.items.find(span => span.name === 'chat gemini-1.5-pro');
- expect(chatSpan!.status).toBe('ok');
- expect(chatSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(chatSpan!.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
- expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
- expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-pro');
- expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
- expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
- expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
+ await runner.start().completed();
- const generateContentSpan = container.items.find(
- span => span.name === 'generate_content gemini-1.5-flash',
- );
- expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(generateContentSpan!.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
- expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-flash');
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
- expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
- expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
+ 'chat gemini-1.5-pro',
+ 'generate_content error-model',
+ 'generate_content gemini-1.5-flash',
+ ]);
- const errorSpan = container.items.find(span => span.name === 'generate_content error-model');
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
- },
- })
- .start()
- .completed();
+ const chatSpan = spans.find(span => span.name === 'chat gemini-1.5-pro');
+ expect(chatSpan!.status).toBe('ok');
+ expect(chatSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(chatSpan!.attributes[SENTRY_ORIGIN].value).toBe(EXPECTED_ORIGIN);
+ expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-pro');
+ expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
+ expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
+ expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
+
+ const generateContentSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
+ expect(generateContentSpan!.status).toBe('ok');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_ORIGIN].value).toBe(EXPECTED_ORIGIN);
+ expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-flash');
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
+
+ const errorSpan = spans.find(span => span.name === 'generate_content error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
});
},
{ additionalDependencies: { '@google/genai': '^2' } },
@@ -91,38 +90,37 @@ describe('Google GenAI integration (v2)', () => {
// `embedContent` is the member that changed shape in v2; asserting its span proves the
// `className`/`methodName` selector still matches the constructor-assigned arrow.
test('auto-instruments embedContent on @google/genai v2', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'embeddings error-model',
- 'embeddings text-embedding-004',
- 'embeddings text-embedding-004',
- ]);
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
+ 'embeddings error-model',
+ 'embeddings text-embedding-004',
+ 'embeddings text-embedding-004',
+ ]);
- const successfulSpans = container.items.filter(
- span => span.name === 'embeddings text-embedding-004' && span.status === 'ok',
- );
- expect(successfulSpans).toHaveLength(2);
- for (const span of successfulSpans) {
- expect(span.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
- expect(span.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
- expect(span.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
- expect(span.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
- expect(span.attributes[GEN_AI_REQUEST_MODEL].value).toBe('text-embedding-004');
- }
+ const successfulSpans = spans.filter(
+ span => span.name === 'embeddings text-embedding-004' && span.status === 'ok',
+ );
+ expect(successfulSpans).toHaveLength(2);
+ for (const span of successfulSpans) {
+ expect(span.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
+ expect(span.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
+ expect(span.attributes[SENTRY_ORIGIN].value).toBe(EXPECTED_ORIGIN);
+ expect(span.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
+ expect(span.attributes[GEN_AI_REQUEST_MODEL].value).toBe('text-embedding-004');
+ }
- const errorSpan = container.items.find(span => span.name === 'embeddings error-model');
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
- expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
- },
- })
- .start()
- .completed();
+ const errorSpan = spans.find(span => span.name === 'embeddings error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
+ expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
});
},
{ additionalDependencies: { '@google/genai': '^2' } },
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-span-streaming.mjs
deleted file mode 100644
index 657bed0a3a8c..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: true, outputs: true } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs
index f1299d9c3542..8edd2db39bc3 100644
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-options.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
@@ -14,11 +13,4 @@ Sentry.init({
recordOutputs: true,
}),
],
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1beta/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs
index e5ddcf227d1f..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-pii.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1beta/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs
index c83310d623f6..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1beta')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts
index 6f51960996fa..710d167bea2e 100644
--- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts
@@ -1,3 +1,4 @@
+import type { Event, SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import {
GEN_AI_EMBEDDINGS_INPUT,
@@ -20,6 +21,8 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -33,22 +36,24 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates google genai related spans with genAI recording disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat gemini-1.5-pro',
'generate_content error-model',
'generate_content gemini-1.5-flash',
]);
- const chatSpan = container.items.find(span => span.name === 'chat gemini-1.5-pro');
- expect(chatSpan).toBeDefined();
+ const chatSpan = spans.find(span => span.name === 'chat gemini-1.5-pro');
expect(chatSpan!.status).toBe('ok');
- expect(chatSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(chatSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(chatSpan!.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
+ expect(chatSpan!.attributes[SENTRY_ORIGIN].value).toBe(EXPECTED_ORIGIN);
expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-pro');
// Given once to `chats.create()` and reused for every message the chat sends.
@@ -60,10 +65,9 @@ describe('Google GenAI integration', () => {
expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
- const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash');
- expect(generateContentSpan).toBeDefined();
+ const generateContentSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-flash');
@@ -74,10 +78,9 @@ describe('Google GenAI integration', () => {
expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
- const errorSpan = container.items.find(span => span.name === 'generate_content error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'generate_content error-model');
expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
@@ -91,20 +94,22 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates google genai related spans with genAI recording enabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat gemini-1.5-pro',
'generate_content error-model',
'generate_content gemini-1.5-flash',
]);
- const chatSpan = container.items.find(span => span.name === 'chat gemini-1.5-pro');
- expect(chatSpan).toBeDefined();
+ const chatSpan = spans.find(span => span.name === 'chat gemini-1.5-pro');
expect(chatSpan!.status).toBe('ok');
- expect(chatSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(chatSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-pro');
@@ -121,21 +126,21 @@ describe('Google GenAI integration', () => {
expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
- const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash');
- expect(generateContentSpan).toBeDefined();
+ const generateContentSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-flash');
expect(generateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- const errorSpan = container.items.find(span => span.name === 'generate_content error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'generate_content error-model');
expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
expect(errorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
},
@@ -148,28 +153,28 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => {
test('creates google genai related spans with custom options', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat gemini-1.5-pro',
'generate_content error-model',
'generate_content gemini-1.5-flash',
]);
- const chatSpan = container.items.find(span => span.name === 'chat gemini-1.5-pro');
- expect(chatSpan).toBeDefined();
+ const chatSpan = spans.find(span => span.name === 'chat gemini-1.5-pro');
expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
- const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash');
- expect(generateContentSpan).toBeDefined();
+ const generateContentSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- const errorSpan = container.items.find(span => span.name === 'generate_content error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'generate_content error-model');
expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
},
})
@@ -184,16 +189,18 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-options.mjs', (createRunner, test) => {
test('creates google genai related spans with tool calls', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const nonStreamingToolsSpan = container.items.find(
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ const nonStreamingToolsSpan = spans.find(
span =>
span.attributes[GEN_AI_TOOL_DEFINITIONS]?.value === EXPECTED_AVAILABLE_TOOLS_JSON &&
span.attributes[GEN_AI_RESPONSE_STREAMING] === undefined,
);
- expect(nonStreamingToolsSpan).toBeDefined();
expect(nonStreamingToolsSpan!.name).toBe('generate_content gemini-2.0-flash-001');
expect(nonStreamingToolsSpan!.status).toBe('ok');
expect(nonStreamingToolsSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
@@ -226,12 +233,11 @@ describe('Google GenAI integration', () => {
expect(nonStreamingToolsSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(8);
expect(nonStreamingToolsSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(23);
- const streamingToolsSpan = container.items.find(
+ const streamingToolsSpan = spans.find(
span =>
span.attributes[GEN_AI_TOOL_DEFINITIONS]?.value === EXPECTED_AVAILABLE_TOOLS_JSON &&
span.attributes[GEN_AI_RESPONSE_STREAMING]?.value === true,
);
- expect(streamingToolsSpan).toBeDefined();
expect(streamingToolsSpan!.name).toBe('generate_content gemini-2.0-flash-001');
expect(streamingToolsSpan!.status).toBe('ok');
expect(streamingToolsSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
@@ -268,8 +274,7 @@ describe('Google GenAI integration', () => {
expect(streamingToolsSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(10);
expect(streamingToolsSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(22);
- const noToolsSpan = container.items.find(span => span.attributes[GEN_AI_TOOL_DEFINITIONS] === undefined);
- expect(noToolsSpan).toBeDefined();
+ const noToolsSpan = spans.find(span => span.attributes[GEN_AI_TOOL_DEFINITIONS] === undefined);
expect(noToolsSpan!.name).toBe('generate_content gemini-2.0-flash-001');
expect(noToolsSpan!.status).toBe('ok');
expect(noToolsSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
@@ -287,148 +292,162 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates google genai streaming spans with genAI recording disabled', async () => {
+ let receivedEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
// The provider surfaces blocked content within the stream and never returns it to the caller as
// a thrown error, so the instrumentation intentionally captures it as an event.
.unordered()
.expect({
- event: {
- exception: {
- values: [
- {
- value: 'Content blocked: The prompt was blocked due to safety concerns',
- mechanism: { type: 'auto.ai.google_genai', handled: false },
- },
- ],
- },
+ event: event => {
+ receivedEvent = event;
},
})
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat gemini-1.5-pro',
- 'generate_content blocked-model',
- 'generate_content error-model',
- 'generate_content gemini-1.5-flash',
- ]);
-
- const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('mock-response-streaming-id');
- expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gemini-1.5-pro');
- expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["STOP"]');
- expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
- expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(22);
-
- const chatSpan = container.items.find(span => span.name === 'chat gemini-1.5-pro');
- expect(chatSpan).toBeDefined();
- expect(chatSpan!.status).toBe('ok');
- expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(chatSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- // Given once to `chats.create()` and reused for every message the chat sends.
- expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.8);
- expect(chatSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
- expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(150);
- expect(chatSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('mock-response-streaming-id');
- expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gemini-1.5-pro');
-
- const blockedSpan = container.items.find(span => span.name === 'generate_content blocked-model');
- expect(blockedSpan).toBeDefined();
- expect(blockedSpan!.status).toBe('error');
- expect(blockedSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(blockedSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
-
- const errorSpan = container.items.find(span => span.name === 'generate_content error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ expect(receivedEvent).toMatchObject({
+ event_id: expect.any(String),
+ exception: {
+ values: [
+ {
+ value: 'Content blocked: The prompt was blocked due to safety concerns',
+ mechanism: { type: 'auto.ai.google_genai', handled: false },
+ },
+ ],
+ },
+ });
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai');
+ expect(spans).toHaveLength(4);
+ expect(spans.map(span => span.name).sort()).toEqual([
+ 'chat gemini-1.5-pro',
+ 'generate_content blocked-model',
+ 'generate_content error-model',
+ 'generate_content gemini-1.5-flash',
+ ]);
+
+ const generateContentSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
+ expect(generateContentSpan!.status).toBe('ok');
+ expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('mock-response-streaming-id');
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gemini-1.5-pro');
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["STOP"]');
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(22);
+
+ const chatSpan = spans.find(span => span.name === 'chat gemini-1.5-pro');
+ expect(chatSpan!.status).toBe('ok');
+ expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(chatSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ // Given once to `chats.create()` and reused for every message the chat sends.
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.8);
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(150);
+ expect(chatSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('mock-response-streaming-id');
+ expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gemini-1.5-pro');
+
+ const blockedSpan = spans.find(span => span.name === 'generate_content blocked-model');
+ expect(blockedSpan!.status).toBe('error');
+ expect(blockedSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(blockedSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+
+ const errorSpan = spans.find(span => span.name === 'generate_content error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
});
});
createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates google genai streaming spans with genAI recording enabled', async () => {
+ let receivedEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
// The provider surfaces blocked content within the stream and never returns it to the caller as
// a thrown error, so the instrumentation intentionally captures it as an event.
.unordered()
.expect({
- event: {
- exception: {
- values: [
- {
- value: 'Content blocked: The prompt was blocked due to safety concerns',
- mechanism: { type: 'auto.ai.google_genai', handled: false },
- },
- ],
- },
+ event: event => {
+ receivedEvent = event;
},
})
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat gemini-1.5-pro',
- 'generate_content blocked-model',
- 'generate_content error-model',
- 'generate_content gemini-1.5-flash',
- ]);
-
- const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- expect(generateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["STOP"]');
-
- const chatSpan = container.items.find(span => span.name === 'chat gemini-1.5-pro');
- expect(chatSpan).toBeDefined();
- expect(chatSpan!.status).toBe('ok');
- expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(chatSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- // Given once to `chats.create()` and reused for every message the chat sends.
- expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.8);
- expect(chatSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
- expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(150);
- expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["STOP"]');
-
- const blockedSpan = container.items.find(span => span.name === 'generate_content blocked-model');
- expect(blockedSpan).toBeDefined();
- expect(blockedSpan!.status).toBe('error');
- expect(blockedSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(blockedSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
- expect(blockedSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(blockedSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
-
- const errorSpan = container.items.find(span => span.name === 'generate_content error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(errorSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(errorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ expect(receivedEvent).toMatchObject({
+ event_id: expect.any(String),
+ exception: {
+ values: [
+ {
+ value: 'Content blocked: The prompt was blocked due to safety concerns',
+ mechanism: { type: 'auto.ai.google_genai', handled: false },
+ },
+ ],
+ },
+ });
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai');
+ expect(spans).toHaveLength(4);
+ expect(spans.map(span => span.name).sort()).toEqual([
+ 'chat gemini-1.5-pro',
+ 'generate_content blocked-model',
+ 'generate_content error-model',
+ 'generate_content gemini-1.5-flash',
+ ]);
+
+ const generateContentSpan = spans.find(span => span.name === 'generate_content gemini-1.5-flash');
+ expect(generateContentSpan!.status).toBe('ok');
+ expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(generateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["STOP"]');
+
+ const chatSpan = spans.find(span => span.name === 'chat gemini-1.5-pro');
+ expect(chatSpan!.status).toBe('ok');
+ expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(chatSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ // Given once to `chats.create()` and reused for every message the chat sends.
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.8);
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.9);
+ expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(150);
+ expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["STOP"]');
+
+ const blockedSpan = spans.find(span => span.name === 'generate_content blocked-model');
+ expect(blockedSpan!.status).toBe('error');
+ expect(blockedSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(blockedSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
+ expect(blockedSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ expect(blockedSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+
+ const errorSpan = spans.find(span => span.name === 'generate_content error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(errorSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(errorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
});
});
@@ -439,11 +458,14 @@ describe('Google GenAI integration', () => {
(createRunner, test) => {
test('extracts system instructions and normalizes messages', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
- const [firstSpan, secondSpan] = container.items;
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(2);
+ const [firstSpan, secondSpan] = spans;
// [0] generate_content with a string system instruction
expect(firstSpan!.name).toBe('generate_content gemini-1.5-flash');
@@ -483,33 +505,35 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates google genai embeddings spans with genAI recording disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'embeddings error-model',
'embeddings text-embedding-004',
'embeddings text-embedding-004',
]);
- const successfulSpans = container.items.filter(
+ const successfulSpans = spans.filter(
span => span.name === 'embeddings text-embedding-004' && span.status === 'ok',
);
expect(successfulSpans).toHaveLength(2);
for (const span of successfulSpans) {
- expect(span.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
+ expect(span.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
expect(span.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
- expect(span.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
+ expect(span.attributes[SENTRY_ORIGIN].value).toBe(EXPECTED_ORIGIN);
expect(span.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
expect(span.attributes[GEN_AI_REQUEST_MODEL].value).toBe('text-embedding-004');
expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined();
}
- const errorSpan = container.items.find(span => span.name === 'embeddings error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'embeddings error-model');
expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
},
})
@@ -521,39 +545,37 @@ describe('Google GenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates google genai embeddings spans with genAI recording enabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.google_genai',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'embeddings error-model',
'embeddings text-embedding-004',
'embeddings text-embedding-004',
]);
- const stringInputSpan = container.items.find(
+ const stringInputSpan = spans.find(
span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'What is the capital of France?',
);
- expect(stringInputSpan).toBeDefined();
expect(stringInputSpan!.name).toBe('embeddings text-embedding-004');
expect(stringInputSpan!.status).toBe('ok');
expect(stringInputSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
expect(stringInputSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
- const errorSpan = container.items.find(
- span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'This will fail',
- );
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'This will fail');
expect(errorSpan!.name).toBe('embeddings error-model');
expect(errorSpan!.status).toBe('error');
expect(errorSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
- const arrayInputSpan = container.items.find(
+ const arrayInputSpan = spans.find(
span =>
span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value ===
'[{"role":"user","parts":[{"text":"First input text"}]},{"role":"user","parts":[{"text":"Second input text"}]}]',
);
- expect(arrayInputSpan).toBeDefined();
expect(arrayInputSpan!.name).toBe('embeddings text-embedding-004');
expect(arrayInputSpan!.status).toBe('ok');
expect(arrayInputSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
@@ -563,24 +585,4 @@ describe('Google GenAI integration', () => {
.completed();
});
});
-
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('creates google genai related spans with span streaming enabled', async () => {
- await createRunner()
- .expect({
- span: container => {
- const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
- expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai');
- expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-flash');
- expect(generateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- },
- })
- .start()
- .completed();
- });
- });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs
deleted file mode 100644
index 53b9511a21f0..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts
index 98cb1127906b..ec3947f54f4a 100644
--- a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts
@@ -1,3 +1,16 @@
+import {
+ DB_NAMESPACE,
+ DB_OPERATION_NAME,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+ DB_USER,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import { describe, expect } from 'vitest';
import { createEsmAndCjsTests, describeWithDockerCompose } from '../../../../utils/runner';
@@ -9,93 +22,75 @@ describeWithDockerCompose('knex auto instrumentation', { workingDirectory: [__di
describe('with `mysql2` client', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('should auto-instrument `knex` package', { timeout: 60_000 }, async () => {
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.system.name': 'mysql2',
- 'db.namespace': 'tests',
- 'db.user': 'root',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- 'server.address': 'localhost',
- 'server.port': 3307,
- }),
- status: 'ok',
- description:
- 'create table `User` (`id` int unsigned not null auto_increment primary key, `createdAt` timestamp(?) not null default CURRENT_TIMESTAMP(?), `email` text not null, `name` text not null)',
- origin: ORIGIN,
+ const EXPECTED_SPANS = [
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'mysql2' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [DB_USER]: { type: 'string', value: 'root' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 3307 },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value:
+ 'create table `User` (`id` int unsigned not null auto_increment primary key, `createdAt` timestamp(?) not null default CURRENT_TIMESTAMP(?), `email` text not null, `name` text not null)',
+ },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'create table `User`' },
}),
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.system.name': 'mysql2',
- 'db.namespace': 'tests',
- 'db.user': 'root',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- 'server.address': 'localhost',
- 'server.port': 3307,
- }),
- status: 'ok',
- description: 'insert into `User` (`email`, `name`) values (?, ?)',
- origin: ORIGIN,
+ status: 'ok',
+ name: 'create table `User`',
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'mysql2' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [DB_USER]: { type: 'string', value: 'root' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 3307 },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'insert into `User` (`email`, `name`) values (?, ?)' },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'insert `User`' },
}),
+ status: 'ok',
+ name: 'insert `User`',
+ }),
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.operation.name': 'select',
- 'db.sql.table': 'User',
- 'db.system.name': 'mysql2',
- 'db.namespace': 'tests',
- 'db.query.text': 'select * from `User`',
- 'db.user': 'root',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- }),
- status: 'ok',
- description: 'select * from `User`',
- origin: ORIGIN,
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'select' },
+ 'db.sql.table': { type: 'string', value: 'User' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'mysql2' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'select * from `User`' },
+ [DB_USER]: { type: 'string', value: 'root' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'select `User`' },
}),
- ]),
- };
-
- await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
- });
- });
+ status: 'ok',
+ name: 'select `User`',
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_QUERY_TEXT]: { type: 'string', value: 'drop table `User`' },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'drop table `User`' },
+ }),
+ name: 'drop table `User`',
+ }),
+ ];
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('should name spans after the query summary with span streaming', { timeout: 60_000 }, async () => {
await createRunner()
.expect({
span: container => {
- // The `mysql2` driver spans underneath the knex spans come from a different integration and
- // are named after the full statement, so they are filtered out here.
- const knexSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN);
-
- expect(
- knexSpans.map(span => ({
- name: span.name,
- summary: span.attributes['db.query.summary']?.value,
- text: span.attributes['db.query.text']?.value,
- })),
- ).toEqual([
- {
- name: 'create table `User`',
- summary: 'create table `User`',
- text: 'create table `User` (`id` int unsigned not null auto_increment primary key, `createdAt` timestamp(?) not null default CURRENT_TIMESTAMP(?), `email` text not null, `name` text not null)',
- },
- {
- name: 'insert `User`',
- summary: 'insert `User`',
- text: 'insert into `User` (`email`, `name`) values (?, ?)',
- },
- { name: 'select `User`', summary: 'select `User`', text: 'select * from `User`' },
- { name: 'drop table `User`', summary: 'drop table `User`', text: 'drop table `User`' },
- ]);
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ const knexSpans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN);
+ expect(knexSpans).toEqual(EXPECTED_SPANS);
},
})
.start()
diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs
deleted file mode 100644
index 53b9511a21f0..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts b/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts
index 5d1da5ad89ff..ae9ea260eab4 100644
--- a/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts
@@ -1,3 +1,15 @@
+import {
+ DB_NAMESPACE,
+ DB_OPERATION_NAME,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import { describe, expect } from 'vitest';
import { createEsmAndCjsTests, describeWithDockerCompose } from '../../../../utils/runner';
@@ -9,112 +21,89 @@ describe('knex auto instrumentation', () => {
describeWithDockerCompose('with `pg` client', { workingDirectory: [__dirname] }, () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('should auto-instrument `knex` package', { timeout: 60_000 }, async () => {
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.system.name': 'postgresql',
- 'db.namespace': 'tests',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- 'server.address': 'localhost',
- 'server.port': 5445,
- }),
- status: 'ok',
- description:
- 'create table "User" ("id" serial primary key, "createdAt" timestamptz(?) not null default CURRENT_TIMESTAMP(?), "email" text not null, "name" text not null)',
- origin: ORIGIN,
+ const EXPECTED_SPANS = [
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgresql' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_TEXT]: {
+ type: 'string',
+ value:
+ 'create table "User" ("id" serial primary key, "createdAt" timestamptz(?) not null default CURRENT_TIMESTAMP(?), "email" text not null, "name" text not null)',
+ },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'create table "User"' },
}),
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.system.name': 'postgresql',
- 'db.namespace': 'tests',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- 'server.address': 'localhost',
- 'server.port': 5445,
- }),
- status: 'ok',
- // In the knex-otel spans, the placeholders (e.g., `$1`) are replaced by a `?`.
- description: 'insert into "User" ("email", "name") values (?, ?)',
- origin: ORIGIN,
+ status: 'ok',
+ name: 'create table "User"',
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgresql' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 5445 },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'insert into "User" ("email", "name") values (?, ?)' },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'insert "User"' },
}),
+ status: 'ok',
+ // In the knex-otel spans, the placeholders (e.g., `$1`) are replaced by a `?`.
+ name: 'insert "User"',
+ }),
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.operation.name': 'select',
- 'db.sql.table': 'User',
- 'db.system.name': 'postgresql',
- 'db.namespace': 'tests',
- 'db.query.text': 'select * from "User"',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- }),
- status: 'ok',
- description: 'select * from "User"',
- origin: ORIGIN,
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'select' },
+ 'db.sql.table': { type: 'string', value: 'User' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgresql' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'select * from "User"' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'select "User"' },
}),
+ status: 'ok',
+ name: 'select "User"',
+ }),
- expect.objectContaining({
- data: expect.objectContaining({
- 'knex.version': KNEX_VERSION,
- 'db.operation.name': 'select',
- 'db.sql.table': 'DoesNotExist',
- 'db.system.name': 'postgresql',
- 'db.namespace': 'tests',
- 'db.query.text': 'select * from "DoesNotExist"',
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- }),
- status: 'internal_error',
- description: 'select * from "DoesNotExist"',
- origin: ORIGIN,
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'knex.version': { type: 'string', value: KNEX_VERSION },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'select' },
+ 'db.sql.table': { type: 'string', value: 'DoesNotExist' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'postgresql' },
+ [DB_NAMESPACE]: { type: 'string', value: 'tests' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'select * from "DoesNotExist"' },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'select "DoesNotExist"' },
}),
- ]),
- };
-
- await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
- });
- });
+ status: 'error',
+ name: 'select "DoesNotExist"',
+ }),
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ [DB_QUERY_TEXT]: { type: 'string', value: 'drop table "User"' },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'drop table "User"' },
+ }),
+ name: 'drop table "User"',
+ }),
+ ];
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('should name spans after the query summary with span streaming', { timeout: 60_000 }, async () => {
await createRunner()
.expect({
span: container => {
- // The `pg` driver spans underneath the knex spans come from a different integration and
- // are named after the full statement, so they are filtered out here.
- const knexSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN);
-
- expect(
- knexSpans.map(span => ({
- name: span.name,
- summary: span.attributes['db.query.summary']?.value,
- text: span.attributes['db.query.text']?.value,
- })),
- ).toEqual([
- {
- name: 'create table "User"',
- summary: 'create table "User"',
- text: 'create table "User" ("id" serial primary key, "createdAt" timestamptz(?) not null default CURRENT_TIMESTAMP(?), "email" text not null, "name" text not null)',
- },
- {
- name: 'insert "User"',
- summary: 'insert "User"',
- text: 'insert into "User" ("email", "name") values (?, ?)',
- },
- { name: 'select "User"', summary: 'select "User"', text: 'select * from "User"' },
- {
- name: 'select "DoesNotExist"',
- summary: 'select "DoesNotExist"',
- text: 'select * from "DoesNotExist"',
- },
- { name: 'drop table "User"', summary: 'drop table "User"', text: 'drop table "User"' },
- ]);
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ const knexSpans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN);
+ expect(knexSpans).toEqual(EXPECTED_SPANS);
},
})
.start()
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-span-streaming.mjs
deleted file mode 100644
index 657bed0a3a8c..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: true, outputs: true } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs
index e60b44f864d7..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/embeddings')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs
index c81bb4dc1322..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/embeddings')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts
index 290cf38bcba6..48767041abf9 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts
@@ -18,6 +18,9 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ LANGCHAIN_CHAIN_NAME,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_EMBEDDINGS } from '@sentry/conventions/op';
import {
@@ -35,21 +38,21 @@ describe('LangChain integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates langchain related spans with genAI recording disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat claude-3-5-sonnet-20241022',
'chat claude-3-opus-20240229',
'chat error-model',
]);
- const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
- expect(sonnetSpan).toBeDefined();
+ const sonnetSpan = spans.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
expect(sonnetSpan!.status).toBe('ok');
- expect(sonnetSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(sonnetSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ expect(sonnetSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(sonnetSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(sonnetSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
@@ -62,11 +65,10 @@ describe('LangChain integration', () => {
expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeDefined();
expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]).toBeDefined();
- const opusSpan = container.items.find(span => span.name === 'chat claude-3-opus-20240229');
- expect(opusSpan).toBeDefined();
+ const opusSpan = spans.find(span => span.name === 'chat claude-3-opus-20240229');
expect(opusSpan!.status).toBe('ok');
- expect(opusSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(opusSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ expect(opusSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(opusSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(opusSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(opusSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-opus-20240229');
expect(opusSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.9);
@@ -76,11 +78,10 @@ describe('LangChain integration', () => {
expect(opusSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
expect(opusSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(errorSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(errorSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
},
@@ -88,44 +89,26 @@ describe('LangChain integration', () => {
.start()
.completed();
});
-
- test('does not create duplicate spans from double module patching', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- // The scenario makes 3 LangChain calls (2 successful + 1 error).
- // Without the dedup guard, the file-level and module-level hooks
- // both patch the same prototype, producing 6 spans instead of 3.
- expect(container.items).toHaveLength(3);
- for (const span of container.items) {
- expect(span.attributes['sentry.op'].value).toBe('gen_ai.chat');
- }
- },
- })
- .start()
- .completed();
- });
});
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates langchain related spans with genAI recording enabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat claude-3-5-sonnet-20241022',
'chat claude-3-opus-20240229',
'chat error-model',
]);
- const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
- expect(sonnetSpan).toBeDefined();
+ const sonnetSpan = spans.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
expect(sonnetSpan!.status).toBe('ok');
- expect(sonnetSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(sonnetSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ expect(sonnetSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(sonnetSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
expect(sonnetSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
@@ -139,8 +122,7 @@ describe('LangChain integration', () => {
expect(sonnetSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
expect(sonnetSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
- const opusSpan = container.items.find(span => span.name === 'chat claude-3-opus-20240229');
- expect(opusSpan).toBeDefined();
+ const opusSpan = spans.find(span => span.name === 'chat claude-3-opus-20240229');
expect(opusSpan!.status).toBe('ok');
expect(opusSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(opusSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-opus-20240229');
@@ -153,8 +135,7 @@ describe('LangChain integration', () => {
expect(opusSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
expect(opusSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
expect(errorSpan!.status).toBe('error');
expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
@@ -169,17 +150,18 @@ describe('LangChain integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates langchain spans with tool calls', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(1);
- const [firstSpan] = container.items;
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(1);
+ const [firstSpan] = spans;
// [0] chat with tool_use stop reason
expect(firstSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
expect(firstSpan!.status).toBe('ok');
- expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(firstSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ expect(firstSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(firstSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(firstSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
@@ -198,31 +180,23 @@ describe('LangChain integration', () => {
createEsmTests(__dirname, 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => {
test('demonstrates timing issue with duplicate spans', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(2);
- const anthropicSpan = container.items.find(
- span => span.attributes['sentry.origin'].value === 'auto.ai.anthropic',
- );
- expect(anthropicSpan).toBeDefined();
- expect(anthropicSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
-
- // LangChain call is instrumented by LangChain.
- const langchainSpan = container.items.find(
- span => span.attributes['sentry.origin'].value === 'auto.ai.langchain',
- );
- expect(langchainSpan).toBeDefined();
- expect(langchainSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
-
- // Third call (not present): Direct Anthropic call made AFTER LangChain import
- // is NOT instrumented, which demonstrates the skip mechanism works for NEW
- // clients. We should only have ONE Anthropic span (the first one), not two.
- },
- })
- .start()
- .completed();
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span =>
+ span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' ||
+ span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic',
+ );
+ expect(spans).toHaveLength(2);
+ const anthropicSpan = spans.find(span => span.attributes[SENTRY_ORIGIN].value === 'auto.ai.anthropic');
+ expect(anthropicSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
+
+ // LangChain call is instrumented by LangChain.
+ const langchainSpan = spans.find(span => span.attributes[SENTRY_ORIGIN].value === 'auto.ai.langchain');
+ expect(langchainSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
});
});
@@ -233,15 +207,18 @@ describe('LangChain integration', () => {
(createRunner, test) => {
test('extracts system instructions from messages', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(1);
- const [firstSpan] = container.items;
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans).toHaveLength(1);
+ const [firstSpan] = spans;
// [0] chat with extracted system instructions
expect(firstSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
- expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
+ expect(firstSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
expect(firstSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe(
JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]),
);
@@ -256,42 +233,41 @@ describe('LangChain integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-chain.mjs', 'instrument.mjs', (createRunner, test) => {
test('uses runName for chain spans instead of unknown_chain', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chain format_prompt',
- 'chain parse_output',
- 'chain unknown_chain',
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(4);
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat claude-3-5-sonnet-20241022',
+ 'invoke_agent',
+ 'invoke_agent format_prompt',
+ 'invoke_agent parse_output',
]);
- const formatPromptSpan = container.items.find(span => span.name === 'chain format_prompt');
- expect(formatPromptSpan).toBeDefined();
- expect(formatPromptSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(formatPromptSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ const formatPromptSpan = spans.find(span => span.name === 'invoke_agent format_prompt');
+ expect(formatPromptSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(formatPromptSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(formatPromptSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent');
expect(formatPromptSpan!.attributes[GEN_AI_PIPELINE_NAME].value).toBe('format_prompt');
- expect(formatPromptSpan!.attributes['langchain.chain.name']).toBeUndefined();
+ expect(formatPromptSpan!.attributes[LANGCHAIN_CHAIN_NAME]).toBeUndefined();
- const chatSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
- expect(chatSpan).toBeDefined();
- expect(chatSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(chatSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ const chatSpan = spans.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
+ expect(chatSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(chatSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
- const parseOutputSpan = container.items.find(span => span.name === 'chain parse_output');
- expect(parseOutputSpan).toBeDefined();
- expect(parseOutputSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(parseOutputSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ const parseOutputSpan = spans.find(span => span.name === 'invoke_agent parse_output');
+ expect(parseOutputSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(parseOutputSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(parseOutputSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent');
expect(parseOutputSpan!.attributes[GEN_AI_PIPELINE_NAME].value).toBe('parse_output');
- expect(parseOutputSpan!.attributes['langchain.chain.name']).toBeUndefined();
+ expect(parseOutputSpan!.attributes[LANGCHAIN_CHAIN_NAME]).toBeUndefined();
- const unknownChainSpan = container.items.find(span => span.name === 'chain unknown_chain');
- expect(unknownChainSpan).toBeDefined();
- expect(unknownChainSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ const unknownChainSpan = spans.find(span => span.name === 'invoke_agent');
+ expect(unknownChainSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(unknownChainSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent');
expect(unknownChainSpan!.attributes[GEN_AI_PIPELINE_NAME]).toBeUndefined();
- expect(unknownChainSpan!.attributes['langchain.chain.name']).toBeUndefined();
+ expect(unknownChainSpan!.attributes[LANGCHAIN_CHAIN_NAME]).toBeUndefined();
},
})
.start()
@@ -306,89 +282,69 @@ describe('LangChain integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates embedding spans with genAI recording disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'embeddings error-model',
'embeddings text-embedding-3-small',
'embeddings text-embedding-3-small',
]);
- const successfulSpans = container.items.filter(
+ const successfulSpans = spans.filter(
span => span.name === 'embeddings text-embedding-3-small' && span.status === 'ok',
);
expect(successfulSpans).toHaveLength(2);
for (const span of successfulSpans) {
- expect(span.attributes['sentry.op'].value).toBe(GEN_AI_EMBEDDINGS);
- expect(span.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
+ expect(span.attributes[SENTRY_OP].value).toBe(GEN_AI_EMBEDDINGS);
+ expect(span.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
expect(span.attributes[GEN_AI_OPERATION_NAME].value).toBe('embeddings');
expect(span.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
expect(span.attributes[GEN_AI_REQUEST_MODEL].value).toBe('text-embedding-3-small');
expect(span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE].value).toBe(1536);
}
- const errorSpan = container.items.find(span => span.name === 'embeddings error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'embeddings error-model');
expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe(GEN_AI_EMBEDDINGS);
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe(GEN_AI_EMBEDDINGS);
expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
},
})
.start()
.completed();
});
-
- test('does not create duplicate embedding spans from double module patching', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- // The scenario makes 3 embedding calls (2 successful + 1 error).
- expect(container.items).toHaveLength(3);
- for (const span of container.items) {
- expect(span.attributes['sentry.op'].value).toBe(GEN_AI_EMBEDDINGS);
- }
- },
- })
- .start()
- .completed();
- });
});
createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates embedding spans with genAI recording enabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
'embeddings error-model',
'embeddings text-embedding-3-small',
'embeddings text-embedding-3-small',
]);
- const querySpan = container.items.find(
- span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Hello world',
- );
- expect(querySpan).toBeDefined();
+ const querySpan = spans.find(span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Hello world');
expect(querySpan!.name).toBe('embeddings text-embedding-3-small');
expect(querySpan!.status).toBe('ok');
expect(querySpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE].value).toBe(1536);
- const documentsSpan = container.items.find(
+ const documentsSpan = spans.find(
span =>
span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value ===
JSON.stringify(['First document', 'Second document']),
);
- expect(documentsSpan).toBeDefined();
expect(documentsSpan!.name).toBe('embeddings text-embedding-3-small');
expect(documentsSpan!.status).toBe('ok');
- const errorSpan = container.items.find(span => span.name === 'embeddings error-model');
- expect(errorSpan).toBeDefined();
+ const errorSpan = spans.find(span => span.name === 'embeddings error-model');
expect(errorSpan!.status).toBe('error');
},
})
@@ -397,44 +353,25 @@ describe('LangChain integration', () => {
});
});
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('creates langchain related spans with span streaming enabled', async () => {
- await createRunner()
- .expect({
- span: container => {
- const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
- expect(sonnetSpan).toBeDefined();
- expect(sonnetSpan!.status).toBe('ok');
- expect(sonnetSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(sonnetSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
- expect(sonnetSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- },
- })
- .start()
- .completed();
- });
- });
-
createEsmAndCjsTests(__dirname, 'scenario-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => {
test('derives gen_ai.conversation.id from the invoke config and lets a scope id win', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
// Four invocations, each producing the outer chain span, the lambda step span and the chat span.
- expect(container.items).toHaveLength(12);
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain');
+ expect(spans).toHaveLength(12);
const idsByName = (name: string): (string | undefined)[] =>
- container.items
+ spans
.filter(span => span.name === name)
.sort((a, b) => a.start_timestamp - b.start_timestamp)
.map(span => span.attributes[GEN_AI_CONVERSATION_ID]?.value as string | undefined);
// The id reaches the chain span, the child lambda span and the child chat span alike.
const expected = ['thread_from_config', 'session_from_config', 'conversation_from_scope', undefined];
- expect(idsByName('chain weather_chain')).toEqual(expected);
- expect(idsByName('chain format_prompt')).toEqual(expected);
+ expect(idsByName('invoke_agent weather_chain')).toEqual(expected);
+ expect(idsByName('invoke_agent format_prompt')).toEqual(expected);
expect(idsByName('chat claude-3-5-sonnet-20241022')).toEqual(expected);
},
})
@@ -442,39 +379,4 @@ describe('LangChain integration', () => {
.completed();
});
});
-
- createEsmAndCjsTests(__dirname, 'scenario-chain.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('leads chain span names with the operation when span streaming is enabled', async () => {
- await createRunner()
- .ignore('event')
- .expect({
- span: container => {
- const chainSpans = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- );
- // The `unknown_chain` sentinel is dropped, so that span falls back to the bare operation.
- expect(chainSpans.map(span => span.name).sort()).toEqual([
- 'invoke_agent',
- 'invoke_agent format_prompt',
- 'invoke_agent parse_output',
- ]);
- for (const span of chainSpans) {
- expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent');
- }
- expect(
- chainSpans
- .map(span => span.attributes[GEN_AI_PIPELINE_NAME]?.value)
- .filter(Boolean)
- .sort(),
- ).toEqual(['format_prompt', 'parse_output']);
- const unnamedChainSpan = chainSpans.find(span => span.name === 'invoke_agent');
- expect(unnamedChainSpan).toBeDefined();
- expect(unnamedChainSpan!.attributes[GEN_AI_PIPELINE_NAME]).toBeUndefined();
- expect(unnamedChainSpan!.attributes['langchain.chain.name']).toBeUndefined();
- },
- })
- .start()
- .completed();
- });
- });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-pii.mjs
index a220d64b148a..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-pii.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/chat/completions')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument.mjs
index 8a2ba440b758..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/chat/completions')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier-inherited-callbacks.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier-inherited-callbacks.mjs
new file mode 100644
index 000000000000..642945063c88
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier-inherited-callbacks.mjs
@@ -0,0 +1,61 @@
+import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
+import { RunnableLambda } from '@langchain/core/runnables';
+import { TypeSafeClassifier } from '@langchain/typesafe';
+// Sets up the AsyncLocalStorage that passes a parent run's config to its children, as it is inside `createAgent`.
+import '@langchain/langgraph';
+import * as Sentry from '@sentry/node';
+import express from 'express';
+
+function startMockTypeSafeServer() {
+ const app = express();
+ app.use(express.json());
+
+ app.post('/v1/systemone', (req, res) => {
+ res.json({
+ model: 'jev-1.13',
+ answers: { urgent: { type: 'noul', noul: 0.9 } },
+ usage: { input_tokens: 30, output_tokens: 2 },
+ });
+ });
+
+ return new Promise(resolve => {
+ const server = app.listen(0, () => {
+ resolve(server);
+ });
+ });
+}
+
+// Stands in for a tracer such as LangSmith, which the user passes to the parent run.
+class RecordingHandler extends BaseCallbackHandler {
+ name = 'RecordingHandler';
+ runs = [];
+
+ handleChainStart(chain, _inputs, _runId, parentRunId) {
+ this.runs.push(`${chain.id.at(-1)}:${parentRunId ? 'child' : 'root'}`);
+ }
+}
+
+async function run() {
+ const server = await startMockTypeSafeServer();
+ const baseUrl = `http://localhost:${server.address().port}`;
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async span => {
+ const classifier = new TypeSafeClassifier({
+ apiKey: 'mock-api-key',
+ baseUrl,
+ questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
+ });
+
+ // Like the `@langchain/typesafe` middlewares, call the classifier without a config.
+ const parent = RunnableLambda.from(input => classifier.invoke(input)).withConfig({ runName: 'parent' });
+ const recorder = new RecordingHandler();
+ await parent.invoke('My payouts have been failing.', { callbacks: [recorder] });
+
+ span.setAttribute('test.recorded_runs', recorder.runs.join(','));
+ });
+
+ await Sentry.flush(2000);
+ server.close();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier.mjs
new file mode 100644
index 000000000000..eaaf7a9c7e52
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-classifier.mjs
@@ -0,0 +1,43 @@
+import { HumanMessage } from '@langchain/core/messages';
+import { TypeSafeClassifier } from '@langchain/typesafe';
+import * as Sentry from '@sentry/node';
+import express from 'express';
+
+function startMockTypeSafeServer() {
+ const app = express();
+ app.use(express.json());
+
+ app.post('/v1/systemone', (req, res) => {
+ res.json({
+ model: 'jev-1.13',
+ answers: { urgent: { type: 'noul', noul: 0.9 } },
+ usage: { input_tokens: 30, output_tokens: 2 },
+ });
+ });
+
+ return new Promise(resolve => {
+ const server = app.listen(0, () => {
+ resolve(server);
+ });
+ });
+}
+
+async function run() {
+ const server = await startMockTypeSafeServer();
+ const baseUrl = `http://localhost:${server.address().port}`;
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const classifier = new TypeSafeClassifier({
+ apiKey: 'mock-api-key',
+ baseUrl,
+ questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
+ });
+
+ await classifier.invoke('My payouts have been failing.');
+ await classifier.invoke(new HumanMessage('My card was charged twice.'));
+ });
+
+ server.close();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-model-router.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-model-router.mjs
new file mode 100644
index 000000000000..d9d56910381b
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-typesafe-model-router.mjs
@@ -0,0 +1,69 @@
+import { ChatAnthropic } from '@langchain/anthropic';
+import { modelRouterMiddleware } from '@langchain/typesafe/middleware';
+import * as Sentry from '@sentry/node';
+import express from 'express';
+import { createAgent } from 'langchain';
+
+function startMockServer() {
+ const app = express();
+ app.use(express.json());
+
+ app.post('/v1/messages', (req, res) => {
+ res.json({
+ id: 'msg_router_test',
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'A refund is on its way.' }],
+ model: req.body.model,
+ stop_reason: 'end_turn',
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 5 },
+ });
+ });
+
+ app.post('/v1/systemone', (req, res) => {
+ res.json({
+ model: 'jev-1.13',
+ answers: {
+ model_route: { type: 'choice', choice: 'fast', probabilities: { fast: 0.9, smart: 0.1 }, confidence: 0.8 },
+ },
+ usage: { input_tokens: 40, output_tokens: 3 },
+ });
+ });
+
+ return new Promise(resolve => {
+ const server = app.listen(0, () => {
+ resolve(server);
+ });
+ });
+}
+
+async function run() {
+ const server = await startMockServer();
+ const baseUrl = `http://localhost:${server.address().port}`;
+
+ const model = name => new ChatAnthropic({ model: name, apiKey: 'mock-api-key', clientOptions: { baseURL: baseUrl } });
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const agent = createAgent({
+ name: 'support_agent',
+ model: model('claude-3-5-sonnet-20241022'),
+ middleware: [
+ modelRouterMiddleware({
+ instructions: 'Pick the model for this request.',
+ choices: {
+ fast: { model: model('claude-3-5-haiku-20241022'), criteria: 'Simple requests' },
+ smart: { model: model('claude-3-5-sonnet-20241022'), criteria: 'Complex requests' },
+ },
+ classifierOptions: { apiKey: 'mock-api-key', baseUrl },
+ }),
+ ],
+ });
+
+ await agent.invoke({ messages: [{ role: 'user', content: 'Where is my refund?' }] });
+ });
+
+ server.close();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts
index 669ac68afa34..f14ccb93169d 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts
@@ -1,8 +1,10 @@
import { afterAll, describe, expect } from 'vitest';
import {
+ GEN_AI_AGENT_NAME,
GEN_AI_CONVERSATION_ID,
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
+ GEN_AI_OUTPUT_MESSAGES,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MAX_TOKENS,
GEN_AI_REQUEST_MODEL,
@@ -15,7 +17,10 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
+import { GEN_AI_EVALUATE } from '@sentry/conventions/op';
import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
import { createEsmTests } from '../../../../utils/runner/createEsmAndCjsTests';
@@ -31,60 +36,56 @@ describe('LangChain integration (v1)', () => {
'instrument.mjs',
(createRunner, test) => {
test('creates langchain related spans with genAI recording disabled', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat claude-3-5-sonnet-20241022',
- 'chat claude-3-opus-20240229',
- 'chat error-model',
- ]);
-
- const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
- expect(sonnetSpan).toBeDefined();
- expect(sonnetSpan!.status).toBe('ok');
- expect(sonnetSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(sonnetSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(sonnetSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(sonnetSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(sonnetSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
- expect(sonnetSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_ID]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]).toBeDefined();
-
- const opusSpan = container.items.find(span => span.name === 'chat claude-3-opus-20240229');
- expect(opusSpan).toBeDefined();
- expect(opusSpan!.status).toBe('ok');
- expect(opusSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(opusSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(opusSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(opusSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-opus-20240229');
- expect(opusSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.9);
- expect(opusSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.95);
- expect(opusSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(200);
- expect(opusSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(opusSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
- expect(opusSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
-
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(errorSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
- },
- })
- .start()
- .completed();
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
+ 'chat claude-3-5-sonnet-20241022',
+ 'chat claude-3-opus-20240229',
+ 'chat error-model',
+ ]);
+
+ const sonnetSpan = spans.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
+ expect(sonnetSpan!.status).toBe('ok');
+ expect(sonnetSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(sonnetSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(sonnetSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
+ expect(sonnetSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(sonnetSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(sonnetSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
+ expect(sonnetSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_ID]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]).toBeDefined();
+
+ const opusSpan = spans.find(span => span.name === 'chat claude-3-opus-20240229');
+ expect(opusSpan!.status).toBe('ok');
+ expect(opusSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(opusSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(opusSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-opus-20240229');
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.9);
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.95);
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(200);
+ expect(opusSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(opusSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
+ expect(opusSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
+
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(errorSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
});
},
{
@@ -102,60 +103,56 @@ describe('LangChain integration (v1)', () => {
'instrument-with-pii.mjs',
(createRunner, test) => {
test('creates langchain related spans with genAI recording enabled', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat claude-3-5-sonnet-20241022',
- 'chat claude-3-opus-20240229',
- 'chat error-model',
- ]);
-
- const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
- expect(sonnetSpan).toBeDefined();
- expect(sonnetSpan!.status).toBe('ok');
- expect(sonnetSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(sonnetSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(sonnetSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_ID]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]).toBeDefined();
- expect(sonnetSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(sonnetSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
- expect(sonnetSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
-
- const opusSpan = container.items.find(span => span.name === 'chat claude-3-opus-20240229');
- expect(opusSpan).toBeDefined();
- expect(opusSpan!.status).toBe('ok');
- expect(opusSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(opusSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-opus-20240229');
- expect(opusSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.9);
- expect(opusSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.95);
- expect(opusSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(200);
- expect(opusSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(opusSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
- expect(opusSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(opusSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
- expect(opusSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
-
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
- expect(errorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- },
- })
- .start()
- .completed();
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual([
+ 'chat claude-3-5-sonnet-20241022',
+ 'chat claude-3-opus-20240229',
+ 'chat error-model',
+ ]);
+
+ const sonnetSpan = spans.find(span => span.name === 'chat claude-3-5-sonnet-20241022');
+ expect(sonnetSpan!.status).toBe('ok');
+ expect(sonnetSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(sonnetSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
+ expect(sonnetSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(sonnetSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_ID]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]).toBeDefined();
+ expect(sonnetSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(sonnetSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
+ expect(sonnetSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
+
+ const opusSpan = spans.find(span => span.name === 'chat claude-3-opus-20240229');
+ expect(opusSpan!.status).toBe('ok');
+ expect(opusSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-opus-20240229');
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.9);
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_TOP_P].value).toBe(0.95);
+ expect(opusSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(200);
+ expect(opusSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
+ expect(opusSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined();
+ expect(opusSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
+ expect(opusSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
+ expect(opusSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
+
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
+ expect(errorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
});
},
{
@@ -173,32 +170,31 @@ describe('LangChain integration (v1)', () => {
'instrument.mjs',
(createRunner, test) => {
test('creates langchain spans with tool calls', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(1);
- const [firstSpan] = container.items;
-
- // [0] chat with tool_use stop reason
- expect(firstSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
- expect(firstSpan!.status).toBe('ok');
- expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(firstSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(firstSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
- expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
- expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(firstSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(150);
- expect(firstSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(20);
- expect(firstSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(30);
- expect(firstSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(50);
- expect(firstSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE].value).toBe('tool_use');
- expect(firstSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeDefined();
- },
- })
- .start()
- .completed();
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans).toHaveLength(1);
+ const [firstSpan] = spans;
+
+ // [0] chat with tool_use stop reason
+ expect(firstSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
+ expect(firstSpan!.status).toBe('ok');
+ expect(firstSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(firstSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(firstSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic');
+ expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022');
+ expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(firstSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(150);
+ expect(firstSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(20);
+ expect(firstSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(30);
+ expect(firstSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(50);
+ expect(firstSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE].value).toBe('tool_use');
+ expect(firstSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeDefined();
});
},
{
@@ -216,20 +212,159 @@ describe('LangChain integration (v1)', () => {
'instrument.mjs',
(createRunner, test) => {
test('derives gen_ai.conversation.id from the invoke config and lets a scope id win', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(4);
- const ids = container.items
- .sort((a, b) => a.start_timestamp - b.start_timestamp)
- .map(span => span.attributes[GEN_AI_CONVERSATION_ID]?.value);
- expect(ids).toEqual(['thread_from_config', 'session_from_config', 'conversation_from_scope', undefined]);
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans).toHaveLength(4);
+ const ids = spans
+ .sort((a, b) => a.start_timestamp - b.start_timestamp)
+ .map(span => span.attributes[GEN_AI_CONVERSATION_ID]?.value);
+ expect(ids).toEqual(['thread_from_config', 'session_from_config', 'conversation_from_scope', undefined]);
+ });
+ },
+ {
+ additionalDependencies: {
+ langchain: '^1.0.0',
+ '@langchain/core': '^1.0.0',
+ '@langchain/anthropic': '^1.0.0',
+ },
+ },
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-typesafe-classifier.mjs',
+ 'instrument-with-pii.mjs',
+ (createRunner, test) => {
+ test('records a TypeSafeClassifier run as a gen_ai.evaluate span', async () => {
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans.map(span => span.name)).toEqual(['evaluate jev-latest', 'evaluate jev-latest']);
+
+ const [evaluateSpan, messageEvaluateSpan] = spans.sort((a, b) => a.start_timestamp - b.start_timestamp);
+ expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
+ expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(evaluateSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('evaluate');
+ expect(evaluateSpan.attributes[GEN_AI_PROVIDER_NAME].value).toBe('typesafe');
+ expect(evaluateSpan.attributes[GEN_AI_REQUEST_MODEL].value).toBe('jev-latest');
+ expect(evaluateSpan.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('jev-1.13');
+ expect(evaluateSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(30);
+ expect(evaluateSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(2);
+ expect(evaluateSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(32);
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
+ {
+ type: 'evaluation',
+ state: 'My payouts have been failing.',
+ questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
+ },
+ ]);
+ // A message is recorded as the transcript line the classifier sends, not as LangChain's serialized form.
+ expect(JSON.parse(messageEvaluateSpan!.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
+ {
+ type: 'evaluation',
+ state: 'user: My card was charged twice.',
+ questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
+ },
+ ]);
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
+ { type: 'evaluation', answers: { urgent: { type: 'noul', noul: 0.9 } } },
+ ]);
+ });
+ },
+ {
+ additionalDependencies: {
+ langchain: '^1.0.0',
+ '@langchain/core': '^1.0.0',
+ '@langchain/typesafe': '^0.0.2',
+ },
+ },
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-typesafe-classifier-inherited-callbacks.mjs',
+ 'instrument-with-pii.mjs',
+ (createRunner, test) => {
+ test('keeps the parent run callbacks for a TypeSafeClassifier called without config', async () => {
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ const segment = spans.find(span => span.is_segment && span.name === 'main');
+ expect(segment!.attributes['test.recorded_runs'].value).toBe('RunnableLambda:root,TypeSafeClassifier:child');
+ expect(spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EVALUATE)).toHaveLength(1);
+ });
+ },
+ {
+ additionalDependencies: {
+ langchain: '^1.0.0',
+ '@langchain/core': '^1.0.0',
+ '@langchain/typesafe': '^0.0.2',
+ '@langchain/langgraph': '^1.0.0',
+ },
+ },
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-typesafe-model-router.mjs',
+ 'instrument-with-pii.mjs',
+ (createRunner, test) => {
+ test('records the Jev model router call inside a createAgent run', async () => {
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ const evaluateSpan = spans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EVALUATE)!;
+ const chatSpan = spans.find(span => span.name === 'chat claude-3-5-haiku-20241022')!;
+
+ // The router classifies in `beforeAgent`, inside the agent run, where other chain steps are skipped.
+ expect(evaluateSpan.name).toBe('evaluate jev-latest');
+ expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(evaluateSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('support_agent');
+ expect(evaluateSpan.parent_span_id).toBe(chatSpan.parent_span_id);
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
+ {
+ type: 'evaluation',
+ // The router passes the latest human message, which the classifier sends as a transcript line.
+ state: 'user: Where is my refund?',
+ questions: {
+ model_route: {
+ type: 'choice',
+ instructions: 'Pick the model for this request.',
+ criteria: { fast: 'Simple requests', smart: 'Complex requests' },
+ },
+ },
+ },
+ ]);
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
+ {
+ type: 'evaluation',
+ answers: {
+ model_route: {
+ type: 'choice',
+ choice: 'fast',
+ probabilities: { fast: 0.9, smart: 0.1 },
+ confidence: 0.8,
+ },
},
- })
- .start()
- .completed();
+ },
+ ]);
});
},
{
@@ -237,6 +372,7 @@ describe('LangChain integration (v1)', () => {
langchain: '^1.0.0',
'@langchain/core': '^1.0.0',
'@langchain/anthropic': '^1.0.0',
+ '@langchain/typesafe': '^0.0.2',
},
},
);
@@ -247,30 +383,22 @@ describe('LangChain integration (v1)', () => {
'instrument.mjs',
(createRunner, test) => {
test('demonstrates timing issue with duplicate spans', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(2);
- const anthropicSpan = container.items.find(
- span => span.attributes['sentry.origin'].value === 'auto.ai.anthropic',
- );
- expect(anthropicSpan).toBeDefined();
- expect(anthropicSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
-
- const langchainSpan = container.items.find(
- span => span.attributes['sentry.origin'].value === 'auto.ai.langchain',
- );
- expect(langchainSpan).toBeDefined();
- expect(langchainSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
-
- // Third call (not present): Direct Anthropic call made AFTER LangChain import
- // is NOT instrumented, demonstrating the skip mechanism works for NEW clients.
- },
- })
- .start()
- .completed();
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span =>
+ span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' ||
+ span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.anthropic',
+ );
+ expect(spans).toHaveLength(2);
+ const anthropicSpan = spans.find(span => span.attributes[SENTRY_ORIGIN].value === 'auto.ai.anthropic');
+ expect(anthropicSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
+
+ const langchainSpan = spans.find(span => span.attributes[SENTRY_ORIGIN].value === 'auto.ai.langchain');
+ expect(langchainSpan!.name).toBe('chat claude-3-5-sonnet-20241022');
});
},
{
@@ -288,60 +416,52 @@ describe('LangChain integration (v1)', () => {
'instrument.mjs',
(createRunner, test) => {
test('creates langchain spans using initChatModel with OpenAI', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(3);
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat error-model',
- 'chat gpt-3.5-turbo',
- 'chat gpt-4o',
- ]);
-
- const gpt4oSpan = container.items.find(span => span.name === 'chat gpt-4o');
- expect(gpt4oSpan).toBeDefined();
- expect(gpt4oSpan!.status).toBe('ok');
- expect(gpt4oSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(gpt4oSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(gpt4oSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
- expect(gpt4oSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
- expect(gpt4oSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gpt-4o');
- expect(gpt4oSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
- expect(gpt4oSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
- expect(gpt4oSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
- expect(gpt4oSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
- expect(gpt4oSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
- expect(gpt4oSpan!.attributes[GEN_AI_RESPONSE_ID]).toBeDefined();
- expect(gpt4oSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-4o');
- expect(gpt4oSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE].value).toBe('stop');
-
- const gpt35Span = container.items.find(span => span.name === 'chat gpt-3.5-turbo');
- expect(gpt35Span).toBeDefined();
- expect(gpt35Span!.status).toBe('ok');
- expect(gpt35Span!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(gpt35Span!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(gpt35Span!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
- expect(gpt35Span!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gpt-3.5-turbo');
- expect(gpt35Span!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.5);
- expect(gpt35Span!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
- expect(gpt35Span!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
- expect(gpt35Span!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
- expect(gpt35Span!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-3.5-turbo');
- expect(gpt35Span!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE].value).toBe('stop');
-
- const errorSpan = container.items.find(span => span.name === 'chat error-model');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.status).toBe('error');
- expect(errorSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(errorSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain');
- expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
- expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
- },
- })
- .start()
- .completed();
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = (await spansPromise).filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain',
+ );
+ expect(spans).toHaveLength(3);
+ expect(spans.map(span => span.name).sort()).toEqual(['chat error-model', 'chat gpt-3.5-turbo', 'chat gpt-4o']);
+
+ const gpt4oSpan = spans.find(span => span.name === 'chat gpt-4o');
+ expect(gpt4oSpan!.status).toBe('ok');
+ expect(gpt4oSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(gpt4oSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(gpt4oSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
+ expect(gpt4oSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
+ expect(gpt4oSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gpt-4o');
+ expect(gpt4oSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.7);
+ expect(gpt4oSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS].value).toBe(100);
+ expect(gpt4oSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
+ expect(gpt4oSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
+ expect(gpt4oSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
+ expect(gpt4oSpan!.attributes[GEN_AI_RESPONSE_ID]).toBeDefined();
+ expect(gpt4oSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-4o');
+ expect(gpt4oSpan!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE].value).toBe('stop');
+
+ const gpt35Span = spans.find(span => span.name === 'chat gpt-3.5-turbo');
+ expect(gpt35Span!.status).toBe('ok');
+ expect(gpt35Span!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(gpt35Span!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(gpt35Span!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
+ expect(gpt35Span!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gpt-3.5-turbo');
+ expect(gpt35Span!.attributes[GEN_AI_REQUEST_TEMPERATURE].value).toBe(0.5);
+ expect(gpt35Span!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(8);
+ expect(gpt35Span!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(12);
+ expect(gpt35Span!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(20);
+ expect(gpt35Span!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-3.5-turbo');
+ expect(gpt35Span!.attributes[GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE].value).toBe('stop');
+
+ const errorSpan = spans.find(span => span.name === 'chat error-model');
+ expect(errorSpan!.status).toBe('error');
+ expect(errorSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.chat');
+ expect(errorSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(errorSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
+ expect(errorSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('error-model');
});
},
{
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs
index b080c8d5ec03..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs
@@ -2,17 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Filter out mock express server transactions
- if (event.transaction && event.transaction.includes('/v1/messages')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-span-streaming.mjs
deleted file mode 100644
index 657bed0a3a8c..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: true, outputs: true } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs
index bdf24d6bbd0e..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-with-pii.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs
index 42052d281304..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs
new file mode 100644
index 000000000000..91537c9e33f0
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs
@@ -0,0 +1,30 @@
+import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
+import * as Sentry from '@sentry/node';
+
+// A graph built on a custom `Annotation.Root` state (no `messages` channel). The invoke_agent span
+// records the whole state object on both the input and the output side, unlike the MessagesAnnotation
+// graphs the other scenarios use.
+const CustomState = Annotation.Root({
+ topic: Annotation(),
+ summary: Annotation(),
+});
+
+async function run() {
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const summarize = state => {
+ return { summary: `Summary of ${state.topic}` };
+ };
+
+ const graph = new StateGraph(CustomState)
+ .addNode('summarize', summarize)
+ .addEdge(START, 'summarize')
+ .addEdge('summarize', END)
+ .compile({ name: 'custom_state_agent' });
+
+ await graph.invoke({ topic: 'weather' });
+ });
+
+ await Sentry.flush(2000);
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts
index 3ec4961fb10f..53dc3f6846af 100644
--- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts
@@ -4,6 +4,7 @@ import {
GEN_AI_CONVERSATION_ID,
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
+ GEN_AI_OUTPUT_MESSAGES,
GEN_AI_PIPELINE_NAME,
GEN_AI_RESPONSE_MODEL,
GEN_AI_RESPONSE_TEXT,
@@ -28,21 +29,23 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('should instrument LangGraph with default PII settings', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'langgraph-test' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-test');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
+ expect(spans).toHaveLength(2);
+ expect(spans.map(span => span.name).sort()).toEqual([
'invoke_agent weather_assistant',
'invoke_agent weather_assistant',
]);
- const invokeAgentSpans = container.items.filter(span => span.name === 'invoke_agent weather_assistant');
+ const invokeAgentSpans = spans.filter(span => span.name === 'invoke_agent weather_assistant');
expect(invokeAgentSpans).toHaveLength(2);
for (const span of invokeAgentSpans) {
expect(span.status).toBe('ok');
- expect(span.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(span.attributes['sentry.origin'].value).toBe('auto.ai.langgraph');
+ expect(span.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(span.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langgraph');
expect(span.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent');
expect(span.attributes[GEN_AI_AGENT_NAME].value).toBe('weather_assistant');
expect(span.attributes[GEN_AI_PIPELINE_NAME].value).toBe('weather_assistant');
@@ -57,31 +60,31 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('should instrument LangGraph with genAI recording enabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'langgraph-test' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
+ const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-test');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
+ expect(spans).toHaveLength(2);
- const weatherTodaySpan = container.items.find(span =>
+ const weatherTodaySpan = spans.find(span =>
getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(
'What is the weather today?',
),
);
- expect(weatherTodaySpan).toBeDefined();
expect(weatherTodaySpan!.name).toBe('invoke_agent weather_assistant');
expect(weatherTodaySpan!.status).toBe('ok');
- expect(weatherTodaySpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(weatherTodaySpan!.attributes['sentry.origin'].value).toBe('auto.ai.langgraph');
+ expect(weatherTodaySpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(weatherTodaySpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langgraph');
- const weatherDetailsSpan = container.items.find(span =>
+ const weatherDetailsSpan = spans.find(span =>
getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(
'Tell me about the weather',
),
);
- expect(weatherDetailsSpan).toBeDefined();
expect(weatherDetailsSpan!.name).toBe('invoke_agent weather_assistant');
expect(weatherDetailsSpan!.status).toBe('ok');
- expect(weatherDetailsSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(weatherDetailsSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
},
})
.start()
@@ -92,19 +95,20 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('should capture tools from LangGraph agent', { timeout: 30000 }, async () => {
await createRunner()
- .expect({ transaction: { transaction: 'langgraph-tools-test' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
- expect(container.items.map(span => span.name).sort()).toEqual([
+ const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-tools-test');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
+ expect(spans).toHaveLength(2);
+ expect(spans.map(span => span.name).sort()).toEqual([
'invoke_agent tool_agent',
'invoke_agent tool_calling_agent',
]);
- const toolAgentInvokeSpan = container.items.find(span => span.name === 'invoke_agent tool_agent');
- expect(toolAgentInvokeSpan).toBeDefined();
+ const toolAgentInvokeSpan = spans.find(span => span.name === 'invoke_agent tool_agent');
expect(toolAgentInvokeSpan!.status).toBe('ok');
- expect(toolAgentInvokeSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(toolAgentInvokeSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(toolAgentInvokeSpan!.attributes[GEN_AI_TOOL_DEFINITIONS].value).toContain('get_weather');
expect(toolAgentInvokeSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toContain('What is the weather?');
expect(toolAgentInvokeSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-4-0613');
@@ -115,10 +119,9 @@ describe('LangGraph integration', () => {
expect(toolAgentInvokeSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
expect(toolAgentInvokeSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(40);
- const toolCallingInvokeSpan = container.items.find(span => span.name === 'invoke_agent tool_calling_agent');
- expect(toolCallingInvokeSpan).toBeDefined();
+ const toolCallingInvokeSpan = spans.find(span => span.name === 'invoke_agent tool_calling_agent');
expect(toolCallingInvokeSpan!.status).toBe('ok');
- expect(toolCallingInvokeSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(toolCallingInvokeSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(toolCallingInvokeSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toContain('San Francisco');
expect(toolCallingInvokeSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-4-0613');
expect(toolCallingInvokeSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toMatch(/"role":"tool"/);
@@ -137,31 +140,30 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-thread-id.mjs', 'instrument.mjs', (createRunner, test) => {
test('should capture thread_id as gen_ai.conversation.id', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'langgraph-thread-id-test' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
+ const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-thread-id-test');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
+ expect(spans).toHaveLength(3);
- const firstThreadSpan = container.items.find(
+ const firstThreadSpan = spans.find(
span => span.attributes[GEN_AI_CONVERSATION_ID]?.value === 'thread_abc123_session_1',
);
- expect(firstThreadSpan).toBeDefined();
expect(firstThreadSpan!.name).toBe('invoke_agent thread_test_agent');
expect(firstThreadSpan!.status).toBe('ok');
- expect(firstThreadSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(firstThreadSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
- const secondThreadSpan = container.items.find(
+ const secondThreadSpan = spans.find(
span => span.attributes[GEN_AI_CONVERSATION_ID]?.value === 'thread_xyz789_session_2',
);
- expect(secondThreadSpan).toBeDefined();
expect(secondThreadSpan!.name).toBe('invoke_agent thread_test_agent');
expect(secondThreadSpan!.status).toBe('ok');
- const noThreadSpan = container.items.find(
+ const noThreadSpan = spans.find(
span =>
span.name === 'invoke_agent thread_test_agent' && span.attributes[GEN_AI_CONVERSATION_ID] === undefined,
);
- expect(noThreadSpan).toBeDefined();
expect(noThreadSpan!.status).toBe('ok');
},
})
@@ -177,13 +179,16 @@ describe('LangGraph integration', () => {
(createRunner, test) => {
test('extracts system instructions from messages', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(1);
- const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent test-agent');
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph',
+ );
+ expect(spans).toHaveLength(1);
+ const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent test-agent');
- expect(invokeAgentSpan).toBeDefined();
expect(invokeAgentSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe(
JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]),
);
@@ -199,28 +204,20 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-resume.mjs', 'instrument.mjs', (createRunner, test) => {
test('should not throw when invoke is called with null input (resume scenario)', async () => {
await createRunner()
- .expect({
- transaction: {
- transaction: 'langgraph-resume-test',
- contexts: {
- trace: expect.objectContaining({
- status: 'ok',
- }),
- },
- },
- })
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
+ const segment = container.items.find(span => span.is_segment && span.name === 'langgraph-resume-test');
+ expect(segment?.status).toBe('ok');
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langgraph');
+ expect(spans).toHaveLength(2);
- const invokeAgentSpan = container.items.find(
+ const invokeAgentSpan = spans.find(
span => span.attributes[GEN_AI_CONVERSATION_ID]?.value === 'resume-thread-1',
);
- expect(invokeAgentSpan).toBeDefined();
expect(invokeAgentSpan!.name).toBe('invoke_agent resume_agent');
expect(invokeAgentSpan!.status).toBe('ok');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(invokeAgentSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langgraph');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langgraph');
expect(invokeAgentSpan!.attributes[GEN_AI_AGENT_NAME].value).toBe('resume_agent');
expect(invokeAgentSpan!.attributes[GEN_AI_PIPELINE_NAME].value).toBe('resume_agent');
},
@@ -230,21 +227,26 @@ describe('LangGraph integration', () => {
});
});
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('creates langgraph related spans with span streaming enabled', async () => {
+ // Custom `Annotation.Root` state has no `messages` channel, so the whole state object is recorded on
+ // both the input and the output side of the invoke_agent span.
+ createEsmAndCjsTests(__dirname, 'scenario-custom-state.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
+ test('records custom Annotation.Root state as input and output on the invoke_agent span', async () => {
await createRunner()
.expect({
span: container => {
- const weatherTodaySpan = container.items.find(span =>
- getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(
- 'What is the weather today?',
- ),
- );
- expect(weatherTodaySpan).toBeDefined();
- expect(weatherTodaySpan!.name).toBe('invoke_agent weather_assistant');
- expect(weatherTodaySpan!.status).toBe('ok');
- expect(weatherTodaySpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(weatherTodaySpan!.attributes['sentry.origin'].value).toBe('auto.ai.langgraph');
+ const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent custom_state_agent');
+ expect(invokeAgentSpan).toBeDefined();
+ expect(invokeAgentSpan!.status).toBe('ok');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langgraph');
+
+ const inputMessages = getStringAttributeValue(invokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value);
+ expect(inputMessages).toContain('"role":"user"');
+ expect(inputMessages).toContain('weather');
+
+ const outputMessages = getStringAttributeValue(invokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value);
+ expect(outputMessages).toContain('"role":"assistant"');
+ expect(outputMessages).toContain('Summary of weather');
},
})
.start()
@@ -259,15 +261,10 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'agent-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => {
test('should instrument createReactAgent with agent and chat spans', { timeout: 30000 }, async () => {
await createRunner()
- .expect({
- transaction: event => {
- const spans = event.spans ?? [];
- expect(event.transaction).toBe('main');
- expect(spans).toContainEqual(expect.objectContaining({ op: 'http.client' }));
- },
- })
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ expect(container.items.find(span => span.attributes[SENTRY_OP]?.value === 'http.client')).toBeDefined();
const spans = container.items;
expect(spans).toContainEqual(
expect.objectContaining({
@@ -301,15 +298,10 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'agent-tools-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => {
test('should create tool execution spans for createReactAgent with tools', { timeout: 30000 }, async () => {
await createRunner()
- .expect({
- transaction: event => {
- const spans = event.spans ?? [];
- expect(event.transaction).toBe('main');
- expect(spans.filter(span => span.op === 'http.client')).toHaveLength(3);
- },
- })
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ expect(container.items.filter(span => span.attributes[SENTRY_OP]?.value === 'http.client')).toHaveLength(3);
const spans = container.items;
expect(spans).toContainEqual(
expect.objectContaining({
@@ -361,13 +353,9 @@ describe('LangGraph integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-stategraph-chat.mjs', 'instrument-agent.mjs', (createRunner, test) => {
test('auto-injects langchain handler for plain StateGraph and emits chat spans', { timeout: 30000 }, async () => {
await createRunner()
- .expect({
- transaction: event => {
- expect(event.transaction).toBe('main');
- },
- })
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
const chatSpans = container.items.filter(s => s.attributes[SENTRY_OP]?.value === 'gen_ai.chat');
expect(chatSpans).toHaveLength(1);
expect(chatSpans[0]?.attributes[GEN_AI_AGENT_NAME]?.value).toBe('plain_assistant');
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/instrument-with-pii.mjs
similarity index 92%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs
rename to dev-packages/node-integration-tests/suites/tracing/langgraph/v1/instrument-with-pii.mjs
index bdf24d6bbd0e..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/instrument-with-pii.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/instrument.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs
rename to dev-packages/node-integration-tests/suites/tracing/langgraph/v1/instrument.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/scenario-typesafe-classifier-node.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/scenario-typesafe-classifier-node.mjs
new file mode 100644
index 000000000000..33256a644def
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/scenario-typesafe-classifier-node.mjs
@@ -0,0 +1,37 @@
+import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
+import { TypeSafeClassifier } from '@langchain/typesafe';
+import * as Sentry from '@sentry/node';
+
+// Answer in-process through the classifier's `fetch` option, so no mock server is needed.
+async function mockTypeSafeFetch() {
+ return new Response(
+ JSON.stringify({
+ model: 'jev-1.13',
+ answers: { urgent: { type: 'noul', noul: 0.9 } },
+ usage: { input_tokens: 30, output_tokens: 2 },
+ }),
+ { headers: { 'content-type': 'application/json' } },
+ );
+}
+
+async function run() {
+ const classifier = new TypeSafeClassifier({
+ apiKey: 'mock-api-key',
+ fetch: mockTypeSafeFetch,
+ questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
+ });
+
+ const State = Annotation.Root({ ticket: Annotation(), urgent: Annotation() });
+
+ await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
+ const graph = new StateGraph(State)
+ .addNode('triage', async state => ({ urgent: (await classifier.invoke(state.ticket)).nouls.urgent.noul }))
+ .addEdge(START, 'triage')
+ .addEdge('triage', END)
+ .compile({ name: 'triage_graph' });
+
+ await graph.invoke({ ticket: 'My payouts have been failing.' });
+ });
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/test.ts
new file mode 100644
index 000000000000..c6a71539de1a
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/v1/test.ts
@@ -0,0 +1,88 @@
+import { afterAll, describe, expect } from 'vitest';
+import {
+ GEN_AI_AGENT_NAME,
+ GEN_AI_INPUT_MESSAGES,
+ GEN_AI_OUTPUT_MESSAGES,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+} from '@sentry/conventions/attributes';
+import { GEN_AI_EVALUATE } from '@sentry/conventions/op';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+
+describe('LangGraph integration (v1)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-typesafe-classifier-node.mjs',
+ 'instrument-with-pii.mjs',
+ (createRunner, test) => {
+ test('records a TypeSafeClassifier call made inside a StateGraph node', async () => {
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ const rootSpan = spans.find(span => span.is_segment && span.name === 'main')!;
+ const agentSpan = spans.find(span => span.name === 'invoke_agent triage_graph')!;
+ const evaluateSpan = spans.find(span => span.name === 'evaluate jev-latest')!;
+
+ expect(agentSpan.parent_span_id).toBe(rootSpan.span_id);
+ expect(evaluateSpan.parent_span_id).toBe(agentSpan.span_id);
+ expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
+ expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.langchain');
+ expect(evaluateSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('triage_graph');
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
+ {
+ type: 'evaluation',
+ state: 'My payouts have been failing.',
+ questions: { urgent: { type: 'noul', instructions: 'Is this urgent?' } },
+ },
+ ]);
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
+ { type: 'evaluation', answers: { urgent: { type: 'noul', noul: 0.9 } } },
+ ]);
+ });
+ },
+ {
+ additionalDependencies: {
+ langchain: '^1.0.0',
+ '@langchain/core': '^1.0.0',
+ '@langchain/langgraph': '^1.0.0',
+ '@langchain/typesafe': '^0.0.2',
+ },
+ },
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-typesafe-classifier-node.mjs',
+ 'instrument.mjs',
+ (createRunner, test) => {
+ test('omits the classifier input and output when genAI recording is off', async () => {
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const spans = await spansPromise;
+ const evaluateSpan = spans.find(span => span.name === 'evaluate jev-latest')!;
+
+ expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
+ expect(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
+ expect(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
+ });
+ },
+ {
+ additionalDependencies: {
+ langchain: '^1.0.0',
+ '@langchain/core': '^1.0.0',
+ '@langchain/langgraph': '^1.0.0',
+ '@langchain/typesafe': '^0.0.2',
+ },
+ },
+ );
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-no-bootstrap.mjs b/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-no-bootstrap.mjs
index f4ebcd4a74cc..377693b0a7f1 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-no-bootstrap.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-no-bootstrap.mjs
@@ -2,15 +2,10 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
integrations: [Sentry.mastraIntegration({ bootstrapObservability: false })],
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Drop the in-process mock provider's own transactions.
- return event.transaction?.includes('/v1/chat/completions') ? null : event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-with-pii.mjs
index 6cd8bfaa55bc..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/mastra/instrument-with-pii.mjs
@@ -2,14 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Drop the in-process mock provider's own transactions.
- return event.transaction?.includes('/v1/chat/completions') ? null : event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mastra/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mastra/instrument.mjs
index ec49598a39c9..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mastra/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/mastra/instrument.mjs
@@ -2,14 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- // Drop the in-process mock provider's own transactions.
- return event.transaction?.includes('/v1/chat/completions') ? null : event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mastra/scenario-classifier-error.mjs b/dev-packages/node-integration-tests/suites/tracing/mastra/scenario-classifier-error.mjs
new file mode 100644
index 000000000000..7a70b22c7527
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/mastra/scenario-classifier-error.mjs
@@ -0,0 +1,48 @@
+import * as Sentry from '@sentry/node';
+import { Mastra } from '@mastra/core';
+import { Classifier } from '@mastra/core/classifier';
+import { Observability } from '@mastra/observability';
+import { SentryMastraExporter } from '@sentry/node';
+
+// Inlined `EvaluationModelV4` mock: the ESM/CJS runner copies only this file into its temp dir.
+const evaluationModel = {
+ specificationVersion: 'v4',
+ provider: 'typesafe-ai.evaluation',
+ modelId: 'jev-latest',
+ supportedQuestionTypes: ['choice', 'score', 'boolean'],
+ async doEvaluate() {
+ throw new Error('evaluation failed');
+ },
+};
+
+async function run() {
+ const classifier = new Classifier({
+ id: 'request-classifier',
+ model: evaluationModel,
+ questions: { urgent: { type: 'boolean', instructions: 'Does this request need an immediate response?' } },
+ });
+
+ const mastra = new Mastra({
+ classifiers: { classifier },
+ logger: false,
+ observability: new Observability({
+ configs: {
+ default: {
+ serviceName: 'mastra-test',
+ exporters: [new SentryMastraExporter()],
+ },
+ },
+ }),
+ });
+
+ await Sentry.startSpan({ op: 'function', name: 'mastra-test' }, async () => {
+ await mastra
+ .getClassifier('classifier')
+ .evaluate({ state: { message: 'I was charged twice.' }, maxRetries: 0 })
+ .catch(() => undefined);
+ });
+
+ await mastra.observability.shutdown();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/mastra/scenario-classifier.mjs b/dev-packages/node-integration-tests/suites/tracing/mastra/scenario-classifier.mjs
new file mode 100644
index 000000000000..e4e5efb15074
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/mastra/scenario-classifier.mjs
@@ -0,0 +1,51 @@
+import * as Sentry from '@sentry/node';
+import { Mastra } from '@mastra/core';
+import { Classifier } from '@mastra/core/classifier';
+import { Observability } from '@mastra/observability';
+import { SentryMastraExporter } from '@sentry/node';
+
+// Inlined `EvaluationModelV4` mock: the ESM/CJS runner copies only this file into its temp dir.
+const evaluationModel = {
+ specificationVersion: 'v4',
+ provider: 'typesafe-ai.evaluation',
+ modelId: 'jev-latest',
+ supportedQuestionTypes: ['choice', 'score', 'boolean'],
+ async doEvaluate() {
+ return {
+ answers: { urgent: { type: 'boolean', probability: 0.9 } },
+ usage: { inputTokens: 30, outputTokens: 2 },
+ warnings: [],
+ };
+ },
+};
+
+async function run() {
+ const classifier = new Classifier({
+ id: 'request-classifier',
+ model: evaluationModel,
+ questions: { urgent: { type: 'boolean', instructions: 'Does this request need an immediate response?' } },
+ });
+
+ const mastra = new Mastra({
+ classifiers: { classifier },
+ logger: false,
+ observability: new Observability({
+ configs: {
+ default: {
+ serviceName: 'mastra-test',
+ exporters: [new SentryMastraExporter()],
+ },
+ },
+ }),
+ });
+
+ await Sentry.startSpan({ op: 'function', name: 'mastra-test' }, async () => {
+ await mastra
+ .getClassifier('classifier')
+ .evaluate({ state: { message: 'I was charged twice.', apiKey: 'sk-test-123' } });
+ });
+
+ await mastra.observability.shutdown();
+}
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/mastra/test.ts b/dev-packages/node-integration-tests/suites/tracing/mastra/test.ts
index 728a12762da6..d26ea2281428 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mastra/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mastra/test.ts
@@ -1,4 +1,5 @@
import {
+ DB_OPERATION_NAME,
GEN_AI_AGENT_NAME,
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
@@ -15,7 +16,11 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ URL_FULL,
} from '@sentry/conventions/attributes';
+import { GEN_AI_CHAT, GEN_AI_EVALUATE, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op';
import { afterAll, expect } from 'vitest';
import { conditionalTest } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
@@ -39,6 +44,14 @@ const MASTRA_NESTING_DEPENDENCIES = {
},
};
+// `Classifier` (the `@mastra/core/classifier` entry) ships in newer `@mastra/core` releases only.
+const MASTRA_CLASSIFIER_DEPENDENCIES = {
+ additionalDependencies: {
+ '@mastra/core': '1.74.0',
+ '@mastra/observability': '1.18.3',
+ },
+};
+
conditionalTest({ min: 22 })('Mastra integration', () => {
afterAll(() => {
cleanupChildProcesses();
@@ -51,18 +64,16 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
(createRunner, test) => {
test('creates invoke_agent and chat spans following the gen_ai conventions', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat gpt-4o-mini',
- 'invoke_agent weather_agent',
- ]);
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name).sort()).toEqual(['chat gpt-4o-mini', 'invoke_agent weather_agent']);
- const agentSpan = container.items.find(span => span.name === 'invoke_agent weather_agent')!;
+ const agentSpan = spans.find(span => span.name === 'invoke_agent weather_agent')!;
expect(agentSpan.status).toBe('ok');
- expect(agentSpan.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(agentSpan.attributes['sentry.origin'].value).toBe('auto.ai.mastra');
+ expect(agentSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_INVOKE_AGENT);
+ expect(agentSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.mastra');
expect(agentSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent');
expect(agentSpan.attributes[GEN_AI_AGENT_NAME].value).toBe('weather_agent');
expect(agentSpan.attributes[GEN_AI_PIPELINE_NAME].value).toBe('weather_agent');
@@ -71,10 +82,10 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
expect(agentSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(19);
expect(agentSpan.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gpt-4o-mini');
- const chatSpan = container.items.find(span => span.name === 'chat gpt-4o-mini')!;
+ const chatSpan = spans.find(span => span.name === 'chat gpt-4o-mini')!;
expect(chatSpan.status).toBe('ok');
- expect(chatSpan.attributes['sentry.op'].value).toBe('gen_ai.chat');
- expect(chatSpan.attributes['sentry.origin'].value).toBe('auto.ai.mastra');
+ expect(chatSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_CHAT);
+ expect(chatSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.mastra');
expect(chatSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat');
expect(chatSpan.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gpt-4o-mini');
expect(chatSpan.attributes[GEN_AI_PROVIDER_NAME].value).toBe('openai');
@@ -89,10 +100,12 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
test('omits prompts and responses when genAI recording is off', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- for (const span of container.items) {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans).toHaveLength(2);
+ for (const span of spans) {
expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
expect(span.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
@@ -113,15 +126,16 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
(createRunner, test) => {
test('records prompts and responses when genAI recording is on', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- const agentSpan = container.items.find(span => span.name === 'invoke_agent weather_agent')!;
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ const agentSpan = spans.find(span => span.name === 'invoke_agent weather_agent')!;
expect(agentSpan.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe('You report the weather.');
expect(agentSpan.attributes[GEN_AI_INPUT_MESSAGES].value).toContain('What is the weather in Berlin?');
expect(agentSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value).toContain('It is 22C in Berlin.');
- const chatSpan = container.items.find(span => span.name === 'chat gpt-4o-mini')!;
+ const chatSpan = spans.find(span => span.name === 'chat gpt-4o-mini')!;
expect(chatSpan.attributes[GEN_AI_INPUT_MESSAGES].value).toContain('What is the weather in Berlin?');
expect(chatSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value).toContain('It is 22C in Berlin.');
},
@@ -140,28 +154,29 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
(createRunner, test) => {
test('creates an execute_tool span parented onto the generation', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- expect(container.items.map(span => span.name).sort()).toEqual([
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name).sort()).toEqual([
'chat gpt-4o-mini',
'execute_tool get_weather',
'invoke_agent weather_agent',
]);
- const toolSpan = container.items.find(span => span.name === 'execute_tool get_weather')!;
+ const toolSpan = spans.find(span => span.name === 'execute_tool get_weather')!;
expect(toolSpan.status).toBe('ok');
- expect(toolSpan.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
- expect(toolSpan.attributes['sentry.origin'].value).toBe('auto.ai.mastra');
+ expect(toolSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EXECUTE_TOOL);
+ expect(toolSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.mastra');
expect(toolSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('execute_tool');
expect(toolSpan.attributes[GEN_AI_TOOL_NAME].value).toBe('get_weather');
expect(toolSpan.attributes[GEN_AI_TOOL_CALL_ARGUMENTS].value).toContain('Berlin');
expect(toolSpan.attributes[GEN_AI_TOOL_CALL_RESULT].value).toContain('22');
- const chatSpan = container.items.find(span => span.name === 'chat gpt-4o-mini')!;
+ const chatSpan = spans.find(span => span.name === 'chat gpt-4o-mini')!;
expect(toolSpan.parent_span_id).toBe(chatSpan.span_id);
- const agentSpan = container.items.find(span => span.name === 'invoke_agent weather_agent')!;
+ const agentSpan = spans.find(span => span.name === 'invoke_agent weather_agent')!;
expect(agentSpan.attributes[GEN_AI_TOOL_DEFINITIONS].value).toContain('get_weather');
expect(agentSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(50);
expect(agentSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(13);
@@ -182,13 +197,15 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
(createRunner, test) => {
test('exports the workflow run but drops workflow steps, which have no conventional op', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- expect(container.items.map(span => span.name)).toEqual(['invoke_agent math_workflow']);
+ const segment = container.items.find(span => span.is_segment && span.name === 'mastra-test');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name)).toEqual(['invoke_agent math_workflow']);
- const workflowSpan = container.items[0]!;
- expect(workflowSpan.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ const workflowSpan = spans[0]!;
+ expect(workflowSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_INVOKE_AGENT);
expect(workflowSpan.attributes[GEN_AI_PIPELINE_NAME].value).toBe('math_workflow');
},
})
@@ -199,6 +216,98 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
MASTRA_DEPENDENCIES,
);
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-classifier.mjs',
+ 'instrument.mjs',
+ (createRunner, test) => {
+ test('maps a classifier evaluation to a gen_ai.evaluate span', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name)).toEqual(['evaluate jev-latest']);
+
+ const evaluateSpan = spans[0]!;
+ expect(evaluateSpan.attributes[SENTRY_OP].value).toBe(GEN_AI_EVALUATE);
+ expect(evaluateSpan.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.mastra');
+ expect(evaluateSpan.attributes[GEN_AI_OPERATION_NAME].value).toBe('evaluate');
+ expect(evaluateSpan.attributes[GEN_AI_REQUEST_MODEL].value).toBe('jev-latest');
+ expect(evaluateSpan.attributes[GEN_AI_PROVIDER_NAME].value).toBe('typesafe-ai.evaluation');
+ expect(evaluateSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(30);
+ expect(evaluateSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(2);
+ expect(evaluateSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(32);
+ expect(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
+ expect(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ MASTRA_CLASSIFIER_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-classifier.mjs',
+ 'instrument-with-pii.mjs',
+ (createRunner, test) => {
+ test('records the evaluated state, questions and answers when genAI recording is on', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const evaluateSpan = container.items.find(span => span.name === 'evaluate jev-latest')!;
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value)).toEqual([
+ {
+ type: 'evaluation',
+ state: { message: 'I was charged twice.', apiKey: '[REDACTED]' },
+ questions: {
+ urgent: { type: 'boolean', instructions: 'Does this request need an immediate response?' },
+ },
+ },
+ ]);
+ expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES].value)).toEqual([
+ { type: 'evaluation', answers: { urgent: { type: 'boolean', probability: 0.9 } } },
+ ]);
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ MASTRA_CLASSIFIER_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-classifier-error.mjs',
+ 'instrument-with-pii.mjs',
+ (createRunner, test) => {
+ test('ends a failed classifier evaluation span with an error status', async () => {
+ await createRunner()
+ .ignore('event')
+ .expect({
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name)).toEqual(['evaluate jev-latest']);
+
+ const evaluateSpan = spans[0]!;
+ expect(evaluateSpan.status).toBe('error');
+ expect(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES].value).toContain('I was charged twice.');
+ expect(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ MASTRA_CLASSIFIER_DEPENDENCIES,
+ );
+
createEsmAndCjsTests(
__dirname,
'scenario-auto.mjs',
@@ -206,15 +315,13 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
(createRunner, test) => {
test('auto-instruments a Mastra app that configured no observability at all', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat gpt-4o-mini',
- 'invoke_agent weather_agent',
- ]);
- for (const span of container.items) {
- expect(span.attributes['sentry.origin'].value).toBe('auto.ai.mastra');
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name).sort()).toEqual(['chat gpt-4o-mini', 'invoke_agent weather_agent']);
+ for (const span of spans) {
+ expect(span.attributes[SENTRY_ORIGIN].value).toBe('auto.ai.mastra');
}
},
})
@@ -232,13 +339,11 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
(createRunner, test) => {
test('still exports its own spans when the community @mastra/sentry exporter is present', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'mastra-test' } })
.expect({
span: container => {
- expect(container.items.map(span => span.name).sort()).toEqual([
- 'chat gpt-4o-mini',
- 'invoke_agent weather_agent',
- ]);
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.mastra');
+ expect(spans.map(span => span.name).sort()).toEqual(['chat gpt-4o-mini', 'invoke_agent weather_agent']);
},
})
.start()
@@ -255,9 +360,11 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
test('leaves an unconfigured app alone when bootstrapObservability is false', async () => {
await createRunner()
.expect({
- transaction: event => {
- expect(event.transaction).toBe('mastra-test');
- const genAiSpans = (event.spans ?? []).filter(span => span.op?.startsWith('gen_ai.'));
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const genAiSpans = container.items.filter(span =>
+ String(span.attributes[SENTRY_OP]?.value ?? '').startsWith('gen_ai.'),
+ );
expect(genAiSpans).toEqual([]);
},
})
@@ -274,56 +381,48 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
'instrument.mjs',
(createRunner, test) => {
test('nests the model fetch and tool work under the exporter spans', async () => {
- // The exporter's gen_ai spans stream as their own span items; the `dataloader` and model
- // `http.client` spans ride in the transaction event. Cross-reference once both have arrived.
- let chatSpanId: string | undefined;
- let executeToolSpanId: string | undefined;
- let modelFetchParentIds: (string | undefined)[] = [];
- let cacheGetParentIds: (string | undefined)[] = [];
-
await createRunner()
.expect({
- transaction: event => {
- expect(event.transaction).toBe('mastra-test');
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
// The model-provider requests Mastra makes for each step.
- const modelFetches = (event.spans ?? []).filter(
- span => span.op === 'http.client' && (span.description ?? '').includes('/v1/chat/completions'),
+ const modelFetches = container.items.filter(
+ span =>
+ span.attributes[SENTRY_OP]?.value === 'http.client' &&
+ String(span.attributes[URL_FULL]?.value ?? '').includes('/v1/chat/completions'),
);
expect(modelFetches.length).toBeGreaterThan(0);
- modelFetchParentIds = modelFetches.map(span => span.parent_span_id);
+ const modelFetchParentIds = modelFetches.map(span => span.parent_span_id);
// The per-key `dataloader.load` spans opened while the tool runs. (dataloader also emits a
// `dataloader.batch` span nested under a load — its own internal structure.)
- const loadSpans = (event.spans ?? []).filter(
- span => span.op === 'cache.get' && span.description === 'dataloader.load',
+ const loadSpans = container.items.filter(
+ span =>
+ span.attributes[SENTRY_OP]?.value === 'cache.get' &&
+ span.attributes[DB_OPERATION_NAME]?.value === 'load',
);
expect(loadSpans.length).toBeGreaterThan(0);
- cacheGetParentIds = loadSpans.map(span => span.parent_span_id);
- },
- })
- .expect({
- span: container => {
- const chat = container.items.find(span => span.attributes['sentry.op']?.value === 'gen_ai.chat')!;
+ const cacheGetParentIds = loadSpans.map(span => span.parent_span_id);
+ const chat = container.items.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT)!;
expect(chat).toBeDefined();
- chatSpanId = chat.span_id;
+ const chatSpanId = chat.span_id;
const executeTool = container.items.find(
span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'count_items',
)!;
- expect(executeTool).toBeDefined();
- expect(executeTool.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
- executeToolSpanId = executeTool.span_id;
+ expect(executeTool.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL);
+ const executeToolSpanId = executeTool.span_id;
+
+ // The `executeWithContext` bridge makes the exporter spans active during the real work, so the
+ // model fetch parents onto the `chat` span and the dataloader loads onto `execute_tool`.
+ expect(chatSpanId).toBeDefined();
+ expect(executeToolSpanId).toBeDefined();
+ expect(new Set(modelFetchParentIds)).toEqual(new Set([chatSpanId]));
+ expect(new Set(cacheGetParentIds)).toEqual(new Set([executeToolSpanId]));
},
})
.start()
.completed();
-
- // The `executeWithContext` bridge makes the exporter spans active during the real work, so the
- // model fetch parents onto the `chat` span and the dataloader loads onto `execute_tool`.
- expect(chatSpanId).toBeDefined();
- expect(executeToolSpanId).toBeDefined();
- expect(new Set(modelFetchParentIds)).toEqual(new Set([chatSpanId]));
- expect(new Set(cacheGetParentIds)).toEqual(new Set([executeToolSpanId]));
});
},
MASTRA_NESTING_DEPENDENCIES,
@@ -337,49 +436,44 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
test('nests the model fetch and tool work under the exporter spans when streaming', async () => {
// Same nesting guarantee as `generate`, but the agent is driven via `stream()` — the model
// request is a real streaming (SSE) call, and the tool still runs inside `executeWithContext`.
- let chatSpanId: string | undefined;
- let executeToolSpanId: string | undefined;
- let modelFetchParentIds: (string | undefined)[] = [];
- let cacheGetParentIds: (string | undefined)[] = [];
await createRunner()
.expect({
- transaction: event => {
- expect(event.transaction).toBe('mastra-test');
- const modelFetches = (event.spans ?? []).filter(
- span => span.op === 'http.client' && (span.description ?? '').includes('/v1/chat/completions'),
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-test')).toBeDefined();
+ const modelFetches = container.items.filter(
+ span =>
+ span.attributes[SENTRY_OP]?.value === 'http.client' &&
+ String(span.attributes[URL_FULL]?.value ?? '').includes('/v1/chat/completions'),
);
expect(modelFetches.length).toBeGreaterThan(0);
- modelFetchParentIds = modelFetches.map(span => span.parent_span_id);
+ const modelFetchParentIds = modelFetches.map(span => span.parent_span_id);
- const loadSpans = (event.spans ?? []).filter(
- span => span.op === 'cache.get' && span.description === 'dataloader.load',
+ const loadSpans = container.items.filter(
+ span =>
+ span.attributes[SENTRY_OP]?.value === 'cache.get' &&
+ span.attributes[DB_OPERATION_NAME]?.value === 'load',
);
expect(loadSpans.length).toBeGreaterThan(0);
- cacheGetParentIds = loadSpans.map(span => span.parent_span_id);
- },
- })
- .expect({
- span: container => {
- const chat = container.items.find(span => span.attributes['sentry.op']?.value === 'gen_ai.chat')!;
+ const cacheGetParentIds = loadSpans.map(span => span.parent_span_id);
+ const chat = container.items.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT)!;
expect(chat).toBeDefined();
- chatSpanId = chat.span_id;
+ const chatSpanId = chat.span_id;
const executeTool = container.items.find(
span => span.attributes[GEN_AI_TOOL_NAME]?.value === 'count_items',
)!;
- expect(executeTool).toBeDefined();
- expect(executeTool.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
- executeToolSpanId = executeTool.span_id;
+ expect(executeTool.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL);
+ const executeToolSpanId = executeTool.span_id;
+
+ expect(chatSpanId).toBeDefined();
+ expect(executeToolSpanId).toBeDefined();
+ expect(new Set(modelFetchParentIds)).toEqual(new Set([chatSpanId]));
+ expect(new Set(cacheGetParentIds)).toEqual(new Set([executeToolSpanId]));
},
})
.start()
.completed();
-
- expect(chatSpanId).toBeDefined();
- expect(executeToolSpanId).toBeDefined();
- expect(new Set(modelFetchParentIds)).toEqual(new Set([chatSpanId]));
- expect(new Set(cacheGetParentIds)).toEqual(new Set([executeToolSpanId]));
});
},
MASTRA_NESTING_DEPENDENCIES,
@@ -394,40 +488,37 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
// Two instances run concurrently in one trace. Each instance's `dataloader.load` must nest under
// that instance's own `execute_tool` span — if the registry cross-talked, one tool span would
// have no dataloader child.
- const toolSpanIds: Record = {};
- let loadParentIds: (string | undefined)[] = [];
await createRunner()
- .expect({
- transaction: event => {
- expect(event.transaction).toBe('mastra-multi');
- const loadSpans = (event.spans ?? []).filter(
- span => span.op === 'cache.get' && span.description === 'dataloader.load',
- );
- loadParentIds = loadSpans.map(span => span.parent_span_id);
- },
- })
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'mastra-multi')).toBeDefined();
+ const loadSpans = container.items.filter(
+ span =>
+ span.attributes[SENTRY_OP]?.value === 'cache.get' &&
+ span.attributes[DB_OPERATION_NAME]?.value === 'load',
+ );
+ const loadParentIds = loadSpans.map(span => span.parent_span_id);
+ const toolSpanIds: Record = {};
for (const toolName of ['count_a', 'count_b']) {
const toolSpan = container.items.find(span => span.attributes[GEN_AI_TOOL_NAME]?.value === toolName)!;
expect(toolSpan).toBeDefined();
toolSpanIds[toolName] = toolSpan.span_id;
}
+
+ // Each instance's tool span parents at least one dataloader load — neither is starved.
+ for (const toolName of ['count_a', 'count_b']) {
+ expect(loadParentIds).toContain(toolSpanIds[toolName]);
+ }
+ // And every load parents onto one of the two tool spans (no stray reparenting).
+ const toolIds = new Set(Object.values(toolSpanIds));
+ for (const parent of loadParentIds) {
+ expect(toolIds.has(parent!)).toBe(true);
+ }
},
})
.start()
.completed();
-
- // Each instance's tool span parents at least one dataloader load — neither is starved.
- for (const toolName of ['count_a', 'count_b']) {
- expect(loadParentIds).toContain(toolSpanIds[toolName]);
- }
- // And every load parents onto one of the two tool spans (no stray reparenting).
- const toolIds = new Set(Object.values(toolSpanIds));
- for (const parent of loadParentIds) {
- expect(toolIds.has(parent!)).toBe(true);
- }
});
},
MASTRA_NESTING_DEPENDENCIES,
@@ -439,10 +530,9 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
'instrument.mjs',
(createRunner, test) => {
test('captures an error thrown in a Mastra tool as a Sentry issue', async () => {
- // The scenario also emits the `mastra-test` transaction and its streamed gen_ai spans; their
- // flush order relative to the error event races, so ignore them and match only the issue.
+ // Span containers can arrive before the error event, so match only the issue.
await createRunner()
- .ignore('transaction', 'span')
+ .ignore('span')
.expect({
event: event => {
const exception = event.exception?.values?.[0];
diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument.mjs
index 450e0dd3ebfe..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument.mjs
@@ -6,5 +6,4 @@ Sentry.init({
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
- traceLifecycle: process.env.STREAMED === 'true' ? 'stream' : 'static',
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
index e0db6de0be75..2a9cb3c3010a 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts
@@ -1,18 +1,35 @@
+import {
+ DB_CONNECTION_STRING,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+ DB_USER,
+ SENTRY_ENVIRONMENT,
+ SENTRY_IS_LOCALHOST,
+ SENTRY_KIND,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SENTRY_RELEASE,
+ SENTRY_SDK_NAME,
+ SENTRY_SDK_VERSION,
+ SENTRY_SEGMENT_ID,
+ SENTRY_SEGMENT_NAME,
+ SENTRY_TRACE_LIFECYCLE,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import type { AddressInfo, Server } from 'node:net';
import { afterAll, beforeAll, describe, expect } from 'vitest';
-import { cleanupChildProcesses, createCjsTests, createEsmAndCjsTests } from '../../../utils/runner';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
import { startMysqlTestServer } from './mysql-test-server';
-import type { SerializedStreamedSpanContainer } from '@sentry/core';
-import { SENTRY_TRACE_LIFECYCLE, SENTRY_OP } from '@sentry/conventions/attributes';
describe('mysql auto instrumentation', () => {
// A minimal in-process MySQL server (on a random free port) so the client's
// connection handshake succeeds. Without it, `createPool()` queries fail at
// connection acquisition — before `connection.query` runs — so the
// diagnostics-channel instrumentation (which hooks `connection.query`) never
- // sees them. Queries still error (the server rejects them), so spans keep
- // `status: internal_error` as the assertions expect. The port is passed to
- // each scenario via the `MYSQL_PORT` env var.
+ // sees them. The port is passed to each scenario via the `MYSQL_PORT` env var.
let mysqlServer: Server;
let mysqlPort: number;
beforeAll(async () => {
@@ -26,36 +43,97 @@ describe('mysql auto instrumentation', () => {
cleanupChildProcesses();
});
- // Builds the expected transaction. When `origin` is given, the spans must also
- // carry that `sentry.origin`, which is how we assert that the
- // diagnostics-channel instrumentation (not the OTel one) produced them. A
- // scenario can pass `override` to replace the default transaction expectation
- // (e.g. the streamed-error scenario, which runs a different, failing query).
- function expectedTransaction(
+ function expectedSpans(
port: number,
origin: string | undefined,
override: Record | undefined,
- ): Record {
- const span = (description: string): ReturnType =>
- expect.objectContaining({
- description,
- op: 'db',
- ...(origin ? { origin } : {}),
- data: expect.objectContaining({
- ...(origin ? { 'sentry.origin': origin } : {}),
- 'db.system.name': 'mysql',
- 'server.address': 'localhost',
- 'server.port': port,
- 'db.user': 'root',
- }),
- status: 'ok',
- });
+ ): unknown {
+ const COMMON_ATTRIBUTES = {
+ // These spans belong to a script with no incoming request, so there is nothing to judge.
+ [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: false },
+ [DB_CONNECTION_STRING]: {
+ type: 'string',
+ value: expect.stringMatching(/^jdbc:mysql:\/\/localhost:.*/),
+ },
+ [DB_SYSTEM_NAME]: {
+ type: 'string',
+ value: 'mysql',
+ },
+ [DB_USER]: {
+ type: 'string',
+ value: 'root',
+ },
+ [SERVER_ADDRESS]: {
+ type: 'string',
+ value: 'localhost',
+ },
+ [SERVER_PORT]: {
+ type: 'integer',
+ value: port,
+ },
+ [SENTRY_KIND]: {
+ type: 'string',
+ value: 'client',
+ },
+ [SENTRY_ENVIRONMENT]: {
+ type: 'string',
+ value: 'production',
+ },
+ [SENTRY_OP]: {
+ type: 'string',
+ value: DB,
+ },
+ [SENTRY_ORIGIN]: {
+ type: 'string',
+ value: origin,
+ },
+ [SENTRY_RELEASE]: {
+ type: 'string',
+ value: '1.0',
+ },
+ [SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.node',
+ },
+ [SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ [SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: expect.stringMatching(/^[\da-f]{16}$/),
+ },
+ [SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: 'Test Transaction',
+ },
+ [SENTRY_TRACE_LIFECYCLE]: {
+ type: 'string',
+ value: 'stream',
+ },
+ };
- return {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([span('SELECT ? + ? AS solution'), span('SELECT NOW()')]),
- ...(override ?? {}),
+ const COMMON_SPAN_PROPS = {
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ parent_span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
};
+
+ const span = (queryText: string) => ({
+ name: 'SELECT',
+ attributes: {
+ ...COMMON_ATTRIBUTES,
+ [DB_QUERY_TEXT]: { type: 'string', value: queryText },
+ [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' },
+ },
+ ...COMMON_SPAN_PROPS,
+ });
+
+ return override?.spans ?? [span('SELECT ? + ? AS solution'), span('SELECT NOW()')];
}
const CHANNEL_ORIGIN = 'auto.db.mysql';
@@ -90,26 +168,27 @@ describe('mysql auto instrumentation', () => {
'scenario-streamError.mjs',
'streamed query error',
{
- // The transaction itself succeeds (status `ok`); only the failing query's child span is errored.
- spans: expect.arrayContaining([
+ // The segment span succeeds (status `ok`); only the failing query span is errored.
+ spans: [
expect.objectContaining({
- description: 'SELECT * FROM does_not_exist',
- op: 'db',
+ name: 'SELECT does_not_exist',
// A failing streamed query emits `error`, which marks the span as errored
- status: 'internal_error',
- data: expect.objectContaining({
- 'db.system.name': 'mysql',
- 'db.user': 'root',
+ status: 'error',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM does_not_exist' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'mysql' },
+ [DB_USER]: { type: 'string', value: 'root' },
}),
}),
- ]),
+ ],
},
],
] as const;
for (const { label, env, flags, origin, failsOnEsm } of CASES) {
describe(label, () => {
- for (const [scenario, description, transactionOverride] of SCENARIOS) {
+ for (const [scenario, description, spanOverride] of SCENARIOS) {
createEsmAndCjsTests(
__dirname,
scenario,
@@ -119,7 +198,13 @@ describe('mysql auto instrumentation', () => {
await createRunner()
.withEnv({ ...env, MYSQL_PORT: String(mysqlPort) })
.withFlags(...flags)
- .expect({ transaction: expectedTransaction(mysqlPort, origin, transactionOverride) })
+ .expect({
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ const spans = container.items.filter(span => span.attributes[SENTRY_OP]?.value === DB);
+ expect(spans).toEqual(expectedSpans(mysqlPort, origin, spanOverride));
+ },
+ })
.start()
.completed();
});
@@ -140,12 +225,14 @@ describe('mysql auto instrumentation', () => {
.withFlags(...flags)
.withEnv({ ...env, MYSQL_PORT: String(mysqlPort) })
.expect({
- transaction: (transaction): void => {
- const transactionSpanId = transaction.contexts?.trace?.span_id;
- const spans = transaction.spans ?? [];
- const mysqlSpan = spans.find(span => span.description === 'SELECT ? + ? AS solution');
- const listenerSpan = spans.find(span => span.description === 'listener-child');
- const innerSpan = spans.find(span => span.description === 'inner-span');
+ span: (container): void => {
+ const transactionSpanId = container.items.find(span => span.is_segment)?.span_id;
+ const spans = container.items;
+ const mysqlSpan = spans.find(
+ span => span.attributes[DB_QUERY_TEXT]?.value === 'SELECT ? + ? AS solution',
+ );
+ const listenerSpan = spans.find(span => span.name === 'listener-child');
+ const innerSpan = spans.find(span => span.name === 'inner-span');
expect(transactionSpanId).toBeDefined();
expect(mysqlSpan).toBeDefined();
@@ -169,163 +256,4 @@ describe('mysql auto instrumentation', () => {
);
});
}
-
- describe('streamed', () => {
- const assertMysqlSpans = (container: SerializedStreamedSpanContainer): void => {
- const segmentSpan = container.items.find(item => item.is_segment);
- expect(segmentSpan?.name).toBe('Test Transaction');
-
- const dbSpans = container.items.filter(spanItem => spanItem.attributes[SENTRY_OP]?.value === 'db');
-
- expect(dbSpans.length).toBe(2);
-
- const COMMON_ATTRIBUTES = {
- // These spans belong to a script with no incoming request, so there is nothing to judge.
- 'sentry.is_localhost': { type: 'boolean', value: false },
- 'db.connection_string': {
- type: 'string',
- value: expect.stringMatching(/^jdbc:mysql:\/\/localhost:.*/),
- },
- 'db.system.name': {
- type: 'string',
- value: 'mysql',
- },
- 'db.user': {
- type: 'string',
- value: 'root',
- },
- 'server.address': {
- type: 'string',
- value: 'localhost',
- },
- 'server.port': {
- type: 'integer',
- value: expect.any(Number),
- },
- 'sentry.kind': {
- type: 'string',
- value: 'client',
- },
- 'sentry.environment': {
- type: 'string',
- value: 'production',
- },
- 'sentry.op': {
- type: 'string',
- value: 'db',
- },
- 'sentry.origin': {
- type: 'string',
- value: 'auto.db.mysql',
- },
- 'sentry.release': {
- type: 'string',
- value: '1.0',
- },
- 'sentry.sdk.name': {
- type: 'string',
- value: 'sentry.javascript.node',
- },
- 'sentry.sdk.version': {
- type: 'string',
- value: expect.any(String),
- },
- 'sentry.segment.id': {
- type: 'string',
- value: expect.stringMatching(/^[\da-f]{16}$/),
- },
- 'sentry.segment.name': {
- type: 'string',
- value: 'Test Transaction',
- },
- [SENTRY_TRACE_LIFECYCLE]: {
- type: 'string',
- value: 'stream',
- },
- };
-
- const COMMON_SPAN_PROPS = {
- end_timestamp: expect.any(Number),
- is_segment: false,
- parent_span_id: expect.stringMatching(/^[\da-f]{16}$/),
- span_id: expect.stringMatching(/^[\da-f]{16}$/),
- start_timestamp: expect.any(Number),
- status: 'ok',
- trace_id: expect.stringMatching(/^[\da-f]{32}$/),
- };
-
- expect(dbSpans).toEqual([
- {
- attributes: {
- ...COMMON_ATTRIBUTES,
- 'db.query.text': {
- type: 'string',
- value: 'SELECT ? + ? AS solution',
- },
- 'db.query.summary': {
- type: 'string',
- value: 'SELECT',
- },
- },
- name: 'SELECT',
- ...COMMON_SPAN_PROPS,
- },
- {
- attributes: {
- ...COMMON_ATTRIBUTES,
- 'db.query.text': {
- type: 'string',
- value: 'SELECT NOW()',
- },
- 'db.query.summary': {
- type: 'string',
- value: 'SELECT',
- },
- },
- name: 'SELECT',
- ...COMMON_SPAN_PROPS,
- },
- ]);
- };
-
- describe('with connection.connect()', () => {
- createCjsTests(__dirname, 'scenario-withConnect.mjs', 'instrument.mjs', (createTestRunner, test) => {
- test('should auto-instrument `mysql` package when using connection.connect()', async () => {
- await createTestRunner()
- .withEnv({ STREAMED: 'true', MYSQL_PORT: String(mysqlPort) })
- .expect({
- span: assertMysqlSpans,
- })
- .start()
- .completed();
- });
- });
- });
-
- describe('query without callback', () => {
- createCjsTests(__dirname, 'scenario-withoutCallback.mjs', 'instrument.mjs', (createTestRunner, test) => {
- test('should auto-instrument `mysql` package when using query without callback', async () => {
- await createTestRunner()
- .withEnv({ STREAMED: 'true', MYSQL_PORT: String(mysqlPort) })
- .expect({ span: assertMysqlSpans })
- .start()
- .completed();
- });
- });
- });
-
- describe('without connection.connect()', () => {
- createCjsTests(__dirname, 'scenario-withoutConnect.mjs', 'instrument.mjs', (createTestRunner, test) => {
- test('should auto-instrument `mysql` package without connection.connect()', async () => {
- await createTestRunner()
- .withEnv({ STREAMED: 'true', MYSQL_PORT: String(mysqlPort) })
- .expect({
- span: assertMysqlSpans,
- })
- .start()
- .completed();
- });
- });
- });
- });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts
index b243da576bb3..6e67cc51482d 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts
@@ -1,3 +1,13 @@
+import {
+ DB_OPERATION_NAME,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
@@ -13,22 +23,21 @@ describe('mysql2 tracing channel Test', () => {
const expectedQuerySpan = (queryText: string) =>
expect.objectContaining({
- description: queryText,
- op: 'db',
- origin: 'auto.db.mysql2.diagnostic_channel',
- data: expect.objectContaining({
- 'sentry.origin': 'auto.db.mysql2.diagnostic_channel',
- 'db.system.name': 'mysql',
- 'db.operation.name': 'SELECT',
- 'db.query.text': queryText,
- 'server.address': 'localhost',
- 'server.port': 3308,
+ attributes: expect.objectContaining({
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.mysql2.diagnostic_channel' },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'mysql' },
+ [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' },
+ [DB_QUERY_TEXT]: { type: 'string', value: queryText },
+ [SERVER_ADDRESS]: { type: 'string', value: 'localhost' },
+ [SERVER_PORT]: { type: 'integer', value: 3308 },
+ [SENTRY_OP]: { type: 'string', value: DB },
}),
+ name: 'SELECT',
});
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ expect.objectContaining({ name: 'Test Transaction', is_segment: true }),
expectedQuerySpan('SELECT ? + ? AS solution'),
// the inlined literal is redacted out of `db.query.text`
expectedQuerySpan('SELECT ? AS leaked'),
@@ -36,10 +45,13 @@ describe('mysql2 tracing channel Test', () => {
expectedQuerySpan('SELECT ? AS answer'),
// a failing query produces a span with an error status
expect.objectContaining({
- description: 'SELECT * FROM does_not_exist',
- op: 'db',
- status: 'internal_error',
- origin: 'auto.db.mysql2.diagnostic_channel',
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.mysql2.diagnostic_channel' },
+ [DB_QUERY_TEXT]: { type: 'string', value: 'SELECT * FROM does_not_exist' },
+ }),
+ name: 'SELECT does_not_exist',
+ status: 'error',
}),
]),
};
@@ -47,19 +59,21 @@ describe('mysql2 tracing channel Test', () => {
describeWithDockerCompose('with pg docker compose', { workingDirectory: [__dirname] }, () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('subscribes to mysql2 >= 3.20.0 diagnostics channels with stable semconv attributes', async () => {
- await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
+ await createTestRunner().expect({ span: EXPECTED_SPANS }).start().completed();
}, 30_000);
test('does not double-instrument: the legacy IITM mysql2 patcher does not fire on 3.20.0+', async () => {
await createTestRunner()
.expect({
- transaction: event => {
- expect(event.transaction).toBe('Test Transaction');
- const spans = event.spans || [];
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ const spans = container.items.filter(span => !span.is_segment);
// The monkey-patch path (origin `auto.db.mysql2`) must be inactive on 3.20.0+.
- expect(spans.find(span => span.origin === 'auto.db.mysql2')).toBeUndefined();
+ expect(spans.find(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.mysql2')).toBeUndefined();
// ...while the diagnostics-channel path is active.
- expect(spans.find(span => span.origin === 'auto.db.mysql2.diagnostic_channel')).toBeDefined();
+ expect(
+ spans.find(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.db.mysql2.diagnostic_channel'),
+ ).toBeDefined();
},
})
.start()
@@ -69,11 +83,11 @@ describe('mysql2 tracing channel Test', () => {
test('never leaks raw values into db.query.text', async () => {
await createTestRunner()
.expect({
- transaction: event => {
- expect(event.transaction).toBe('Test Transaction');
- const spans = event.spans || [];
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ const spans = container.items.filter(span => !span.is_segment);
for (const span of spans) {
- const queryText = span.data?.['db.query.text'];
+ const queryText = span.attributes[DB_QUERY_TEXT]?.value;
if (typeof queryText === 'string') {
expect(queryText).not.toContain('super-secret');
}
diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts
index 9733d4c7b614..619fd607a06e 100644
--- a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts
@@ -1,3 +1,4 @@
+import { DB } from '@sentry/conventions/op';
import { afterAll, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
@@ -15,7 +16,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
spans: expect.arrayContaining([
expect.objectContaining({
description: 'SELECT ? + ? AS solution',
- op: 'db',
+ op: DB,
origin: ORIGIN,
data: expect.objectContaining({
'db.system.name': 'mysql',
@@ -28,7 +29,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
// bind values are left as `?` placeholders in `db.statement` (not inlined)
expect.objectContaining({
description: 'SELECT ? as a, ? as b, NOW() as c',
- op: 'db',
+ op: DB,
origin: ORIGIN,
data: expect.objectContaining({
'db.system.name': 'mysql',
@@ -41,7 +42,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
// a single non-array bind value is also left as a `?` placeholder in `db.statement`
expect.objectContaining({
description: 'SELECT ? AS scalar_value',
- op: 'db',
+ op: DB,
origin: ORIGIN,
data: expect.objectContaining({
'db.system.name': 'mysql',
@@ -51,7 +52,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
// `execute` is instrumented the same way as `query`
expect.objectContaining({
description: 'SELECT ? AS answer',
- op: 'db',
+ op: DB,
origin: ORIGIN,
data: expect.objectContaining({
'db.system.name': 'mysql',
@@ -61,7 +62,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__
// a failing query produces a span with an error status
expect.objectContaining({
description: 'SELECT * FROM does_not_exist',
- op: 'db',
+ op: DB,
status: 'internal_error',
origin: ORIGIN,
data: expect.objectContaining({
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-root-span.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-root-span.mjs
deleted file mode 100644
index 42052d281304..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-root-span.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- traceLifecycle: 'static',
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: false, outputs: false } },
- transport: loggingTransport,
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-span-streaming.mjs
deleted file mode 100644
index 657bed0a3a8c..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: true, outputs: true } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-options.mjs
index 0d6548af1291..913758cd6078 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-options.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-options.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
@@ -14,10 +13,4 @@ Sentry.init({
recordOutputs: true,
}),
],
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-pii.mjs
index 2b78a3ea1987..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-pii.mjs
@@ -2,16 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument.mjs
index 2097d76a4eff..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument.mjs
@@ -2,16 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs
index bdf24d6bbd0e..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs
index 42052d281304..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts
index 5dfd13c21f7f..59473fcea8fe 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts
@@ -79,14 +79,15 @@ describe('OpenAI Tool Calls integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates openai tool calls related spans with genAI recording disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- const chatToolsSpan = container.items.find(
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(4);
+ const chatToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-tools-123',
);
- expect(chatToolsSpan).toBeDefined();
expect(chatToolsSpan!.name).toBe('chat gpt-4');
expect(chatToolsSpan!.status).toBe('ok');
expect(chatToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -133,10 +134,9 @@ describe('OpenAI Tool Calls integration', () => {
value: 40,
});
- const streamingChatToolsSpan = container.items.find(
+ const streamingChatToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-tools-123',
);
- expect(streamingChatToolsSpan).toBeDefined();
expect(streamingChatToolsSpan!.name).toBe('chat gpt-4');
expect(streamingChatToolsSpan!.status).toBe('ok');
expect(streamingChatToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -193,10 +193,9 @@ describe('OpenAI Tool Calls integration', () => {
value: 40,
});
- const responsesToolsSpan = container.items.find(
+ const responsesToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_tools_789',
);
- expect(responsesToolsSpan).toBeDefined();
expect(responsesToolsSpan!.name).toBe('chat gpt-4');
expect(responsesToolsSpan!.status).toBe('ok');
expect(responsesToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -246,10 +245,9 @@ describe('OpenAI Tool Calls integration', () => {
value: 20,
});
- const streamingResponsesToolsSpan = container.items.find(
+ const streamingResponsesToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_tools_789',
);
- expect(streamingResponsesToolsSpan).toBeDefined();
expect(streamingResponsesToolsSpan!.name).toBe('chat gpt-4');
expect(streamingResponsesToolsSpan!.status).toBe('ok');
expect(streamingResponsesToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -315,14 +313,15 @@ describe('OpenAI Tool Calls integration', () => {
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates openai tool calls related spans with genAI recording enabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- const chatToolsSpan = container.items.find(
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(4);
+ const chatToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-tools-123',
);
- expect(chatToolsSpan).toBeDefined();
expect(chatToolsSpan!.name).toBe('chat gpt-4');
expect(chatToolsSpan!.status).toBe('ok');
expect(chatToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -383,10 +382,9 @@ describe('OpenAI Tool Calls integration', () => {
value: 40,
});
- const streamingChatToolsSpan = container.items.find(
+ const streamingChatToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-tools-123',
);
- expect(streamingChatToolsSpan).toBeDefined();
expect(streamingChatToolsSpan!.name).toBe('chat gpt-4');
expect(streamingChatToolsSpan!.status).toBe('ok');
expect(streamingChatToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -454,10 +452,9 @@ describe('OpenAI Tool Calls integration', () => {
value: 40,
});
- const responsesToolsSpan = container.items.find(
+ const responsesToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_tools_789',
);
- expect(responsesToolsSpan).toBeDefined();
expect(responsesToolsSpan!.name).toBe('chat gpt-4');
expect(responsesToolsSpan!.status).toBe('ok');
expect(responsesToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
@@ -518,10 +515,9 @@ describe('OpenAI Tool Calls integration', () => {
value: 20,
});
- const streamingResponsesToolsSpan = container.items.find(
+ const streamingResponsesToolsSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_tools_789',
);
- expect(streamingResponsesToolsSpan).toBeDefined();
expect(streamingResponsesToolsSpan!.name).toBe('chat gpt-4');
expect(streamingResponsesToolsSpan!.status).toBe('ok');
expect(streamingResponsesToolsSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts
index 2da282ebabb4..754328af131a 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts
@@ -34,1634 +34,1587 @@ describe('OpenAI integration', () => {
createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates openai related spans with genAI recording disabled', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(8);
- const chatCompletionSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.7,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-mock123',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 25,
- });
-
- const responsesSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
- );
- expect(responsesSpan).toBeDefined();
- expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(responsesSpan!.status).toBe('ok');
- expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_mock456',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["completed"]',
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 5,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 8,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 13,
- });
-
- const nonStreamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
- );
- expect(nonStreamingErrorSpan).toBeDefined();
- expect(nonStreamingErrorSpan!.name).toBe('chat error-model');
- expect(nonStreamingErrorSpan!.status).toBe('error');
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
-
- const streamingChatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingChatCompletionSpan).toBeDefined();
- expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
- expect(streamingChatCompletionSpan!.status).toBe('ok');
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.8,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-stream-123',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 12,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 18,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 30,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
-
- const streamingResponsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
- );
- expect(streamingResponsesSpan).toBeDefined();
- expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
- expect(streamingResponsesSpan!.status).toBe('ok');
- expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_stream_456',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["in_progress","completed"]',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 6,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 16,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
-
- const streamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
- );
- expect(streamingErrorSpan).toBeDefined();
- expect(streamingErrorSpan!.name).toBe('chat error-model');
- expect(streamingErrorSpan!.status).toBe('error');
- expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
-
- const parsedChatCompletionSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedChatCompletionSpan).toBeDefined();
- expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o');
- expect(parsedChatCompletionSpan!.status).toBe('ok');
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(parsedChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(parsedChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-mock123',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 25,
- });
-
- const parsedResponsesSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedResponsesSpan).toBeDefined();
- expect(parsedResponsesSpan!.name).toBe('chat gpt-4o');
- expect(parsedResponsesSpan!.status).toBe('ok');
- expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(parsedResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(parsedResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_mock456',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["completed"]',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 5,
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 8,
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 13,
- });
-
- // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them.
- // Asserting over every span in the envelope catches attributes leaking onto
- // spans which are not individually inspected above.
- for (const span of container.items) {
- expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
- expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
- expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined();
- }
- },
- })
- .start()
- .completed();
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(8);
+ const chatCompletionSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
+ );
+ expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(chatCompletionSpan!.status).toBe('ok');
+ expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.7,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-mock123',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+
+ const responsesSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
+ );
+ expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(responsesSpan!.status).toBe('ok');
+ expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock456',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["completed"]',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 5,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 8,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 13,
+ });
+
+ const nonStreamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
+ );
+ expect(nonStreamingErrorSpan!.status).toBe('error');
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+
+ const streamingChatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
+ expect(streamingChatCompletionSpan!.status).toBe('ok');
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.8,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-stream-123',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 12,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 18,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingResponsesSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
+ );
+ expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
+ expect(streamingResponsesSpan!.status).toBe('ok');
+ expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_stream_456',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["in_progress","completed"]',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 6,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 16,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
+ );
+ expect(streamingErrorSpan!.status).toBe('error');
+ expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+
+ const parsedChatCompletionSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
+ );
+ expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o');
+ expect(parsedChatCompletionSpan!.status).toBe('ok');
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(parsedChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(parsedChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-mock123',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+
+ const parsedResponsesSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
+ );
+ expect(parsedResponsesSpan!.name).toBe('chat gpt-4o');
+ expect(parsedResponsesSpan!.status).toBe('ok');
+ expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(parsedResponsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(parsedResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock456',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["completed"]',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 5,
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 8,
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 13,
+ });
+
+ // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them.
+ // Asserting over every GenAI span catches attributes leaking onto
+ // spans which are not individually inspected above.
+ for (const span of spans) {
+ expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
+ expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
+ expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
+ expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined();
+ }
});
});
createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates openai related spans with genAI recording enabled', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(8);
- const chatCompletionSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.7,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"What is the capital of France?"}]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
- type: 'string',
- value: JSON.stringify([{ type: 'text', content: 'You are a helpful assistant.' }]),
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-mock123',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: '["Hello from OpenAI mock!"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 25,
- });
-
- const responsesSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
- );
- expect(responsesSpan).toBeDefined();
- expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(responsesSpan!.status).toBe('ok');
- expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: 'Translate this to French: Hello',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Response to: Translate this to French: Hello',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["completed"]',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_mock456',
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 5,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 8,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 13,
- });
-
- const nonStreamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
- );
- expect(nonStreamingErrorSpan).toBeDefined();
- expect(nonStreamingErrorSpan!.name).toBe('chat error-model');
- expect(nonStreamingErrorSpan!.status).toBe('error');
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"This will fail"}]',
- });
-
- const streamingChatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingChatCompletionSpan).toBeDefined();
- expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
- expect(streamingChatCompletionSpan!.status).toBe('ok');
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.8,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"Tell me about streaming"}]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
- type: 'string',
- value: JSON.stringify([{ type: 'text', content: 'You are a helpful assistant.' }]),
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Hello from OpenAI streaming!',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-stream-123',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 12,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 18,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 30,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
-
- const streamingResponsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
- );
- expect(streamingResponsesSpan).toBeDefined();
- expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
- expect(streamingResponsesSpan!.status).toBe('ok');
- expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: 'Test streaming responses API',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Streaming response to: Test streaming responses APITest streaming responses API',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["in_progress","completed"]',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_stream_456',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 6,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 16,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
-
- const streamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
- );
- expect(streamingErrorSpan).toBeDefined();
- expect(streamingErrorSpan!.name).toBe('chat error-model');
- expect(streamingErrorSpan!.status).toBe('error');
- expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"This will fail"}]',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
-
- const parsedChatCompletionSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedChatCompletionSpan).toBeDefined();
- expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o');
- expect(parsedChatCompletionSpan!.status).toBe('ok');
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(parsedChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(parsedChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"What is the capital of France?"}]',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-mock123',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: '["Hello from OpenAI mock!"]',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 25,
- });
-
- const parsedResponsesSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedResponsesSpan).toBeDefined();
- expect(parsedResponsesSpan!.name).toBe('chat gpt-4o');
- expect(parsedResponsesSpan!.status).toBe('ok');
- expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(parsedResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(parsedResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: 'What is the capital of France?',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Response to: What is the capital of France?',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["completed"]',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_mock456',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 5,
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 8,
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 13,
- });
- },
- })
- .start()
- .completed();
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(8);
+ const chatCompletionSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
+ );
+ expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(chatCompletionSpan!.status).toBe('ok');
+ expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.7,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"What is the capital of France?"}]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
+ type: 'string',
+ value: JSON.stringify([{ type: 'text', content: 'You are a helpful assistant.' }]),
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-mock123',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: '["Hello from OpenAI mock!"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+
+ const responsesSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
+ );
+ expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(responsesSpan!.status).toBe('ok');
+ expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: 'Translate this to French: Hello',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Response to: Translate this to French: Hello',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["completed"]',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock456',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 5,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 8,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 13,
+ });
+
+ const nonStreamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
+ );
+ expect(nonStreamingErrorSpan!.status).toBe('error');
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"This will fail"}]',
+ });
+
+ const streamingChatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
+ expect(streamingChatCompletionSpan!.status).toBe('ok');
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.8,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"Tell me about streaming"}]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
+ type: 'string',
+ value: JSON.stringify([{ type: 'text', content: 'You are a helpful assistant.' }]),
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Hello from OpenAI streaming!',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-stream-123',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 12,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 18,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingResponsesSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
+ );
+ expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
+ expect(streamingResponsesSpan!.status).toBe('ok');
+ expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: 'Test streaming responses API',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Streaming response to: Test streaming responses APITest streaming responses API',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["in_progress","completed"]',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_stream_456',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 6,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 16,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
+ );
+ expect(streamingErrorSpan!.status).toBe('error');
+ expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"This will fail"}]',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+
+ const parsedChatCompletionSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
+ );
+ expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o');
+ expect(parsedChatCompletionSpan!.status).toBe('ok');
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(parsedChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(parsedChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"What is the capital of France?"}]',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-mock123',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: '["Hello from OpenAI mock!"]',
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+
+ const parsedResponsesSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
+ );
+ expect(parsedResponsesSpan!.name).toBe('chat gpt-4o');
+ expect(parsedResponsesSpan!.status).toBe('ok');
+ expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(parsedResponsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(parsedResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: 'What is the capital of France?',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Response to: What is the capital of France?',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["completed"]',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4o',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock456',
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 5,
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 8,
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 13,
+ });
});
});
createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => {
test('creates openai related spans with custom options', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(8);
- const chatCompletionSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
-
- const streamingChatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingChatCompletionSpan).toBeDefined();
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
-
- const parsedChatCompletionSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedChatCompletionSpan).toBeDefined();
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
-
- const parsedResponsesSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedResponsesSpan).toBeDefined();
- expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- },
- })
- .start()
- .completed();
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
+
+ await runner.start().completed();
+
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(8);
+ const chatCompletionSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
+ );
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+
+ const streamingChatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+
+ const parsedChatCompletionSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
+ );
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(parsedChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+
+ const parsedResponsesSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
+ );
+ expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(parsedResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
});
});
createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates openai related spans with genAI recording disabled', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- const singleEmbeddingSpan = container.items.find(
- span =>
- span.name === 'embeddings text-embedding-3-small' &&
- span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] !== undefined,
- );
- expect(singleEmbeddingSpan).toBeDefined();
- expect(singleEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(singleEmbeddingSpan!.status).toBe('ok');
- expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
- type: 'string',
- value: 'float',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
- type: 'integer',
- value: 1536,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
-
- const errorEmbeddingSpan = container.items.find(span => span.name === 'embeddings error-model');
- expect(errorEmbeddingSpan).toBeDefined();
- expect(errorEmbeddingSpan!.name).toBe('embeddings error-model');
- expect(errorEmbeddingSpan!.status).toBe('error');
- expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
-
- const multiEmbeddingSpan = container.items.find(
- span =>
- span.name === 'embeddings text-embedding-3-small' &&
- span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] === undefined,
- );
- expect(multiEmbeddingSpan).toBeDefined();
- expect(multiEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(multiEmbeddingSpan!.status).toBe('ok');
- expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
-
- // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them.
- // Asserting over every span in the envelope catches attributes leaking onto
- // spans which are not individually inspected above.
- for (const span of container.items) {
- expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
- expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
- expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined();
- }
-
- const rawEmbeddingSpan = container.items.find(span => span.name === 'embeddings text-embedding-3-large');
- expect(rawEmbeddingSpan).toBeDefined();
- expect(rawEmbeddingSpan!.name).toBe('embeddings text-embedding-3-large');
- expect(rawEmbeddingSpan!.status).toBe('ok');
- expect(rawEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(rawEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(rawEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(rawEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(rawEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-large',
- });
- expect(rawEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
- expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined();
- expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined();
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(4);
+ const singleEmbeddingSpan = spans.find(
+ span =>
+ span.name === 'embeddings text-embedding-3-small' &&
+ span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] !== undefined,
+ );
+ expect(singleEmbeddingSpan!.status).toBe('ok');
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
+ type: 'string',
+ value: 'float',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
+ type: 'integer',
+ value: 1536,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+
+ const errorEmbeddingSpan = spans.find(span => span.name === 'embeddings error-model');
+ expect(errorEmbeddingSpan!.status).toBe('error');
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+
+ const multiEmbeddingSpan = spans.find(
+ span =>
+ span.name === 'embeddings text-embedding-3-small' &&
+ span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] === undefined,
+ );
+ expect(multiEmbeddingSpan!.status).toBe('ok');
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+
+ // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them.
+ // Asserting over every span in the envelope catches attributes leaking onto
+ // spans which are not individually inspected above.
+ for (const span of spans) {
+ expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
+ expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
+ expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
+ expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined();
+ }
+
+ const rawEmbeddingSpan = spans.find(span => span.name === 'embeddings text-embedding-3-large');
+ expect(rawEmbeddingSpan!.status).toBe('ok');
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(rawEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(rawEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-large',
+ });
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined();
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined();
});
});
createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
test('creates openai related spans with genAI recording enabled', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(4);
- const singleEmbeddingSpan = container.items.find(
- span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!',
- );
- expect(singleEmbeddingSpan).toBeDefined();
- expect(singleEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(singleEmbeddingSpan!.status).toBe('ok');
- expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
- type: 'string',
- value: 'float',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
- type: 'integer',
- value: 1536,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: 'Embedding test!',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
-
- const errorEmbeddingSpan = container.items.find(
- span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Error embedding test!',
- );
- expect(errorEmbeddingSpan).toBeDefined();
- expect(errorEmbeddingSpan!.name).toBe('embeddings error-model');
- expect(errorEmbeddingSpan!.status).toBe('error');
- expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: 'Error embedding test!',
- });
-
- const multiEmbeddingSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value ===
- '["First input text","Second input text","Third input text"]',
- );
- expect(multiEmbeddingSpan).toBeDefined();
- expect(multiEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(multiEmbeddingSpan!.status).toBe('ok');
- expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: '["First input text","Second input text","Third input text"]',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
-
- const rawEmbeddingSpan = container.items.find(span => span.name === 'embeddings text-embedding-3-large');
- expect(rawEmbeddingSpan).toBeDefined();
- expect(rawEmbeddingSpan!.name).toBe('embeddings text-embedding-3-large');
- expect(rawEmbeddingSpan!.status).toBe('ok');
- expect(rawEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(rawEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(rawEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(rawEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(rawEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-large',
- });
- expect(rawEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
- expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined();
- expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined();
- expect(rawEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: 'Raw embedding test!',
- });
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(4);
+ const singleEmbeddingSpan = spans.find(
+ span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!',
+ );
+ expect(singleEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
+ expect(singleEmbeddingSpan!.status).toBe('ok');
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
+ type: 'string',
+ value: 'float',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
+ type: 'integer',
+ value: 1536,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: 'Embedding test!',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+
+ const errorEmbeddingSpan = spans.find(
+ span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Error embedding test!',
+ );
+ expect(errorEmbeddingSpan!.name).toBe('embeddings error-model');
+ expect(errorEmbeddingSpan!.status).toBe('error');
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: 'Error embedding test!',
+ });
+
+ const multiEmbeddingSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value ===
+ '["First input text","Second input text","Third input text"]',
+ );
+ expect(multiEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
+ expect(multiEmbeddingSpan!.status).toBe('ok');
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: '["First input text","Second input text","Third input text"]',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+
+ const rawEmbeddingSpan = spans.find(span => span.name === 'embeddings text-embedding-3-large');
+ expect(rawEmbeddingSpan!.status).toBe('ok');
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(rawEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(rawEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-large',
+ });
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toBeUndefined();
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined();
+ expect(rawEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: 'Raw embedding test!',
+ });
});
});
- createEsmAndCjsTests(__dirname, 'scenario-root-span.mjs', 'instrument-root-span.mjs', (createRunner, test) => {
+ createEsmAndCjsTests(__dirname, 'scenario-root-span.mjs', 'instrument.mjs', (createRunner, test) => {
test('it works without a wrapping span', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- // First the span that our mock express server is emitting, unrelated to this test
- .expect({
- transaction: {
- transaction: 'POST /openai/chat/completions',
- },
- })
+ .unordered()
.expect({
- transaction: {
- transaction: 'chat gpt-3.5-turbo',
- contexts: {
- trace: {
- span_id: expect.any(String),
- trace_id: expect.any(String),
- data: {
- [GEN_AI_OPERATION_NAME]: 'chat',
- [SENTRY_OP]: 'gen_ai.chat',
- [SENTRY_ORIGIN]: 'auto.ai.openai',
- [GEN_AI_PROVIDER_NAME]: 'openai',
- [GEN_AI_REQUEST_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_REQUEST_TEMPERATURE]: 0.7,
- [GEN_AI_RESPONSE_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_RESPONSE_ID]: 'chatcmpl-mock123',
- [GEN_AI_RESPONSE_FINISH_REASONS]: '["stop"]',
- [GEN_AI_USAGE_INPUT_TOKENS]: 10,
- [GEN_AI_USAGE_OUTPUT_TOKENS]: 15,
- [GEN_AI_USAGE_TOTAL_TOKENS]: 25,
- },
- op: 'gen_ai.chat',
- origin: 'auto.ai.openai',
- status: 'ok',
- },
- },
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const segment = receivedSpans.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo');
+ expect(segment?.span_id).toEqual(expect.any(String));
+ expect(segment?.trace_id).toEqual(expect.any(String));
+ expect(segment?.attributes[GEN_AI_OPERATION_NAME]).toEqual({ value: 'chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_OP]).toEqual({ value: 'gen_ai.chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_ORIGIN]).toEqual({ value: 'auto.ai.openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ value: 'openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({ value: 0.7, type: 'double' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'chatcmpl-mock123', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({ value: '["stop"]', type: 'string' });
+ expect(segment?.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ value: 10, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ value: 15, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ value: 25, type: 'integer' });
+ expect(segment?.status).toEqual('ok');
});
});
createEsmAndCjsTests(__dirname, 'scenario-azure-openai.mjs', 'instrument.mjs', (createRunner, test) => {
test('it works with Azure OpenAI', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- // First the span that our mock express server is emitting, unrelated to this test
- .expect({
- transaction: {
- transaction: 'POST /azureopenai/deployments/:model/chat/completions',
- },
- })
+ .unordered()
.expect({
- transaction: {
- transaction: 'chat gpt-3.5-turbo',
- contexts: {
- trace: {
- span_id: expect.any(String),
- trace_id: expect.any(String),
- data: {
- [GEN_AI_OPERATION_NAME]: 'chat',
- [SENTRY_OP]: 'gen_ai.chat',
- [SENTRY_ORIGIN]: 'auto.ai.openai',
- [GEN_AI_PROVIDER_NAME]: 'openai',
- [GEN_AI_REQUEST_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_REQUEST_TEMPERATURE]: 0.7,
- [GEN_AI_RESPONSE_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_RESPONSE_ID]: 'chatcmpl-mock123',
- [GEN_AI_RESPONSE_FINISH_REASONS]: '["stop"]',
- [GEN_AI_USAGE_INPUT_TOKENS]: 10,
- [GEN_AI_USAGE_OUTPUT_TOKENS]: 15,
- [GEN_AI_USAGE_TOTAL_TOKENS]: 25,
- },
- op: 'gen_ai.chat',
- origin: 'auto.ai.openai',
- status: 'ok',
- },
- },
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const segment = receivedSpans.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo');
+ expect(segment?.span_id).toEqual(expect.any(String));
+ expect(segment?.trace_id).toEqual(expect.any(String));
+ expect(segment?.attributes[GEN_AI_OPERATION_NAME]).toEqual({ value: 'chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_OP]).toEqual({ value: 'gen_ai.chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_ORIGIN]).toEqual({ value: 'auto.ai.openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ value: 'openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({ value: 0.7, type: 'double' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'chatcmpl-mock123', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({ value: '["stop"]', type: 'string' });
+ expect(segment?.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ value: 10, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ value: 15, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ value: 25, type: 'integer' });
+ expect(segment?.status).toEqual('ok');
});
});
// Test for conversation ID support (Conversations API and previous_response_id)
createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => {
test('captures conversation ID from Conversations API and previous_response_id', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'conversation-test',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(5);
- const conversationCreateSpan = container.items.find(span => span.name === 'chat unknown');
- expect(conversationCreateSpan).toBeDefined();
- expect(conversationCreateSpan!.name).toBe('chat unknown');
- expect(conversationCreateSpan!.status).toBe('ok');
- expect(conversationCreateSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(conversationCreateSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(conversationCreateSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(conversationCreateSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(conversationCreateSpan!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
- type: 'string',
- value: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab',
- });
-
- const conversationResponseSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_CONVERSATION_ID]?.value ===
- 'conv_689667905b048191b4740501625afd940c7533ace33a2dab' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4',
- );
- expect(conversationResponseSpan).toBeDefined();
- expect(conversationResponseSpan!.status).toBe('ok');
- expect(conversationResponseSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(conversationResponseSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(conversationResponseSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(conversationResponseSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(conversationResponseSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(conversationResponseSpan!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
- type: 'string',
- value: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab',
- });
-
- const unlinkedResponseSpan = container.items.find(
- span =>
- span.attributes[SENTRY_OP]?.value === 'gen_ai.chat' &&
- span.attributes[GEN_AI_CONVERSATION_ID] === undefined,
- );
- expect(unlinkedResponseSpan).toBeDefined();
- expect(unlinkedResponseSpan!.status).toBe('ok');
- expect(unlinkedResponseSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(unlinkedResponseSpan!.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();
-
- const previousResponseSpan = container.items.find(
- span => span.attributes[GEN_AI_CONVERSATION_ID]?.value === 'resp_mock_conv_123',
- );
- expect(previousResponseSpan).toBeDefined();
- expect(previousResponseSpan!.status).toBe('ok');
- expect(previousResponseSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(previousResponseSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(previousResponseSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(previousResponseSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(previousResponseSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(previousResponseSpan!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
- type: 'string',
- value: 'resp_mock_conv_123',
- });
-
- const rawConversationSpan = container.items.find(
- span => span.name === 'chat unknown' && span.attributes[GEN_AI_CONVERSATION_ID] === undefined,
- );
- expect(rawConversationSpan).toBeDefined();
- expect(rawConversationSpan!.name).toBe('chat unknown');
- expect(rawConversationSpan!.status).toBe('ok');
- expect(rawConversationSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(rawConversationSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(rawConversationSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(rawConversationSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(rawConversationSpan!.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();
+ expect(container.items.find(span => span.is_segment && span.name === 'conversation-test')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(5);
+ const conversationCreateSpan = spans.find(
+ span => span.name === 'chat' && span.attributes[GEN_AI_RESPONSE_ID] !== undefined,
+ );
+ expect(conversationCreateSpan!.status).toBe('ok');
+ expect(conversationCreateSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(conversationCreateSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(conversationCreateSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(conversationCreateSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(conversationCreateSpan!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
+ type: 'string',
+ value: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab',
+ });
+
+ const conversationResponseSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_CONVERSATION_ID]?.value === 'conv_689667905b048191b4740501625afd940c7533ace33a2dab' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4',
+ );
+ expect(conversationResponseSpan!.status).toBe('ok');
+ expect(conversationResponseSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(conversationResponseSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(conversationResponseSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(conversationResponseSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(conversationResponseSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(conversationResponseSpan!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
+ type: 'string',
+ value: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab',
+ });
+
+ const unlinkedResponseSpan = spans.find(
+ span =>
+ span.attributes[SENTRY_OP]?.value === 'gen_ai.chat' &&
+ span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4' &&
+ span.attributes[GEN_AI_CONVERSATION_ID] === undefined,
+ );
+ expect(unlinkedResponseSpan!.status).toBe('ok');
+ expect(unlinkedResponseSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(unlinkedResponseSpan!.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();
+
+ const previousResponseSpan = spans.find(
+ span => span.attributes[GEN_AI_CONVERSATION_ID]?.value === 'resp_mock_conv_123',
+ );
+ expect(previousResponseSpan!.status).toBe('ok');
+ expect(previousResponseSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(previousResponseSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(previousResponseSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(previousResponseSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(previousResponseSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(previousResponseSpan!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock_conv_123',
+ });
+
+ const rawConversationSpan = spans.find(
+ span => span.name === 'chat' && span.attributes[GEN_AI_CONVERSATION_ID] === undefined,
+ );
+ expect(rawConversationSpan!.status).toBe('ok');
+ expect(rawConversationSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(rawConversationSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(rawConversationSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(rawConversationSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(rawConversationSpan!.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();
});
});
// Test for manual conversation ID setting using setConversationId()
createEsmAndCjsTests(__dirname, 'scenario-manual-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => {
test('attaches manual conversation ID set via setConversationId() to all chat spans', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'chat-with-manual-conversation-id',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
-
- // All three chat completion spans should have the same manually-set conversation ID
- for (const span of container.items) {
- expect(span!.name).toBe('chat gpt-4');
- expect(span!.status).toBe('ok');
- expect(span!.attributes['gen_ai.conversation.id']).toEqual({
- type: 'string',
- value: 'user_chat_session_abc123',
- });
- expect(span!.attributes['gen_ai.provider.name']).toEqual({ type: 'string', value: 'openai' });
- expect(span!.attributes['gen_ai.request.model']).toEqual({ type: 'string', value: 'gpt-4' });
- expect(span!.attributes['gen_ai.operation.name']).toEqual({ type: 'string', value: 'chat' });
- expect(span!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' });
- }
+ expect(
+ container.items.find(span => span.is_segment && span.name === 'chat-with-manual-conversation-id'),
+ ).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(3);
+
+ // All three chat completion spans should have the same manually-set conversation ID
+ for (const span of spans) {
+ expect(span!.name).toBe('chat gpt-4');
+ expect(span!.status).toBe('ok');
+ expect(span!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
+ type: 'string',
+ value: 'user_chat_session_abc123',
+ });
+ expect(span!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4' });
+ expect(span!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
+ expect(span!.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'gen_ai.chat' });
+ }
});
});
createEsmAndCjsTests(__dirname, 'scenario-separate-scope-1.mjs', 'instrument.mjs', (createRunner, test) => {
test('isolates conversation IDs across separate scopes - conversation 1', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'GET /chat/conversation-1',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
-
- // Both chat completion spans should have the expected conversation ID
- for (const span of container.items) {
- expect(span!.name).toBe('chat gpt-4');
- expect(span!.status).toBe('ok');
- expect(span!.attributes['gen_ai.conversation.id']).toEqual({
- type: 'string',
- value: 'conv_user1_session_abc',
- });
- expect(span!.attributes['gen_ai.provider.name']).toEqual({ type: 'string', value: 'openai' });
- expect(span!.attributes['gen_ai.request.model']).toEqual({ type: 'string', value: 'gpt-4' });
- expect(span!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' });
- }
+ expect(
+ container.items.find(span => span.is_segment && span.name === 'GET /chat/conversation-1'),
+ ).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(2);
+
+ // Both chat completion spans should have the expected conversation ID
+ for (const span of spans) {
+ expect(span!.name).toBe('chat gpt-4');
+ expect(span!.status).toBe('ok');
+ expect(span!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
+ type: 'string',
+ value: 'conv_user1_session_abc',
+ });
+ expect(span!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4' });
+ expect(span!.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'gen_ai.chat' });
+ }
});
});
createEsmAndCjsTests(__dirname, 'scenario-separate-scope-2.mjs', 'instrument.mjs', (createRunner, test) => {
test('isolates conversation IDs across separate scopes - conversation 2', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'GET /chat/conversation-2',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(2);
-
- // Both chat completion spans should have the expected conversation ID
- for (const span of container.items) {
- expect(span!.name).toBe('chat gpt-4');
- expect(span!.status).toBe('ok');
- expect(span!.attributes['gen_ai.conversation.id']).toEqual({
- type: 'string',
- value: 'conv_user2_session_xyz',
- });
- expect(span!.attributes['gen_ai.provider.name']).toEqual({ type: 'string', value: 'openai' });
- expect(span!.attributes['gen_ai.request.model']).toEqual({ type: 'string', value: 'gpt-4' });
- expect(span!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' });
- }
+ expect(
+ container.items.find(span => span.is_segment && span.name === 'GET /chat/conversation-2'),
+ ).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(2);
+
+ // Both chat completion spans should have the expected conversation ID
+ for (const span of spans) {
+ expect(span!.name).toBe('chat gpt-4');
+ expect(span!.status).toBe('ok');
+ expect(span!.attributes[GEN_AI_CONVERSATION_ID]).toEqual({
+ type: 'string',
+ value: 'conv_user2_session_xyz',
+ });
+ expect(span!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4' });
+ expect(span!.attributes[SENTRY_OP]).toEqual({ type: 'string', value: 'gen_ai.chat' });
+ }
});
});
@@ -1671,166 +1624,68 @@ describe('OpenAI integration', () => {
'instrument-with-pii.mjs',
(createRunner, test) => {
test('extracts system instructions from messages', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(1);
- const [firstSpan] = container.items;
-
- // [0] chat completion with system instructions extracted from messages
- expect(firstSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
- type: 'string',
- value: JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]),
- });
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(1);
+ const [firstSpan] = spans;
+
+ // [0] chat completion with system instructions extracted from messages
+ expect(firstSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
+ type: 'string',
+ value: JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]),
+ });
});
},
);
createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => {
test('preserves .withResponse() method and works correctly', async () => {
- await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(2);
-
- for (const span of container.items) {
- expect(span!.name).toBe('chat gpt-4');
- expect(span!.status).toBe('ok');
- expect(span!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
- expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4' });
- }
-
- const parsedSpans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID] !== undefined);
- expect(parsedSpans).toHaveLength(1);
- expect(parsedSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-withresponse',
- });
-
- const rawSpans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined);
- expect(rawSpans).toHaveLength(1);
- expect(rawSpans[0]!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
- expect(rawSpans[0]!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined();
- },
- })
- .start()
- .completed();
- });
- });
-
- createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
- test('creates openai related spans with span streaming enabled', async () => {
- const spans: SerializedStreamedSpanContainer['items'] = [];
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
await createRunner()
.unordered()
.expect({
span: container => {
- spans.push(...container.items);
- const chatCompletionSpan = spans.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-3.5-turbo',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"What is the capital of France?"}]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
-
- const parsedChatCompletionSpan = spans.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedChatCompletionSpan).toBeDefined();
- expect(parsedChatCompletionSpan!.name).toBe('chat gpt-4o');
- expect(parsedChatCompletionSpan!.status).toBe('ok');
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(parsedChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(parsedChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"What is the capital of France?"}]',
- });
- expect(parsedChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
-
- const parsedResponsesSpan = spans.find(
- span =>
- span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456' &&
- span.attributes[GEN_AI_REQUEST_MODEL]?.value === 'gpt-4o',
- );
- expect(parsedResponsesSpan).toBeDefined();
- expect(parsedResponsesSpan!.name).toBe('chat gpt-4o');
- expect(parsedResponsesSpan!.status).toBe('ok');
- expect(parsedResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
- expect(parsedResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(parsedResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4o',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: 'What is the capital of France?',
- });
- expect(parsedResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(2);
+
+ for (const span of spans) {
+ expect(span!.name).toBe('chat gpt-4');
+ expect(span!.status).toBe('ok');
+ expect(span!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
+ expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4' });
+ }
+
+ const parsedSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID] !== undefined);
+ expect(parsedSpans).toHaveLength(1);
+ expect(parsedSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-withresponse',
+ });
+
+ const rawSpans = spans.filter(span => span.attributes[GEN_AI_RESPONSE_ID] === undefined);
+ expect(rawSpans).toHaveLength(1);
+ expect(rawSpans[0]!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined();
+ expect(rawSpans[0]!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined();
});
});
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-root-span.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-root-span.mjs
deleted file mode 100644
index 42052d281304..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-root-span.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- traceLifecycle: 'static',
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: false, outputs: false } },
- transport: loggingTransport,
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs
index 0169719e5d59..8bdeb0ef483f 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-options.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
@@ -15,10 +14,4 @@ Sentry.init({
}),
],
debug: true,
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs
index 2b78a3ea1987..1c507ba84b9c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument-with-pii.mjs
@@ -2,16 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: true, outputs: true } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs
index 2097d76a4eff..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/instrument.mjs
@@ -2,16 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts
index 49d5cb48bd9d..c93586010b03 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts
@@ -1,3 +1,4 @@
+import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import {
GEN_AI_EMBEDDINGS_INPUT,
@@ -36,300 +37,287 @@ describe('OpenAI integration (V6)', () => {
'instrument.mjs',
(createRunner, test) => {
test('creates openai related spans with genAI recording disabled (v6)', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(6);
- const chatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.7,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-mock123',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 25,
- });
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
- const responsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456',
- );
- expect(responsesSpan).toBeDefined();
- expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(responsesSpan!.status).toBe('ok');
- expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_mock456',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["completed"]',
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 5,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 8,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 13,
- });
+ await runner.start().completed();
- const nonStreamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
- );
- expect(nonStreamingErrorSpan).toBeDefined();
- expect(nonStreamingErrorSpan!.name).toBe('chat error-model');
- expect(nonStreamingErrorSpan!.status).toBe('error');
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(6);
+ const chatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
+ );
+ expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(chatCompletionSpan!.status).toBe('ok');
+ expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.7,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-mock123',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
- const streamingChatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingChatCompletionSpan).toBeDefined();
- expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
- expect(streamingChatCompletionSpan!.status).toBe('ok');
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.8,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-stream-123',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 12,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 18,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 30,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
+ const responsesSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456');
+ expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(responsesSpan!.status).toBe('ok');
+ expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock456',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["completed"]',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 5,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 8,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 13,
+ });
- const streamingResponsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
- );
- expect(streamingResponsesSpan).toBeDefined();
- expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
- expect(streamingResponsesSpan!.status).toBe('ok');
- expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_stream_456',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["in_progress","completed"]',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 6,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 16,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
+ const nonStreamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
+ );
+ expect(nonStreamingErrorSpan!.status).toBe('error');
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
- const streamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
- );
- expect(streamingErrorSpan).toBeDefined();
- expect(streamingErrorSpan!.name).toBe('chat error-model');
- expect(streamingErrorSpan!.status).toBe('error');
- expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- },
- })
- .start()
- .completed();
+ const streamingChatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
+ expect(streamingChatCompletionSpan!.status).toBe('ok');
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.8,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-stream-123',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 12,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 18,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingResponsesSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
+ );
+ expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
+ expect(streamingResponsesSpan!.status).toBe('ok');
+ expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_stream_456',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["in_progress","completed"]',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 6,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 16,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
+ );
+ expect(streamingErrorSpan!.status).toBe('error');
+ expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
});
},
{
@@ -345,348 +333,335 @@ describe('OpenAI integration (V6)', () => {
'instrument-with-pii.mjs',
(createRunner, test) => {
test('creates openai related spans with genAI recording enabled (v6)', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(6);
- const chatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.7,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"What is the capital of France?"}]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
- type: 'string',
- value: '[{"type":"text","content":"You are a helpful assistant."}]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-mock123',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: '["Hello from OpenAI mock!"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 25,
- });
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
- const responsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456',
- );
- expect(responsesSpan).toBeDefined();
- expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(responsesSpan!.status).toBe('ok');
- expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: 'Translate this to French: Hello',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Response to: Translate this to French: Hello',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["completed"]',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_mock456',
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 5,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 8,
- });
- expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 13,
- });
+ await runner.start().completed();
- const nonStreamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
- );
- expect(nonStreamingErrorSpan).toBeDefined();
- expect(nonStreamingErrorSpan!.name).toBe('chat error-model');
- expect(nonStreamingErrorSpan!.status).toBe('error');
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(nonStreamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"This will fail"}]',
- });
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(6);
+ const chatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
+ );
+ expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(chatCompletionSpan!.status).toBe('ok');
+ expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.7,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"What is the capital of France?"}]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
+ type: 'string',
+ value: '[{"type":"text","content":"You are a helpful assistant."}]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-mock123',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: '["Hello from OpenAI mock!"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
- const streamingChatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingChatCompletionSpan).toBeDefined();
- expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
- expect(streamingChatCompletionSpan!.status).toBe('ok');
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
- type: 'double',
- value: 0.8,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"Tell me about streaming"}]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
- type: 'string',
- value: '[{"type":"text","content":"You are a helpful assistant."}]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Hello from OpenAI streaming!',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'chatcmpl-stream-123',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 12,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 18,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 30,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
+ const responsesSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456');
+ expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(responsesSpan!.status).toBe('ok');
+ expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(responsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(responsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: 'Translate this to French: Hello',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Response to: Translate this to French: Hello',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["completed"]',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_mock456',
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 5,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 8,
+ });
+ expect(responsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 13,
+ });
- const streamingResponsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
- );
- expect(streamingResponsesSpan).toBeDefined();
- expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
- expect(streamingResponsesSpan!.status).toBe('ok');
- expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: 'Test streaming responses API',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
- type: 'string',
- value: 'Streaming response to: Test streaming responses APITest streaming responses API',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["in_progress","completed"]',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
- type: 'string',
- value: 'resp_stream_456',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-4',
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 6,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 16,
- });
- expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
- type: 'boolean',
- value: true,
- });
+ const nonStreamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] === undefined,
+ );
+ expect(nonStreamingErrorSpan!.status).toBe('error');
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(nonStreamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(nonStreamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"This will fail"}]',
+ });
- const streamingErrorSpan = container.items.find(
- span =>
- span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
- );
- expect(streamingErrorSpan).toBeDefined();
- expect(streamingErrorSpan!.name).toBe('chat error-model');
- expect(streamingErrorSpan!.status).toBe('error');
- expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'chat',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
- type: 'string',
- value: '[{"role":"user","content":"This will fail"}]',
- });
- expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- },
- })
- .start()
- .completed();
+ const streamingChatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamingChatCompletionSpan!.name).toBe('chat gpt-4');
+ expect(streamingChatCompletionSpan!.status).toBe('ok');
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingChatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({
+ type: 'double',
+ value: 0.8,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"Tell me about streaming"}]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toEqual({
+ type: 'string',
+ value: '[{"type":"text","content":"You are a helpful assistant."}]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Hello from OpenAI streaming!',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'chatcmpl-stream-123',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 12,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 18,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingResponsesSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_stream_456',
+ );
+ expect(streamingResponsesSpan!.name).toBe('chat gpt-4');
+ expect(streamingResponsesSpan!.status).toBe('ok');
+ expect(streamingResponsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingResponsesSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: 'Test streaming responses API',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toEqual({
+ type: 'string',
+ value: 'Streaming response to: Test streaming responses APITest streaming responses API',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["in_progress","completed"]',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({
+ type: 'string',
+ value: 'resp_stream_456',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-4',
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 6,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 16,
+ });
+ expect(streamingResponsesSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+
+ const streamingErrorSpan = spans.find(
+ span => span.name === 'chat error-model' && span.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]?.value === true,
+ );
+ expect(streamingErrorSpan!.status).toBe('error');
+ expect(streamingErrorSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'chat',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"This will fail"}]',
+ });
+ expect(streamingErrorSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(streamingErrorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
});
},
{
@@ -702,53 +677,50 @@ describe('OpenAI integration (V6)', () => {
'instrument-with-options.mjs',
(createRunner, test) => {
test('creates openai related spans with custom options (v6)', async () => {
- await createRunner()
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(6);
- const chatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
- expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
+ const runner = createRunner();
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
- const streamingChatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingChatCompletionSpan).toBeDefined();
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
- type: 'boolean',
- value: true,
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
- type: 'string',
- value: expect.any(String),
- });
- },
- })
- .start()
- .completed();
+ await runner.start().completed();
+
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(6);
+ const chatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
+ );
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined();
+ expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+
+ const streamingChatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
+ );
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toEqual({
+ type: 'boolean',
+ value: true,
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
+ expect(streamingChatCompletionSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toMatchObject({
+ type: 'string',
+ value: expect.any(String),
+ });
});
},
{
@@ -764,133 +736,129 @@ describe('OpenAI integration (V6)', () => {
'instrument.mjs',
(createRunner, test) => {
test('creates openai related spans with genAI recording disabled (v6)', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const singleEmbeddingSpan = container.items.find(
- span =>
- span.name === 'embeddings text-embedding-3-small' &&
- span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] !== undefined,
- );
- expect(singleEmbeddingSpan).toBeDefined();
- expect(singleEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(singleEmbeddingSpan!.status).toBe('ok');
- expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
- type: 'string',
- value: 'float',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
- type: 'integer',
- value: 1536,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
-
- const errorEmbeddingSpan = container.items.find(span => span.name === 'embeddings error-model');
- expect(errorEmbeddingSpan).toBeDefined();
- expect(errorEmbeddingSpan!.name).toBe('embeddings error-model');
- expect(errorEmbeddingSpan!.status).toBe('error');
- expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
-
- const multiEmbeddingSpan = container.items.find(
- span =>
- span.name === 'embeddings text-embedding-3-small' &&
- span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] === undefined,
- );
- expect(multiEmbeddingSpan).toBeDefined();
- expect(multiEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(multiEmbeddingSpan!.status).toBe('ok');
- expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(3);
+ const singleEmbeddingSpan = spans.find(
+ span =>
+ span.name === 'embeddings text-embedding-3-small' &&
+ span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] !== undefined,
+ );
+ expect(singleEmbeddingSpan!.status).toBe('ok');
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
+ type: 'string',
+ value: 'float',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
+ type: 'integer',
+ value: 1536,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+
+ const errorEmbeddingSpan = spans.find(span => span.name === 'embeddings error-model');
+ expect(errorEmbeddingSpan!.status).toBe('error');
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+
+ const multiEmbeddingSpan = spans.find(
+ span =>
+ span.name === 'embeddings text-embedding-3-small' &&
+ span.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] === undefined,
+ );
+ expect(multiEmbeddingSpan!.status).toBe('ok');
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
});
},
{
@@ -906,145 +874,144 @@ describe('OpenAI integration (V6)', () => {
'instrument-with-pii.mjs',
(createRunner, test) => {
test('creates openai related spans with genAI recording enabled (v6)', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- .expect({
- transaction: {
- transaction: 'main',
- },
- })
+ .unordered()
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const singleEmbeddingSpan = container.items.find(
- span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!',
- );
- expect(singleEmbeddingSpan).toBeDefined();
- expect(singleEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(singleEmbeddingSpan!.status).toBe('ok');
- expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
- type: 'string',
- value: 'float',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
- type: 'integer',
- value: 1536,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: 'Embedding test!',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
-
- const errorEmbeddingSpan = container.items.find(
- span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Error embedding test!',
- );
- expect(errorEmbeddingSpan).toBeDefined();
- expect(errorEmbeddingSpan!.name).toBe('embeddings error-model');
- expect(errorEmbeddingSpan!.status).toBe('error');
- expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'error-model',
- });
- expect(errorEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: 'Error embedding test!',
- });
-
- const multiEmbeddingSpan = container.items.find(
- span =>
- span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value ===
- '["First input text","Second input text","Third input text"]',
- );
- expect(multiEmbeddingSpan).toBeDefined();
- expect(multiEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
- expect(multiEmbeddingSpan!.status).toBe('ok');
- expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
- type: 'string',
- value: 'embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
- type: 'string',
- value: '["First input text","Second input text","Third input text"]',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'text-embedding-3-small',
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
- expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
- type: 'integer',
- value: 10,
- });
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(3);
+ const singleEmbeddingSpan = spans.find(
+ span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!',
+ );
+ expect(singleEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
+ expect(singleEmbeddingSpan!.status).toBe('ok');
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE]).toEqual({
+ type: 'string',
+ value: 'float',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toEqual({
+ type: 'integer',
+ value: 1536,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: 'Embedding test!',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+
+ const errorEmbeddingSpan = spans.find(
+ span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Error embedding test!',
+ );
+ expect(errorEmbeddingSpan!.name).toBe('embeddings error-model');
+ expect(errorEmbeddingSpan!.status).toBe('error');
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(errorEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'error-model',
+ });
+ expect(errorEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: 'Error embedding test!',
+ });
+
+ const multiEmbeddingSpan = spans.find(
+ span =>
+ span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value ===
+ '["First input text","Second input text","Third input text"]',
+ );
+ expect(multiEmbeddingSpan!.name).toBe('embeddings text-embedding-3-small');
+ expect(multiEmbeddingSpan!.status).toBe('ok');
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(multiEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toEqual({
+ type: 'string',
+ value: '["First input text","Second input text","Third input text"]',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'text-embedding-3-small',
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(multiEmbeddingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
});
},
{
@@ -1057,46 +1024,41 @@ describe('OpenAI integration (V6)', () => {
createEsmAndCjsTests(
__dirname,
'scenario-root-span.mjs',
- 'instrument-root-span.mjs',
+ 'instrument.mjs',
(createRunner, test) => {
test('it works without a wrapping span (v6)', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- // First the span that our mock express server is emitting, unrelated to this test
+ .unordered()
.expect({
- transaction: {
- transaction: 'POST /openai/chat/completions',
- },
- })
- .expect({
- transaction: {
- transaction: 'chat gpt-3.5-turbo',
- contexts: {
- trace: {
- span_id: expect.any(String),
- trace_id: expect.any(String),
- data: {
- [GEN_AI_OPERATION_NAME]: 'chat',
- [SENTRY_OP]: 'gen_ai.chat',
- [SENTRY_ORIGIN]: 'auto.ai.openai',
- [GEN_AI_PROVIDER_NAME]: 'openai',
- [GEN_AI_REQUEST_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_REQUEST_TEMPERATURE]: 0.7,
- [GEN_AI_RESPONSE_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_RESPONSE_ID]: 'chatcmpl-mock123',
- [GEN_AI_RESPONSE_FINISH_REASONS]: '["stop"]',
- [GEN_AI_USAGE_INPUT_TOKENS]: 10,
- [GEN_AI_USAGE_OUTPUT_TOKENS]: 15,
- [GEN_AI_USAGE_TOTAL_TOKENS]: 25,
- },
- op: 'gen_ai.chat',
- origin: 'auto.ai.openai',
- status: 'ok',
- },
- },
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const segment = receivedSpans.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo');
+ expect(segment?.span_id).toEqual(expect.any(String));
+ expect(segment?.trace_id).toEqual(expect.any(String));
+ expect(segment?.attributes[GEN_AI_OPERATION_NAME]).toEqual({ value: 'chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_OP]).toEqual({ value: 'gen_ai.chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_ORIGIN]).toEqual({ value: 'auto.ai.openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ value: 'openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({ value: 0.7, type: 'double' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'chatcmpl-mock123', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ value: '["stop"]',
+ type: 'string',
+ });
+ expect(segment?.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ value: 10, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ value: 15, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ value: 25, type: 'integer' });
+ expect(segment?.status).toEqual('ok');
});
},
{
@@ -1113,43 +1075,38 @@ describe('OpenAI integration (V6)', () => {
'instrument.mjs',
(createRunner, test) => {
test('it works with Azure OpenAI (v6)', async () => {
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
+
await createRunner()
- // First the span that our mock express server is emitting, unrelated to this test
+ .unordered()
.expect({
- transaction: {
- transaction: 'POST /azureopenai/deployments/:model/chat/completions',
- },
- })
- .expect({
- transaction: {
- transaction: 'chat gpt-3.5-turbo',
- contexts: {
- trace: {
- span_id: expect.any(String),
- trace_id: expect.any(String),
- data: {
- [GEN_AI_OPERATION_NAME]: 'chat',
- [SENTRY_OP]: 'gen_ai.chat',
- [SENTRY_ORIGIN]: 'auto.ai.openai',
- [GEN_AI_PROVIDER_NAME]: 'openai',
- [GEN_AI_REQUEST_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_REQUEST_TEMPERATURE]: 0.7,
- [GEN_AI_RESPONSE_MODEL]: 'gpt-3.5-turbo',
- [GEN_AI_RESPONSE_ID]: 'chatcmpl-mock123',
- [GEN_AI_RESPONSE_FINISH_REASONS]: '["stop"]',
- [GEN_AI_USAGE_INPUT_TOKENS]: 10,
- [GEN_AI_USAGE_OUTPUT_TOKENS]: 15,
- [GEN_AI_USAGE_TOTAL_TOKENS]: 25,
- },
- op: 'gen_ai.chat',
- origin: 'auto.ai.openai',
- status: 'ok',
- },
- },
+ span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const segment = receivedSpans.find(span => span.is_segment && span.name === 'chat gpt-3.5-turbo');
+ expect(segment?.span_id).toEqual(expect.any(String));
+ expect(segment?.trace_id).toEqual(expect.any(String));
+ expect(segment?.attributes[GEN_AI_OPERATION_NAME]).toEqual({ value: 'chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_OP]).toEqual({ value: 'gen_ai.chat', type: 'string' });
+ expect(segment?.attributes[SENTRY_ORIGIN]).toEqual({ value: 'auto.ai.openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ value: 'openai', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_REQUEST_TEMPERATURE]).toEqual({ value: 0.7, type: 'double' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({ value: 'gpt-3.5-turbo', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'chatcmpl-mock123', type: 'string' });
+ expect(segment?.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ value: '["stop"]',
+ type: 'string',
+ });
+ expect(segment?.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ value: 10, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ value: 15, type: 'integer' });
+ expect(segment?.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ value: 25, type: 'integer' });
+ expect(segment?.status).toEqual('ok');
});
},
{
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-span-streaming.mjs
deleted file mode 100644
index 5e0b6fb5592f..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: false, outputs: false } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs
index 2097d76a4eff..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs
@@ -2,16 +2,9 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- beforeSendTransaction: event => {
- if (event.transaction.includes('/openai/')) {
- return null;
- }
- return event;
- },
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts
index fbea79d4f34c..34199f155036 100644
--- a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts
@@ -1,3 +1,4 @@
+import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, expect } from 'vitest';
import {
GEN_AI_OPERATION_NAME,
@@ -32,79 +33,69 @@ conditionalTest({ min: 22 })('OpenAI integration (V7)', () => {
'instrument.mjs',
(createRunner, test) => {
test('instruments chat completions, the responses API and streaming on openai v7', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- expect(container.items).toHaveLength(6);
+ const runner = createRunner().ignore('event');
+ const spansPromise = runner.collectStreamedSpansUntilSegment('main');
- const chatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
- expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
- type: 'string',
- value: '["stop"]',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 10 });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
- type: 'integer',
- value: 15,
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 25 });
+ await runner.start().completed();
- // The responses API is a separate instrumented resource file from chat completions.
- const responsesSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456',
- );
- expect(responsesSpan).toBeDefined();
- expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(responsesSpan!.status).toBe('ok');
- expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
- expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
- type: 'string',
- value: 'gpt-3.5-turbo',
- });
+ const allSpans = await spansPromise;
+ const spans = allSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ expect(spans).toHaveLength(6);
- // Streaming goes through the patched async iterator rather than `beforeSpanEnd`, so it
- // is the part most likely to break if the `Stream` shape changes across a major.
- const streamingSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123',
- );
- expect(streamingSpan).toBeDefined();
- expect(streamingSpan!.name).toBe('chat gpt-4');
- expect(streamingSpan!.status).toBe('ok');
- expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({ type: 'boolean', value: true });
- expect(streamingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 12 });
- expect(streamingSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ type: 'integer', value: 18 });
- expect(streamingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 30 });
+ const chatCompletionSpan = spans.find(
+ span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
+ );
+ expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(chatCompletionSpan!.status).toBe('ok');
+ expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.chat',
+ });
+ expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toEqual({
+ type: 'string',
+ value: '["stop"]',
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 10 });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 25 });
- const errorSpan = container.items.find(span => span.name === 'chat error-model' && span.status !== 'ok');
- expect(errorSpan).toBeDefined();
- expect(errorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- },
- })
- .start()
- .completed();
+ // The responses API is a separate instrumented resource file from chat completions.
+ const responsesSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456');
+ expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo');
+ expect(responsesSpan!.status).toBe('ok');
+ expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' });
+ expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'gpt-3.5-turbo',
+ });
+
+ // Streaming goes through the patched async iterator rather than `beforeSpanEnd`, so it
+ // is the part most likely to break if the `Stream` shape changes across a major.
+ const streamingSpan = spans.find(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123');
+ expect(streamingSpan!.name).toBe('chat gpt-4');
+ expect(streamingSpan!.status).toBe('ok');
+ expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({ type: 'boolean', value: true });
+ expect(streamingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 12 });
+ expect(streamingSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ type: 'integer', value: 18 });
+ expect(streamingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 30 });
+
+ const errorSpan = spans.find(span => span.name === 'chat error-model' && span.status !== 'ok');
+ expect(errorSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
});
},
{
@@ -122,82 +113,43 @@ conditionalTest({ min: 22 })('OpenAI integration (V7)', () => {
'instrument.mjs',
(createRunner, test) => {
test('instruments the embeddings API on openai v7', async () => {
- await createRunner()
- .ignore('event')
- .expect({ transaction: { transaction: 'main' } })
- .expect({
- span: container => {
- const embeddingSpans = container.items.filter(
- span => span.attributes[GEN_AI_OPERATION_NAME]?.value === 'embeddings',
- );
- expect(embeddingSpans).toHaveLength(3);
-
- const singleEmbeddingSpan = embeddingSpans.find(
- span => span.name === 'embeddings text-embedding-3-small' && span.status === 'ok',
- );
- expect(singleEmbeddingSpan).toBeDefined();
- expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.embeddings',
- });
- expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
- type: 'string',
- value: 'openai',
- });
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
- const errorEmbeddingSpan = embeddingSpans.find(span => span.name === 'embeddings error-model');
- expect(errorEmbeddingSpan).toBeDefined();
- expect(errorEmbeddingSpan!.status).not.toBe('ok');
- },
- })
- .start()
- .completed();
- });
- },
- {
- additionalDependencies: {
- openai: '7.5.0',
- },
- },
- );
- // Span streaming is the default trace lifecycle, so cover it too. The span buffer flushes on a 5s
- // timer per trace, which splits this scenario's spans across envelopes under CI load, and the
- // runner asserts against one envelope at a time — so this only asserts on the first call's span,
- // which is always in the first flush. The exhaustive assertions above stay on the static lifecycle,
- // where every span arrives in a single envelope.
- createEsmAndCjsTests(
- __dirname,
- 'scenario-chat.mjs',
- 'instrument-span-streaming.mjs',
- (createRunner, test) => {
- test('instruments chat completions on openai v7 with span streaming enabled', async () => {
await createRunner()
+ .unordered()
.ignore('event')
.expect({
span: container => {
- const chatCompletionSpan = container.items.find(
- span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123',
- );
- expect(chatCompletionSpan).toBeDefined();
- expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo');
- expect(chatCompletionSpan!.status).toBe('ok');
- expect(chatCompletionSpan!.attributes[SENTRY_OP]).toEqual({
- type: 'string',
- value: 'gen_ai.chat',
- });
- expect(chatCompletionSpan!.attributes[SENTRY_ORIGIN]).toEqual({
- type: 'string',
- value: 'auto.ai.openai',
- });
- expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' });
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.start()
.completed();
+
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.openai');
+ const embeddingSpans = spans.filter(span => span.attributes[GEN_AI_OPERATION_NAME]?.value === 'embeddings');
+ expect(embeddingSpans).toHaveLength(3);
+
+ const singleEmbeddingSpan = embeddingSpans.find(
+ span => span.name === 'embeddings text-embedding-3-small' && span.status === 'ok',
+ );
+ expect(singleEmbeddingSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.embeddings',
+ });
+ expect(singleEmbeddingSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: 'auto.ai.openai',
+ });
+ expect(singleEmbeddingSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({
+ type: 'string',
+ value: 'openai',
+ });
+
+ const errorEmbeddingSpan = embeddingSpans.find(span => span.name === 'embeddings error-model');
+ expect(errorEmbeddingSpan).toBeDefined();
+ expect(errorEmbeddingSpan!.status).not.toBe('ok');
});
},
{
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/instrument.mjs
similarity index 66%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs
rename to dev-packages/node-integration-tests/suites/tracing/pi-durable/instrument.mjs
index 5e0b6fb5592f..f1f018ef471a 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/instrument.mjs
@@ -5,7 +5,8 @@ Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: false, outputs: false } },
transport: loggingTransport,
- traceLifecycle: 'stream',
+ ...(process.env.PI_DURABLE_RECORDING === 'off'
+ ? { dataCollection: { genAI: { inputs: false, outputs: false } } }
+ : {}),
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-compaction.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-compaction.mjs
new file mode 100644
index 000000000000..18c98f77f2ba
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-compaction.mjs
@@ -0,0 +1,38 @@
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai/providers/faux';
+import { createRegistry, Harness, MemoryStorage } from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+
+// A context window so small that pi-durable compacts the conversation before it can answer the
+// third question. The compaction request belongs to that run.
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'tiny', contextWindow: 60 }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses([
+ fauxAssistantMessage('First answer with quite a lot of detail so the transcript grows beyond the window.'),
+ fauxAssistantMessage('Second answer with even more detail so that the next request needs a compaction first.'),
+ fauxAssistantMessage('Summary of the conversation.'),
+ fauxAssistantMessage('Third answer.'),
+]);
+
+const harness = await Harness.open(
+ new MemoryStorage(),
+ {
+ models,
+ registry: createRegistry(),
+ settings: { compaction: { enabled: true, reserveTokens: 0, backgroundTokens: 0, keepRecentTokens: 1 } },
+ },
+ context,
+);
+const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'tiny' } } });
+for (const content of [
+ 'First question with some words in it.',
+ 'Second question with some more words.',
+ 'Third question.',
+]) {
+ await (await root.submit({ type: 'input', content }, context)).wait(context);
+}
+await harness.waitForIdle(context);
+await harness.close(context);
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-concurrent.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-concurrent.mjs
new file mode 100644
index 000000000000..1fdf6b110bd2
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-concurrent.mjs
@@ -0,0 +1,61 @@
+import * as Sentry from '@sentry/node';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage } from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+
+// Two conversations run at the same time, each submitted from its own request with its own scope
+// data. Each response is derived from the transcript, and the delays make the two runs interleave.
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+const respond = async transcript => {
+ const lastUser = [...transcript.messages].reverse().find(message => message.role === 'user');
+ const prompt =
+ typeof lastUser.content === 'string' ? lastUser.content : lastUser.content.map(part => part.text).join('');
+ const afterTool = transcript.messages.some(message => message.role === 'toolResult');
+ await delay(prompt.includes('A') ? 30 : 10);
+ return afterTool
+ ? fauxAssistantMessage(`Answer for ${prompt}`)
+ : fauxAssistantMessage(fauxToolCall('work', { tag: prompt }, { id: `call_${prompt.at(-1)}` }), {
+ stopReason: 'toolUse',
+ });
+};
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses([respond, respond, respond, respond]);
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'app',
+ tools: [
+ defineTool({
+ name: 'work',
+ description: 'Works for a while, then fails.',
+ parameters: Type.Object({ tag: Type.String() }),
+ execute: async args => {
+ await delay(args.tag.includes('A') ? 10 : 30);
+ throw new Error(`work failed for ${args.tag}`);
+ },
+ }),
+ ],
+ }),
+);
+
+const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
+const model = { provider: 'faux', modelId: 'faux-model' };
+const a = await harness.createConversation({ ownership: { kind: 'ownerless' }, agent: { model } }, context);
+const b = await harness.createConversation({ ownership: { kind: 'ownerless' }, agent: { model } }, context);
+
+// Each request has its own isolation scope, as it would in a server, with data of its own.
+const ask = (conversation, request) =>
+ Sentry.withIsolationScope(async isolationScope => {
+ isolationScope.setTag('request', request);
+ isolationScope.setConversationId(`scope-of-${request}`);
+ await (await conversation.submit({ type: 'input', content: request }, context)).wait(context);
+ });
+await Promise.all([ask(a, 'request A'), ask(b, 'request B')]);
+await harness.close(context);
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs
new file mode 100644
index 000000000000..f0efbea32483
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs
@@ -0,0 +1,114 @@
+import * as Sentry from '@sentry/node';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage } from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+
+// Six runs that do not end with an answer:
+// 1. the model request fails (`stopReason` error), with retries off;
+// 2. the provider throws, which faults the generation task;
+// 3. the run is aborted while the `slow` tool waits;
+// 4. the run is aborted while the model request is in flight;
+// 5. the Harness closes while the `slow` tool waits;
+// 6. in a second Harness, the model request fails and the Harness closes right after `wait()`.
+const started = new Map();
+const toolStarted = job => new Promise(resolve => started.set(job, resolve));
+
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const realModels = createModels();
+realModels.setProvider(faux.provider);
+// The faux provider turns a throwing response into an error message, so the throw of a broken
+// provider is staged in front of pi-ai: the request after `breakNextRequest()` throws synchronously.
+let brokenRequests = 0;
+const breakNextRequest = () => brokenRequests++;
+const models = new Proxy(realModels, {
+ get(target, property) {
+ const value = Reflect.get(target, property, target);
+ if (property === 'streamSimple') {
+ return (...args) => {
+ if (brokenRequests > 0) {
+ brokenRequests--;
+ throw new Error('provider broke');
+ }
+ return value.apply(target, args);
+ };
+ }
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+});
+faux.setResponses([
+ fauxAssistantMessage('', { stopReason: 'error', errorMessage: 'provider exploded' }),
+ fauxAssistantMessage(fauxToolCall('slow', { job: 'abort' }, { id: 'call_abort' }), { stopReason: 'toolUse' }),
+ async (_transcript, options) => {
+ started.get('request')();
+ await new Promise(resolve => options.signal.addEventListener('abort', resolve));
+ return fauxAssistantMessage('partial', { stopReason: 'aborted' });
+ },
+ fauxAssistantMessage(fauxToolCall('slow', { job: 'close' }, { id: 'call_close' }), { stopReason: 'toolUse' }),
+ fauxAssistantMessage('', { stopReason: 'error', errorMessage: 'provider exploded again' }),
+]);
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'app',
+ tools: [
+ defineTool({
+ name: 'slow',
+ description: 'Waits until it is aborted.',
+ parameters: Type.Object({ job: Type.String() }),
+ execute: async (args, _api, toolContext) => {
+ started.get(args.job)();
+ await new Promise((_, reject) =>
+ toolContext.abortSignal.addEventListener('abort', () => reject(new Error('tool aborted'))),
+ );
+ return { content: [] };
+ },
+ }),
+ ],
+ }),
+);
+
+const harness = await Harness.open(
+ new MemoryStorage(),
+ { models, registry, settings: { retry: { enabled: false } } },
+ context,
+);
+const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+
+await (await root.submit({ type: 'input', content: 'Fail please.' }, context)).wait(context);
+breakNextRequest();
+await (await root.submit({ type: 'input', content: 'Break please.' }, context)).wait(context);
+
+const abortRun = toolStarted('abort');
+const aborted = await root.submit({ type: 'input', content: 'Run slow, then abort.' }, context);
+await abortRun;
+await root.abort(context);
+await aborted.wait(context);
+
+const requestRun = toolStarted('request');
+const abortedRequest = await root.submit({ type: 'input', content: 'Answer slowly, then abort.' }, context);
+await requestRun;
+await root.abort(context);
+await abortedRequest.wait(context);
+
+const closeRun = toolStarted('close');
+await root.submit({ type: 'input', content: 'Run slow, then close.' }, context);
+await closeRun;
+await harness.close(context);
+
+const second = await Harness.open(
+ new MemoryStorage(),
+ { models, registry, settings: { retry: { enabled: false } } },
+ context,
+);
+const secondRoot = await second.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+await (await secondRoot.submit({ type: 'input', content: 'Fail, then close.' }, context)).wait(context);
+await second.close(context);
+
+// Everything captured during the runs is sent before this sentinel.
+await Sentry.flush(2000);
+Sentry.captureMessage('pi-durable interrupted done');
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-reports.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-reports.mjs
new file mode 100644
index 000000000000..50ff47c4f156
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-reports.mjs
@@ -0,0 +1,70 @@
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai/providers/faux';
+import {
+ createRegistry,
+ defineExtension,
+ defineTask,
+ GenerationTask,
+ Harness,
+ hook,
+ MemoryStorage,
+} from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+
+// Failures pi-durable does not propagate: a hook that throws (pi-durable reports it and keeps the
+// run going) and a durable task whose phase throws (the scheduler faults the task). A compaction
+// started while the conversation is idle runs outside any run.
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses([
+ fauxAssistantMessage('First answer with some detail.'),
+ fauxAssistantMessage('Second answer with more detail.'),
+ fauxAssistantMessage('Summary of the conversation.'),
+]);
+
+const Charge = defineTask({
+ name: 'app.charge',
+ version: 1,
+ initial: () => ({ phase: 'charge' }),
+ phases: {
+ charge: async () => {
+ throw new Error('card declined');
+ },
+ },
+ abort: async (_task, runtime, taskContext) => {
+ await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), taskContext);
+ },
+});
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'app',
+ tasks: [Charge],
+ hooks: [
+ hook(GenerationTask, {
+ afterResponse: () => {
+ throw new Error('afterResponse hook failed');
+ },
+ }),
+ ],
+ }),
+);
+
+const harness = await Harness.open(
+ new MemoryStorage(),
+ { models, registry, settings: { compaction: { keepRecentTokens: 1 } } },
+ context,
+);
+const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+await (await root.submit({ type: 'input', content: 'First question.' }, context)).wait(context);
+await (await root.submit({ type: 'input', content: 'Second question.' }, context)).wait(context);
+
+const charge = await root.commit(tx => tx.createTask(Charge, {}, { ownership: { kind: 'conversation' } }), context);
+await harness.waitForTask(charge, context);
+
+await harness.waitForTask(await root.compact(undefined, context), context);
+await harness.close(context);
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-restart-abort.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-restart-abort.mjs
new file mode 100644
index 000000000000..deb1ba445d95
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-restart-abort.mjs
@@ -0,0 +1,57 @@
+import * as Sentry from '@sentry/node';
+import { mkdtemp } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai/providers/faux';
+import { createRegistry, Harness } from '@earendil-works/pi-durable';
+import { openNodeJsonlStorage } from '@earendil-works/pi-durable/storage/jsonl/node';
+
+const context = BACKGROUND_CONTEXT;
+
+// The first Harness closes while a model request is in flight, which leaves the generation task in
+// storage. A second Harness on the same storage aborts the conversation before anything resumes the
+// task, so pi-durable runs only the abort handler of that task, in a Harness without a run for it.
+let requestStarted;
+const requestInFlight = new Promise(resolve => (requestStarted = resolve));
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses([
+ async (_transcript, options) => {
+ requestStarted();
+ await new Promise(resolve => options.signal.addEventListener('abort', resolve));
+ return fauxAssistantMessage('partial', { stopReason: 'aborted' });
+ },
+]);
+
+const registry = createRegistry();
+const directory = await mkdtemp(join(tmpdir(), 'pi-durable-restart-'));
+const open = async () => Harness.open(await openNodeJsonlStorage(directory, context), { models, registry }, context);
+
+const first = await open();
+const root = await first.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+const submission = await root.submit({ type: 'input', content: 'Answer slowly.' }, context);
+await requestInFlight;
+await first.close(context);
+
+let restarted = false;
+let runsStarted = 0;
+Sentry.getClient().on('spanStart', span => {
+ if (restarted && Sentry.spanToJSON(span).name === 'invoke_agent') {
+ runsStarted++;
+ }
+});
+restarted = true;
+
+const second = await open();
+await (await second.conversation(root.id, context)).abort(context);
+const settled = await (await second.submission(submission.id, context)).wait(context);
+await second.close(context);
+
+// Everything captured during the runs is sent before this sentinel.
+await Sentry.flush(2000);
+Sentry.captureMessage('pi-durable restart abort', {
+ extra: { status: settled.status, reason: settled.reason, runsStarted },
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs
new file mode 100644
index 000000000000..dc84d6387ba9
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs
@@ -0,0 +1,76 @@
+import * as Sentry from '@sentry/node';
+import { setFlagsFromString } from 'node:v8';
+import { runInNewContext } from 'node:vm';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage } from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+const RUNS = 3;
+
+// Three runs that each call `spawn`, which creates a conversation it owns and returns without running
+// it. The links to those conversations stay until the Harness closes; the traces must not.
+const toolSpans = [];
+Sentry.getClient().on('spanStart', span => {
+ if (Sentry.spanToJSON(span).name === 'execute_tool spawn') {
+ toolSpans.push(new WeakRef(span));
+ }
+});
+
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses(
+ Array.from(
+ { length: RUNS * 2 },
+ () => transcript =>
+ transcript.messages.at(-1).role === 'toolResult'
+ ? fauxAssistantMessage('The subagent is ready.')
+ : fauxAssistantMessage(fauxToolCall('spawn', {}, { id: 'call_spawn' }), { stopReason: 'toolUse' }),
+ ),
+);
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'app',
+ tools: [
+ defineTool({
+ name: 'spawn',
+ description: 'Creates a subagent for later.',
+ parameters: Type.Object({}),
+ execute: async (_args, api, toolContext) => {
+ await api.commit(
+ tx => tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } }),
+ toolContext,
+ );
+ return { content: [{ type: 'text', text: 'Subagent created.' }] };
+ },
+ }),
+ ],
+ }),
+);
+
+const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
+const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+for (let run = 1; run <= RUNS; run++) {
+ await (await root.submit({ type: 'input', content: `Run ${run}` }, context)).wait(context);
+}
+await harness.waitForIdle(context);
+
+// Sends the finished traces, which span streaming buffers until then.
+await Sentry.flush();
+// A WeakRef is never cleared in the job that created it.
+await new Promise(resolve => setImmediate(resolve));
+setFlagsFromString('--expose-gc');
+const gc = runInNewContext('gc');
+setFlagsFromString('--no-expose-gc');
+gc();
+gc();
+
+Sentry.captureMessage('pi-durable retention', {
+ extra: { toolCalls: toolSpans.length, alive: toolSpans.filter(ref => ref.deref()).length },
+});
+await harness.close(context);
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs
new file mode 100644
index 000000000000..da80b508f252
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs
@@ -0,0 +1,121 @@
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+import {
+ AssistantEntry,
+ createRegistry,
+ defineExtension,
+ defineTool,
+ Harness,
+ MemoryStorage,
+} from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+
+// Three runs of the root conversation, each with one tool round:
+// - `delegate` creates a conversation it owns and waits for its answer, the subagent pattern;
+// - `fork_delegate` does the same with a fork of the current conversation;
+// - `batch` runs the `inner` tool of its agent itself, passing its own api on.
+// Each response is derived from the transcript, so the faux model answers every conversation.
+const respond = transcript => {
+ const lastUser = [...transcript.messages].reverse().find(message => message.role === 'user');
+ const text =
+ typeof lastUser.content === 'string' ? lastUser.content : lastUser.content.map(part => part.text).join('');
+ const afterTool = transcript.messages.at(-1).role === 'toolResult';
+ if (afterTool) {
+ return fauxAssistantMessage(`Done: ${text}`);
+ }
+ if (text === 'Delegate the weather question.') {
+ return fauxAssistantMessage(fauxToolCall('delegate', { task: 'Weather in Vienna?' }, { id: 'call_delegate' }), {
+ stopReason: 'toolUse',
+ });
+ }
+ if (text === 'Fork a helper.') {
+ return fauxAssistantMessage(fauxToolCall('fork_delegate', {}, { id: 'call_fork' }), { stopReason: 'toolUse' });
+ }
+ if (text === 'Run the batch.') {
+ return fauxAssistantMessage(fauxToolCall('batch', {}, { id: 'call_batch' }), { stopReason: 'toolUse' });
+ }
+ return fauxAssistantMessage(`Answer: ${text}`);
+};
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses(Array.from({ length: 12 }, () => respond));
+
+const answerText = async (api, settled, toolContext) => {
+ const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), toolContext);
+ return (answer?.model?.[0]?.content ?? [])
+ .filter(part => part.type === 'text')
+ .map(part => part.text)
+ .join('');
+};
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'app',
+ tools: [
+ defineTool({
+ name: 'delegate',
+ description: 'Delegates a task to a subagent.',
+ parameters: Type.Object({ task: Type.String() }),
+ execute: async (args, api, toolContext) => {
+ const childId = await api.commit(
+ async tx => (await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } })).id,
+ toolContext,
+ );
+ const child = await api.conversation(childId, toolContext);
+ const settled = await (
+ await child.submit({ type: 'input', content: args.task }, toolContext)
+ ).wait(toolContext);
+ return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] };
+ },
+ }),
+ defineTool({
+ name: 'fork_delegate',
+ description: 'Forks this conversation into a subagent.',
+ parameters: Type.Object({}),
+ execute: async (_args, api, toolContext) => {
+ const childId = await api.commit(async tx => {
+ const at = (await tx.scanEntries({ conversationId: api.conversationId }, 1)).items[0].id;
+ const fork = await tx.forkConversation(api.conversationId, at, {
+ ownership: { kind: 'task', taskId: api.taskId },
+ });
+ return fork.id;
+ }, toolContext);
+ const child = await api.conversation(childId, toolContext);
+ const settled = await (
+ await child.submit({ type: 'input', content: 'Forked task.' }, toolContext)
+ ).wait(toolContext);
+ return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] };
+ },
+ }),
+ defineTool({
+ name: 'inner',
+ description: 'A plain tool.',
+ parameters: Type.Object({}),
+ execute: async () => ({ content: [{ type: 'text', text: 'INNER RESULT' }] }),
+ }),
+ defineTool({
+ name: 'batch',
+ description: 'Runs the inner tool itself.',
+ parameters: Type.Object({}),
+ execute: async (_args, api, toolContext) => {
+ const inner = (await api.agent(toolContext)).tools.find(tool => tool.name === 'inner');
+ await inner.execute({}, api, toolContext);
+ return { content: [{ type: 'text', text: 'OUTER RESULT' }] };
+ },
+ }),
+ ],
+ }),
+);
+
+const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
+const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+for (const content of ['Delegate the weather question.', 'Fork a helper.', 'Run the batch.']) {
+ await (await root.submit({ type: 'input', content }, context)).wait(context);
+}
+await harness.waitForIdle(context);
+await harness.close(context);
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs
new file mode 100644
index 000000000000..fc42ebf2091f
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs
@@ -0,0 +1,110 @@
+import * as Sentry from '@sentry/node';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+import {
+ createRegistry,
+ defineExtension,
+ defineTool,
+ Harness,
+ hook,
+ MemoryStorage,
+ ToolTask,
+} from '@earendil-works/pi-durable';
+import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node';
+import { CodingTools, createBashTool } from '@earendil-works/pi-durable/tools';
+
+const context = BACKGROUND_CONTEXT;
+
+// One tool round, run one call at a time so the order of the error events is fixed:
+// - `bash` is the built-in tool, registered by the app's own extension through its factory; it runs
+// a command that exits non-zero, which it reports by throwing;
+// - `read` is an app tool that replaces the built-in `read` by name, and throws;
+// - `streamer` returns nothing, so its streamed output becomes the result;
+// - `secret` returns a value an `afterTool` hook redacts before the model sees it;
+// - `fail_now` is an app tool that throws.
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses([
+ fauxAssistantMessage(
+ [
+ fauxToolCall('bash', { command: 'ls does-not-exist' }, { id: 'call_bash' }),
+ fauxToolCall('read', { path: 'notes.txt' }, { id: 'call_read' }),
+ fauxToolCall('streamer', {}, { id: 'call_streamer' }),
+ fauxToolCall('secret', {}, { id: 'call_secret' }),
+ fauxToolCall('fail_now', {}, { id: 'call_fail' }),
+ ],
+ { stopReason: 'toolUse' },
+ ),
+ fauxAssistantMessage('Done.'),
+]);
+
+const registry = createRegistry();
+registry.install(CodingTools);
+registry.install(
+ defineExtension({
+ name: 'app',
+ tools: [
+ createBashTool(),
+ defineTool({
+ name: 'read',
+ description: 'Reads notes.',
+ parameters: Type.Object({ path: Type.String() }),
+ execute: async () => {
+ throw new Error('app read failed');
+ },
+ }),
+ defineTool({
+ name: 'streamer',
+ description: 'Streams its output.',
+ parameters: Type.Object({}),
+ execute: async (_args, api) => {
+ api.output('line 1\n');
+ api.output('line 2\n');
+ return {};
+ },
+ }),
+ defineTool({
+ name: 'secret',
+ description: 'Returns a secret.',
+ parameters: Type.Object({}),
+ execute: async () => ({ content: [{ type: 'text', text: 'original secret value' }] }),
+ }),
+ defineTool({
+ name: 'fail_now',
+ description: 'Always throws.',
+ parameters: Type.Object({}),
+ execute: async () => {
+ throw new Error('Intentional pi-durable tool failure');
+ },
+ }),
+ ],
+ hooks: [
+ hook(ToolTask, {
+ afterTool: (call, result) =>
+ call.name === 'secret'
+ ? { ...result, content: [{ type: 'text', text: 'redacted by afterTool' }] }
+ : undefined,
+ }),
+ ],
+ }),
+);
+
+const cwd = mkdtempSync(join(tmpdir(), 'pi-durable-tools-'));
+const harness = await Harness.open(
+ new MemoryStorage(),
+ { models, registry, env: () => new NodeExecutionEnv({ cwd }), settings: { toolExecution: 'sequential' } },
+ context,
+);
+const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+await (await root.submit({ type: 'input', content: 'Run the tools.' }, context)).wait(context);
+await harness.close(context);
+
+// Everything captured during the run is sent before this sentinel.
+await Sentry.flush(2000);
+Sentry.captureMessage('pi-durable tools done');
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs
new file mode 100644
index 000000000000..42309d5ba2d4
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs
@@ -0,0 +1,63 @@
+import * as Sentry from '@sentry/node';
+import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
+import { Type } from '@earendil-works/pi-ai';
+import { createModels } from '@earendil-works/pi-ai/models';
+import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
+import {
+ createRegistry,
+ defineExtension,
+ defineTool,
+ Harness,
+ MemoryStorage,
+ section,
+} from '@earendil-works/pi-durable';
+
+const context = BACKGROUND_CONTEXT;
+
+// `pi-ai`'s faux provider scripts model responses in-process. The first run calls two tools and then
+// answers, the second run answers directly.
+const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
+const models = createModels();
+models.setProvider(faux.provider);
+faux.setResponses([
+ fauxAssistantMessage(
+ [fauxToolCall('get_weather', { city: 'Berlin' }, { id: 'call_1' }), fauxToolCall('broken', {}, { id: 'call_2' })],
+ { stopReason: 'toolUse' },
+ ),
+ fauxAssistantMessage('It is 21 degrees and sunny in Berlin.'),
+ fauxAssistantMessage('Goodbye.'),
+]);
+
+const registry = createRegistry();
+registry.install(
+ defineExtension({
+ name: 'weather',
+ sections: [section('preamble', () => 'You are a weather assistant.', { tag: false })],
+ tools: [
+ defineTool({
+ name: 'get_weather',
+ description: 'Get the current weather for a city.',
+ parameters: Type.Object({ city: Type.String() }),
+ execute: async args => ({ content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }),
+ }),
+ defineTool({
+ name: 'broken',
+ description: 'Always fails.',
+ parameters: Type.Object({}),
+ execute: async () => {
+ throw new Error('broken tool');
+ },
+ }),
+ ],
+ }),
+);
+
+// The Harness is opened and driven inside a request span. Its runs must still start traces of their
+// own: the scheduler runs them later, and their trace must not depend on which request woke it.
+await Sentry.startSpan({ name: 'pi-durable-request', op: 'http.server' }, async () => {
+ const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
+ const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
+ await (await root.submit({ type: 'input', content: 'What is the weather in Berlin?' }, context)).wait(context);
+ await (await root.submit({ type: 'input', content: 'Thanks!' }, context)).wait(context);
+ await harness.close(context);
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts
new file mode 100644
index 000000000000..be521b3d3d98
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts
@@ -0,0 +1,747 @@
+import {
+ GEN_AI_CONVERSATION_ID,
+ GEN_AI_INPUT_MESSAGES,
+ GEN_AI_OPERATION_NAME,
+ GEN_AI_OUTPUT_MESSAGES,
+ GEN_AI_PROVIDER_NAME,
+ GEN_AI_REQUEST_MODEL,
+ GEN_AI_RESPONSE_FINISH_REASONS,
+ GEN_AI_RESPONSE_STREAMING,
+ GEN_AI_SYSTEM_INSTRUCTIONS,
+ GEN_AI_TOOL_CALL_ARGUMENTS,
+ GEN_AI_TOOL_CALL_RESULT,
+ GEN_AI_TOOL_DEFINITIONS,
+ GEN_AI_TOOL_DESCRIPTION,
+ GEN_AI_TOOL_NAME,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SENTRY_STATUS_MESSAGE,
+} from '@sentry/conventions/attributes';
+import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op';
+import { afterAll, expect } from 'vitest';
+import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
+import { conditionalTest, RUNTIME } from '../../../utils';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
+
+// The pi packages declare `engines.node >= 22.19`, so they can't live in the package's root
+// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install them per-suite
+// instead, guarded by the `min: 22` skip below.
+const PI_DURABLE_DEPENDENCIES = {
+ additionalDependencies: {
+ '@earendil-works/pi-durable': '^1.0.0',
+ '@earendil-works/pi-ai': '^1.0.0',
+ '@earendil-works/chord': '^1.0.0',
+ },
+};
+
+const CONTENT_ATTRIBUTES = [
+ GEN_AI_INPUT_MESSAGES,
+ GEN_AI_OUTPUT_MESSAGES,
+ GEN_AI_SYSTEM_INSTRUCTIONS,
+ GEN_AI_TOOL_DEFINITIONS,
+ GEN_AI_TOOL_CALL_ARGUMENTS,
+ GEN_AI_TOOL_CALL_RESULT,
+];
+
+conditionalTest({ min: 22 })('pi-durable integration', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ // The pi packages are ESM-only: their `exports` maps have no `require` condition.
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('traces each run as its own invoke_agent trace with chat and execute_tool children', async () => {
+ let toolRunTraceId: string | undefined;
+ let brokenToolSpanId: string | undefined;
+ let errorTraceId: string | undefined;
+ let errorSpanId: string | undefined;
+ const conversationIds: unknown[] = [];
+
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(spans.filter(span => span.name === 'chat faux-model')).toHaveLength(2);
+ // One span per run, request and tool call: a tool wrapped twice would add a span.
+ expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable')).toHaveLength(
+ 5,
+ );
+
+ expect(agent.attributes[SENTRY_OP]?.value).toBe(GEN_AI_INVOKE_AGENT);
+ expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
+ expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent');
+ const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ // The root conversation's id is `1` in every pi-durable storage, so it is prefixed
+ // with a per-Harness id.
+ expect(conversationId).toMatch(/^[0-9a-f]{32}:1$/);
+
+ const chats = spans
+ .filter(span => span.name === 'chat faux-model')
+ .sort((a, b) => a.start_timestamp - b.start_timestamp);
+ for (const chat of chats) {
+ expect(chat.parent_span_id).toBe(agent.span_id);
+ expect(chat.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
+ expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable');
+ expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('faux');
+ expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('faux-model');
+ expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
+ expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0);
+ // pi-durable sends the prompt and the tools as positional system messages.
+ expect(chat.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toBe('You are a weather assistant.');
+ expect(
+ JSON.parse(String(chat.attributes[GEN_AI_TOOL_DEFINITIONS]?.value)).map(
+ (tool: { name: string }) => tool.name,
+ ),
+ ).toEqual(['get_weather', 'broken']);
+ }
+
+ // Mapped from pi-ai's shape to the conventions: the tool calls and tool results the
+ // second request carries are what Sentry's conversation view renders.
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
+ { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] },
+ ]);
+ const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value));
+ expect(answerInput.map((message: { role: string }) => message.role)).toEqual([
+ 'user',
+ 'assistant',
+ 'tool',
+ 'tool',
+ ]);
+ expect(answerInput[1].parts).toEqual([
+ { type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' },
+ { type: 'tool_call', id: 'call_2', name: 'broken', arguments: '{}' },
+ ]);
+ expect(answerInput[2].parts).toEqual([
+ {
+ type: 'tool_call_response',
+ id: 'call_1',
+ name: 'get_weather',
+ result: 'It is 21 degrees and sunny in Berlin.',
+ },
+ ]);
+ expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([
+ '["stop"]',
+ '["tool_call"]',
+ ]);
+ expect(chats.map(chat => chat.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).join()).toContain(
+ 'It is 21 degrees and sunny in Berlin.',
+ );
+
+ const weather = spans.find(span => span.name === 'execute_tool get_weather')!;
+ expect(weather.parent_span_id).toBe(agent.span_id);
+ expect(weather.status).toBe('ok');
+ expect(weather.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL);
+ expect(weather.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather');
+ expect(weather.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe('Get the current weather for a city.');
+ expect(weather.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]?.value).toBe('call_1');
+ expect(weather.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}');
+ expect(weather.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin');
+ expect(weather.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+
+ const broken = spans.find(span => span.name === 'execute_tool broken')!;
+ expect(broken.parent_span_id).toBe(agent.span_id);
+ expect(broken.status).toBe('error');
+ // The error result pi-durable gives the model, not the thrown error.
+ expect(broken.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('[error] broken tool');
+
+ toolRunTraceId = agent.trace_id;
+ brokenToolSpanId = broken.span_id;
+ conversationIds.push(conversationId);
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(spans).toHaveLength(2);
+
+ const chat = spans.find(span => span.name === 'chat faux-model')!;
+ expect(chat.parent_span_id).toBe(agent.span_id);
+ expect(chat.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('Goodbye.');
+
+ conversationIds.push(agent.attributes[GEN_AI_CONVERSATION_ID]?.value);
+ },
+ })
+ .expect({
+ span: container => {
+ // The request that opened the Harness and submitted both inputs holds no agent work.
+ expect(container.items).toHaveLength(1);
+ expect(container.items[0]!.name).toBe('pi-durable-request');
+ },
+ })
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('broken tool');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+
+ errorTraceId = event.contexts?.trace?.trace_id;
+ errorSpanId = event.contexts?.trace?.span_id;
+ },
+ })
+ .start()
+ .completed();
+
+ // Both runs belong to the root conversation, so they share one conversation id.
+ expect(conversationIds).toHaveLength(2);
+ expect(conversationIds[0]).toBe(conversationIds[1]);
+ // The tool error is reported on the failing call's span, in the run's trace.
+ expect(errorTraceId).toBe(toolRunTraceId);
+ expect(errorSpanId).toBe(brokenToolSpanId);
+ });
+
+ test('records no message content when the client turns gen_ai recording off', async () => {
+ await createRunner()
+ .withEnv({ PI_DURABLE_RECORDING: 'off' })
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ expect(spans).toHaveLength(5);
+ for (const span of spans) {
+ for (const attribute of CONTENT_ATTRIBUTES) {
+ expect(span.attributes[attribute]).toBeUndefined();
+ }
+ }
+ const weather = spans.find(span => span.name === 'execute_tool get_weather')!;
+ expect(weather.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe('Get the current weather for a city.');
+ expect(
+ spans.find(span => span.name === 'chat faux-model')!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value,
+ ).toBeGreaterThan(0);
+ },
+ })
+ .expect({
+ span: container => {
+ expect(container.items).toHaveLength(2);
+ for (const span of container.items) {
+ for (const attribute of CONTENT_ATTRIBUTES) {
+ expect(span.attributes[attribute]).toBeUndefined();
+ }
+ }
+ },
+ })
+ .expect({
+ span: container => {
+ expect(container.items).toHaveLength(1);
+ expect(container.items[0]!.name).toBe('pi-durable-request');
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('broken tool');
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-tools.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('reports app tool failures but not failures of the built-in coding tools', async () => {
+ const appErrors: unknown[] = [];
+
+ // Ordered, with spans ignored: a captured failure of the built-in `bash` tool would be an
+ // extra event before the sentinel, which the scenario sends once everything else is sent.
+ // The two app failures are processed concurrently, so they may arrive in either order.
+ await createRunner()
+ .ignore('span')
+ .expect({
+ event: event => {
+ expect(['app read failed', 'Intentional pi-durable tool failure']).toContain(
+ event.exception?.values?.[0]?.value,
+ );
+ appErrors.push(event.exception?.values?.[0]?.value);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(['app read failed', 'Intentional pi-durable tool failure']).toContain(
+ event.exception?.values?.[0]?.value,
+ );
+ appErrors.push(event.exception?.values?.[0]?.value);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.message).toBe('pi-durable tools done');
+ },
+ })
+ .start()
+ .completed();
+
+ expect(appErrors.sort()).toEqual(['Intentional pi-durable tool failure', 'app read failed']);
+ });
+
+ test('records the tool result the model receives', async () => {
+ let errorSpanId: string | undefined;
+ let failingToolSpanId: string | undefined;
+
+ await createRunner()
+ .unordered()
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('Intentional pi-durable tool failure');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ errorSpanId = event.contexts?.trace?.span_id;
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('app read failed');
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+
+ const bash = spans.find(span => span.name === 'execute_tool bash')!;
+ expect(bash.status).toBe('error');
+ expect(bash.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toMatch(/Command exited with code \d+/);
+
+ const read = spans.find(span => span.name === 'execute_tool read')!;
+ expect(read.status).toBe('error');
+ expect(read.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('app read failed');
+
+ // The streamed output becomes the result, since the tool returns no content.
+ const streamer = spans.find(span => span.name === 'execute_tool streamer')!;
+ expect(streamer.status).toBe('ok');
+ expect(streamer.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('line 1\nline 2\n');
+
+ const secret = spans.find(span => span.name === 'execute_tool secret')!;
+ expect(secret.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('redacted by afterTool');
+
+ const failNow = spans.find(span => span.name === 'execute_tool fail_now')!;
+ expect(failNow.status).toBe('error');
+ failingToolSpanId = failNow.span_id;
+ },
+ })
+ .start()
+ .completed();
+
+ expect(errorSpanId).toBe(failingToolSpanId);
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-reports.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('captures failures pi-durable does not propagate, and tags idle compaction with its conversation', async () => {
+ let runConversationId: unknown;
+ let compactionConversationId: unknown;
+
+ await createRunner()
+ .unordered()
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('afterResponse hook failed');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ },
+ })
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('card declined');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ },
+ })
+ .expect({
+ span: container => {
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ runConversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ },
+ })
+ .expect({
+ span: container => {
+ // A compaction started while idle belongs to no run, so its request is a trace of its own.
+ expect(container.items).toHaveLength(1);
+ const summary = container.items[0]!;
+ expect(summary.is_segment).toBe(true);
+ expect(summary.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT);
+ compactionConversationId = summary.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ },
+ })
+ .start()
+ .completed();
+
+ expect(runConversationId).toMatch(/^[0-9a-f]{32}:1$/);
+ expect(compactionConversationId).toBe(runConversationId);
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-subagent.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('nests subagent runs under the tool call that started them', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const parent = spans.find(span => span.is_segment)!;
+ const delegate = spans.find(span => span.name === 'execute_tool delegate')!;
+ expect(parent.name).toBe('invoke_agent');
+ expect(delegate.parent_span_id).toBe(parent.span_id);
+
+ // The subagent's run is a child of the delegating call, in the same trace, on its own
+ // conversation of the same Harness.
+ const child = spans.find(span => span.name === 'invoke_agent' && !span.is_segment)!;
+ expect(child.parent_span_id).toBe(delegate.span_id);
+ const parentConversation = String(parent.attributes[GEN_AI_CONVERSATION_ID]?.value);
+ const childConversation = String(child.attributes[GEN_AI_CONVERSATION_ID]?.value);
+ expect(childConversation).not.toBe(parentConversation);
+ expect(childConversation.split(':')[0]).toBe(parentConversation.split(':')[0]);
+
+ const childChat = spans.find(span => span.parent_span_id === child.span_id)!;
+ expect(childChat.name).toBe('chat faux-model');
+ expect(childChat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(childConversation);
+ expect(delegate.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('Answer: Weather in Vienna?');
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const parent = spans.find(span => span.is_segment)!;
+ const fork = spans.find(span => span.name === 'execute_tool fork_delegate')!;
+ const child = spans.find(span => span.name === 'invoke_agent' && !span.is_segment)!;
+ expect(fork.parent_span_id).toBe(parent.span_id);
+ expect(child.parent_span_id).toBe(fork.span_id);
+ expect(fork.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('Answer: Forked task.');
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const batch = spans.find(span => span.name === 'execute_tool batch')!;
+ const inner = spans.find(span => span.name === 'execute_tool inner')!;
+ expect(batch.parent_span_id).toBe(spans.find(span => span.is_segment)!.span_id);
+ // A tool that runs another tool itself: the inner call ends with its own result.
+ expect(inner.parent_span_id).toBe(batch.span_id);
+ expect(inner.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('INNER RESULT');
+ expect(batch.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toBe('OUTER RESULT');
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-retention.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ // Bun does not implement `v8.setFlagsFromString`, which the scenario needs to force GC.
+ test.skipIf(RUNTIME === 'bun')(
+ 'does not keep the traces of tool calls alive for subagent conversations that never run',
+ async () => {
+ await createRunner()
+ .ignore('span')
+ .expect({ event: { message: 'pi-durable retention', extra: { toolCalls: 3, alive: 0 } } })
+ .start()
+ .completed();
+ },
+ );
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-restart-abort.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('starts no run for an abort handler in a Harness that never saw the run', async () => {
+ await createRunner()
+ .ignore('span')
+ .expect({
+ event: {
+ message: 'pi-durable restart abort',
+ extra: { status: 'unanswered', reason: 'aborted', runsStarted: 0 },
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-interrupted.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('reports a provider that throws as a fault, and nothing for failed, aborted or closed runs', async () => {
+ await createRunner()
+ .ignore('span')
+ .expect({
+ event: event => {
+ const exception = event.exception?.values?.[0];
+ expect(exception?.value).toBe('provider broke');
+ expect(exception?.mechanism).toEqual({ type: 'auto.ai.pi_durable', handled: false });
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.message).toBe('pi-durable interrupted done');
+ },
+ })
+ .start()
+ .completed();
+ });
+
+ test('ends every run that does not end with an answer', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ // 1. The model request failed: the run and the request are errors.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Fail please.');
+ expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["error"]');
+ expect(chat.status).toBe('error');
+ expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(agent.status).toBe('error');
+ expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('model_error');
+ },
+ })
+ .expect({
+ span: container => {
+ // 2. The provider threw: the generation faulted.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toBeUndefined();
+ expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error');
+ },
+ })
+ .expect({
+ span: container => {
+ // 3. Aborted while the tool ran: cancelled, which streamed spans report as ok.
+ const slow = container.items.find(span => span.name === 'execute_tool slow')!;
+ expect(slow.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"job":"abort"}');
+ expect(slow.status).toBe('ok');
+ expect(container.items.find(span => span.is_segment)!.status).toBe('ok');
+ },
+ })
+ .expect({
+ span: container => {
+ // 4. Aborted while the model request ran.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["aborted"]');
+ expect(container.items).toHaveLength(2);
+ },
+ })
+ .expect({
+ span: container => {
+ // 5. The Harness closed while the tool ran: the run still ends, so the trace has a segment.
+ const slow = container.items.find(span => span.name === 'execute_tool slow')!;
+ expect(slow.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"job":"close"}');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(slow.parent_span_id).toBe(agent.span_id);
+ },
+ })
+ .expect({
+ span: container => {
+ // 6. The run failed, and the Harness closed right after `wait()`, before the commit that
+ // ended the run resolved: the run keeps its own status.
+ const chat = container.items.find(span => span.name === 'chat faux-model')!;
+ expect(chat.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Fail, then close.');
+ const agent = container.items.find(span => span.is_segment)!;
+ expect(agent.status).toBe('error');
+ expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('model_error');
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-concurrent.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('keeps concurrent runs apart and out of the scopes of the requests that submitted them', async () => {
+ const runTraceIds = new Set();
+ const errorTraceIds = new Set();
+
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ const chats = spans.filter(span => span.name === 'chat faux-model');
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))[0].parts[0].content).toBe(
+ 'request A',
+ );
+ // The run's own conversation id, not the one the request set on its scope.
+ const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ expect(conversationId).toMatch(/^[0-9a-f]{32}:\d+$/);
+ for (const span of spans) {
+ expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(span.attributes['request']).toBeUndefined();
+ }
+ expect(
+ spans.find(span => span.name === 'execute_tool work')!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value,
+ ).toBe('{"tag":"request A"}');
+ runTraceIds.add(agent.trace_id);
+ },
+ })
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ const chats = spans.filter(span => span.name === 'chat faux-model');
+ expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))[0].parts[0].content).toBe(
+ 'request B',
+ );
+ const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value;
+ expect(conversationId).toMatch(/^[0-9a-f]{32}:\d+$/);
+ for (const span of spans) {
+ expect(span.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId);
+ expect(span.attributes['request']).toBeUndefined();
+ }
+ expect(
+ spans.find(span => span.name === 'execute_tool work')!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value,
+ ).toBe('{"tag":"request B"}');
+ runTraceIds.add(agent.trace_id);
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('work failed for request A');
+ expect(event.tags?.request).toBeUndefined();
+ errorTraceIds.add(String(event.contexts?.trace?.trace_id));
+ },
+ })
+ .expect({
+ event: event => {
+ expect(event.exception?.values?.[0]?.value).toBe('work failed for request B');
+ expect(event.tags?.request).toBeUndefined();
+ errorTraceIds.add(String(event.contexts?.trace?.trace_id));
+ },
+ })
+ .start()
+ .completed();
+
+ // Each tool error belongs to its run's trace.
+ expect(errorTraceIds).toEqual(runTraceIds);
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-compaction.mjs',
+ 'instrument.mjs',
+ (createRunner, test, mode) => {
+ if (mode === 'cjs') {
+ return;
+ }
+
+ test('keeps a compaction that pi-durable starts for a run inside that run', async () => {
+ await createRunner()
+ .unordered()
+ .expect({
+ span: container => {
+ const spans = container.items;
+ const summary = spans.find(span =>
+ String(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).includes('Summary'),
+ )!;
+ const agent = spans.find(span => span.is_segment)!;
+ expect(agent.name).toBe('invoke_agent');
+ expect(summary.parent_span_id).toBe(agent.span_id);
+ // pi-durable completes the summary without streaming; the run's own request streams.
+ expect(summary.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined();
+ const answer = spans.find(span =>
+ String(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).includes('Third answer'),
+ )!;
+ expect(answer.parent_span_id).toBe(agent.span_id);
+ expect(answer.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
+ },
+ })
+ .expect({
+ span: container => expect(container.items.find(span => span.is_segment)!.name).toBe('invoke_agent'),
+ })
+ .expect({
+ span: container => expect(container.items.find(span => span.is_segment)!.name).toBe('invoke_agent'),
+ })
+ .start()
+ .completed();
+ });
+ },
+ PI_DURABLE_DEPENDENCIES,
+ );
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs
deleted file mode 100644
index 53b9511a21f0..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/tedious/instrument.mjs
index 170ad6f6a702..46a27dd03b74 100644
--- a/dev-packages/node-integration-tests/suites/tracing/tedious/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/tedious/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts
index eb5c81a0a49d..1d24ab0e7041 100644
--- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts
@@ -1,3 +1,15 @@
+import {
+ DB_NAMESPACE,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+ DB_USER,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
+ SERVER_ADDRESS,
+ SERVER_PORT,
+} from '@sentry/conventions/attributes';
+import { DB } from '@sentry/conventions/op';
import { afterAll, expect } from 'vitest';
import { conditionalTest } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
@@ -9,36 +21,36 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
cleanupChildProcesses();
});
- const dbSpan = (overrides: Record) =>
+ const dbSpan = (text: string, status = 'ok') =>
expect.objectContaining({
- op: 'db',
- origin: ORIGIN,
- data: expect.objectContaining({
- 'sentry.origin': ORIGIN,
- 'sentry.op': 'db',
- 'db.system.name': 'mssql',
- 'db.namespace': 'master',
- 'db.user': 'sa',
- 'server.address': '127.0.0.1',
- 'server.port': 1433,
+ status,
+ attributes: expect.objectContaining({
+ [DB_QUERY_TEXT]: { type: 'string', value: text },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [DB_SYSTEM_NAME]: { type: 'string', value: 'mssql' },
+ [DB_NAMESPACE]: { type: 'string', value: 'master' },
+ [DB_USER]: { type: 'string', value: 'sa' },
+ [SERVER_ADDRESS]: { type: 'string', value: '127.0.0.1' },
+ [SERVER_PORT]: { type: 'integer', value: 1433 },
}),
- ...overrides,
});
- const EXPECTED_TRANSACTION = {
- transaction: 'Test Transaction',
- spans: expect.arrayContaining([
- dbSpan({ description: 'SELECT ? + ? AS solution', status: 'ok' }),
- dbSpan({ description: 'SELECT ?; SELECT ?', status: 'ok' }),
- dbSpan({ description: 'select !', status: 'internal_error' }),
- dbSpan({ description: '[dbo].[test_proced]', status: 'ok' }),
- dbSpan({ description: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)', status: 'ok' }),
+ const EXPECTED_SPANS = {
+ items: expect.arrayContaining([
+ dbSpan('SELECT ? + ? AS solution'),
+ dbSpan('SELECT ?; SELECT ?'),
+ dbSpan('select !', 'error'),
+ dbSpan('[dbo].[test_proced]'),
+ dbSpan('INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)'),
expect.objectContaining({
- description: 'execBulkLoad test_bulk master',
- op: 'db',
- origin: ORIGIN,
+ name: 'execBulkLoad test_bulk',
status: 'ok',
- data: expect.objectContaining({ 'db.sql.table': 'test_bulk' }),
+ attributes: expect.objectContaining({
+ 'db.sql.table': { type: 'string', value: 'test_bulk' },
+ [SENTRY_OP]: { type: 'string', value: DB },
+ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN },
+ }),
}),
]),
};
@@ -47,9 +59,9 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
test('should auto-instrument `tedious` package', async () => {
await createTestRunner()
.expect({
- transaction: transaction => {
- expect(transaction.transaction).toBe(EXPECTED_TRANSACTION.transaction);
- expect(transaction.spans).toEqual(EXPECTED_TRANSACTION.spans);
+ span: container => {
+ expect(container.items.find(span => span.is_segment)?.name).toBe('Test Transaction');
+ expect(container.items).toEqual(EXPECTED_SPANS.items);
const CREATE_PROCEDURE =
'CREATE OR ALTER PROCEDURE [dbo].[test_proced] @inputVal varchar(?), @outputCount int OUTPUT AS set @outputCount = LEN(@inputVal)';
@@ -65,26 +77,43 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
const SELECT_STRING_LITERAL = 'SELECT c1, c2 FROM [dbo].[test_bulk] WHERE c2 = ?';
expect(
- (transaction.spans ?? [])
- .filter(span => span.origin === ORIGIN)
- .map(span => ({ name: span.description, text: span.data?.['db.query.text'] })),
+ container.items
+ .filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN)
+ .map(span => ({
+ name: span.name,
+ summary: span.attributes[DB_QUERY_SUMMARY]?.value,
+ text: span.attributes[DB_QUERY_TEXT]?.value,
+ })),
).toEqual([
- { name: 'SELECT ? + ? AS solution', text: 'SELECT ? + ? AS solution' },
- { name: 'SELECT ?; SELECT ?', text: 'SELECT ?; SELECT ?' },
- { name: 'select !', text: 'select !' },
- { name: CREATE_PROCEDURE, text: CREATE_PROCEDURE },
- { name: '[dbo].[test_proced]', text: '[dbo].[test_proced]' },
- { name: CREATE_PREPARED_TABLE, text: CREATE_PREPARED_TABLE },
- { name: INSERT_PREPARED, text: INSERT_PREPARED },
- { name: INSERT_PREPARED, text: INSERT_PREPARED },
- { name: CREATE_BULK_TABLE, text: CREATE_BULK_TABLE },
- { name: 'execBulkLoad test_bulk master', text: undefined },
- { name: INSERT_BULK, text: INSERT_BULK },
- { name: SELECT_PREPARED, text: SELECT_PREPARED },
- { name: SELECT_JOIN, text: SELECT_JOIN },
- { name: SELECT_INLINE_LITERAL, text: SELECT_INLINE_LITERAL },
- { name: SELECT_PARAMETERIZED, text: SELECT_PARAMETERIZED },
- { name: SELECT_STRING_LITERAL, text: SELECT_STRING_LITERAL },
+ { name: 'SELECT', summary: 'SELECT', text: 'SELECT ? + ? AS solution' },
+ { name: 'SELECT', summary: 'SELECT', text: 'SELECT ?; SELECT ?' },
+ { name: 'select', summary: 'select', text: 'select !' },
+ { name: 'CREATE', summary: 'CREATE', text: CREATE_PROCEDURE },
+ { name: 'callProcedure [dbo].[test_proced]', summary: undefined, text: '[dbo].[test_proced]' },
+ { name: 'if', summary: 'if', text: CREATE_PREPARED_TABLE },
+ { name: 'INSERT [dbo].[test_prepared]', summary: 'INSERT [dbo].[test_prepared]', text: INSERT_PREPARED },
+ { name: 'INSERT [dbo].[test_prepared]', summary: 'INSERT [dbo].[test_prepared]', text: INSERT_PREPARED },
+ { name: 'if', summary: 'if', text: CREATE_BULK_TABLE },
+ { name: 'insert', summary: 'insert', text: INSERT_BULK },
+ { name: 'execBulkLoad test_bulk', summary: undefined, text: undefined },
+ { name: 'SELECT [dbo].[test_prepared]', summary: 'SELECT [dbo].[test_prepared]', text: SELECT_PREPARED },
+ {
+ // TODO: (check if correct) Both sides of the join survive into the summary.
+ name: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]',
+ summary: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]',
+ text: SELECT_JOIN,
+ },
+ {
+ name: 'SELECT [dbo].[test_prepared]',
+ summary: 'SELECT [dbo].[test_prepared]',
+ text: SELECT_INLINE_LITERAL,
+ },
+ {
+ name: 'SELECT [dbo].[test_prepared]',
+ summary: 'SELECT [dbo].[test_prepared]',
+ text: SELECT_PARAMETERIZED,
+ },
+ { name: 'SELECT [dbo].[test_bulk]', summary: 'SELECT [dbo].[test_bulk]', text: SELECT_STRING_LITERAL },
]);
},
})
@@ -98,13 +127,13 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
createEsmAndCjsTests(
__dirname,
'scenario.mjs',
- 'instrument-span-streaming.mjs',
+ 'instrument.mjs',
(createTestRunner, test) => {
test('should auto-instrument `tedious` package', async () => {
await createTestRunner()
.expect({
span: container => {
- const dbSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN);
+ const dbSpans = container.items.filter(item => item.attributes[SENTRY_ORIGIN]?.value === ORIGIN);
expect(dbSpans.map(span => span.name)).toEqual(
expect.arrayContaining([
@@ -116,13 +145,12 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
]),
);
expect(dbSpans.find(span => span.name === 'select')?.status).toBe('error');
- expect(dbSpans[0]?.attributes).toMatchObject({
- 'db.system.name': { value: 'mssql' },
- 'db.namespace': { value: 'master' },
- 'db.user': { value: 'sa' },
- 'server.address': { value: '127.0.0.1' },
- 'server.port': { value: 1433 },
- });
+ const attributes = dbSpans[0]?.attributes;
+ expect(attributes?.[DB_SYSTEM_NAME]).toEqual({ type: 'string', value: 'mssql' });
+ expect(attributes?.[DB_NAMESPACE]).toEqual({ type: 'string', value: 'master' });
+ expect(attributes?.[DB_USER]).toEqual({ type: 'string', value: 'sa' });
+ expect(attributes?.[SERVER_ADDRESS]).toEqual({ type: 'string', value: '127.0.0.1' });
+ expect(attributes?.[SERVER_PORT]).toEqual({ type: 'integer', value: 1433 });
},
})
.start()
@@ -132,87 +160,4 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_
{ additionalDependencies: { tedious: '^20' } },
);
});
-
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createTestRunner, test) => {
- test('should name spans after the query summary with span streaming', async () => {
- await createTestRunner()
- .expect({
- span: container => {
- const dbSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN);
-
- expect(
- dbSpans.map(span => ({
- name: span.name,
- summary: span.attributes['db.query.summary']?.value,
- text: span.attributes['db.query.text']?.value,
- })),
- ).toEqual([
- { name: 'SELECT', summary: 'SELECT', text: 'SELECT ? + ? AS solution' },
- { name: 'SELECT', summary: 'SELECT', text: 'SELECT ?; SELECT ?' },
- { name: 'select', summary: 'select', text: 'select !' },
- {
- name: 'CREATE',
- summary: 'CREATE',
- text: 'CREATE OR ALTER PROCEDURE [dbo].[test_proced] @inputVal varchar(?), @outputCount int OUTPUT AS set @outputCount = LEN(@inputVal)',
- },
- { name: 'callProcedure [dbo].[test_proced]', summary: undefined, text: '[dbo].[test_proced]' },
- {
- name: 'if',
- summary: 'if',
- text: 'if object_id(?) is null CREATE TABLE [dbo].[test_prepared] (c1 int, c2 int)',
- },
- {
- name: 'INSERT [dbo].[test_prepared]',
- summary: 'INSERT [dbo].[test_prepared]',
- text: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)',
- },
- {
- name: 'INSERT [dbo].[test_prepared]',
- summary: 'INSERT [dbo].[test_prepared]',
- text: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)',
- },
- {
- name: 'if',
- summary: 'if',
- text: 'if object_id(?) is null CREATE TABLE [dbo].[test_bulk] (c1 int, c2 varchar(?))',
- },
- {
- name: 'insert',
- summary: 'insert',
- text: 'insert bulk test_bulk([c1] int, [c2] nvarchar(?)) WITH (KEEP_NULLS)',
- },
- { name: 'execBulkLoad test_bulk', summary: undefined, text: undefined },
- {
- name: 'SELECT [dbo].[test_prepared]',
- summary: 'SELECT [dbo].[test_prepared]',
- text: 'SELECT c1, c2 FROM [dbo].[test_prepared]',
- },
- {
- // TODO: (check if correct) Both sides of the join survive into the summary.
- name: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]',
- summary: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]',
- text: 'SELECT p.c1 FROM [dbo].[test_prepared] p INNER JOIN [dbo].[test_bulk] b ON p.c1 = b.c1',
- },
- {
- name: 'SELECT [dbo].[test_prepared]',
- summary: 'SELECT [dbo].[test_prepared]',
- text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = ?',
- },
- {
- name: 'SELECT [dbo].[test_prepared]',
- summary: 'SELECT [dbo].[test_prepared]',
- text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = @c1',
- },
- {
- name: 'SELECT [dbo].[test_bulk]',
- summary: 'SELECT [dbo].[test_bulk]',
- text: 'SELECT c1, c2 FROM [dbo].[test_bulk] WHERE c2 = ?',
- },
- ]);
- },
- })
- .start()
- .completed();
- });
- });
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs
deleted file mode 100644
index c0abcf114ae1..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-pii.mjs
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- // inputs and outputs are enabeld by default when opting into dataCollection
- dataCollection: {},
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs
deleted file mode 100644
index a305d0bd2dd8..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-error-in-tool.mjs
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { generateText } from 'ai';
-import { MockLanguageModelV1 } from 'ai/test';
-import { z } from 'zod';
-
-async function run() {
- Sentry.setTag('test-tag', 'test-value');
-
- await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
- try {
- await generateText({
- model: new MockLanguageModelV1({
- doGenerate: async () => ({
- rawCall: { rawPrompt: null, rawSettings: {} },
- finishReason: 'tool-calls',
- usage: { promptTokens: 15, completionTokens: 25 },
- text: 'Tool call completed!',
- toolCalls: [
- {
- toolCallType: 'function',
- toolCallId: 'call-1',
- toolName: 'getWeather',
- args: '{ "location": "San Francisco" }',
- },
- ],
- }),
- }),
- tools: {
- getWeather: {
- parameters: z.object({ location: z.string() }),
- execute: async () => {
- throw new Error('Error in tool');
- },
- },
- },
- prompt: 'What is the weather in San Francisco?',
- });
- } catch {
- // Expected error - we want the spans to still be flushed
- }
- });
-
- await Sentry.flush(2000);
-}
-
-run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs
deleted file mode 100644
index ef8cb19c3646..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario.mjs
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { generateText } from 'ai';
-import { MockLanguageModelV1 } from 'ai/test';
-import { z } from 'zod';
-
-async function run() {
- await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
- await generateText({
- model: new MockLanguageModelV1({
- doGenerate: async () => ({
- rawCall: { rawPrompt: null, rawSettings: {} },
- finishReason: 'stop',
- usage: { promptTokens: 10, completionTokens: 20 },
- text: 'First span here!',
- }),
- }),
- prompt: 'Where is the first span?',
- });
-
- // This span should have input and output prompts attached because telemetry is explicitly enabled.
- await generateText({
- experimental_telemetry: { isEnabled: true },
- model: new MockLanguageModelV1({
- doGenerate: async () => ({
- rawCall: { rawPrompt: null, rawSettings: {} },
- finishReason: 'stop',
- usage: { promptTokens: 10, completionTokens: 20 },
- text: 'Second span here!',
- }),
- }),
- prompt: 'Where is the second span?',
- });
-
- // This span should include tool calls and tool results
- await generateText({
- model: new MockLanguageModelV1({
- doGenerate: async () => ({
- rawCall: { rawPrompt: null, rawSettings: {} },
- finishReason: 'tool-calls',
- usage: { promptTokens: 15, completionTokens: 25 },
- text: 'Tool call completed!',
- toolCalls: [
- {
- toolCallType: 'function',
- toolCallId: 'call-1',
- toolName: 'getWeather',
- args: '{ "location": "San Francisco" }',
- },
- ],
- }),
- }),
- tools: {
- getWeather: {
- description: 'Get the current weather for a location',
- parameters: z.object({ location: z.string() }),
- execute: async args => {
- return `Weather in ${args.location}: Sunny, 72°F`;
- },
- },
- },
- prompt: 'What is the weather in San Francisco?',
- });
-
- // This span should not be captured because we've disabled telemetry
- await generateText({
- experimental_telemetry: { isEnabled: false },
- model: new MockLanguageModelV1({
- doGenerate: async () => ({
- rawCall: { rawPrompt: null, rawSettings: {} },
- finishReason: 'stop',
- usage: { promptTokens: 10, completionTokens: 20 },
- text: 'Third span here!',
- }),
- }),
- prompt: 'Where is the third span?',
- });
- });
-
- await Sentry.flush(2000);
-}
-
-run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts
deleted file mode 100644
index 50385f0e0c40..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import { afterAll, describe, expect } from 'vitest';
-import {
- GEN_AI_INPUT_MESSAGES,
- GEN_AI_OPERATION_NAME,
- GEN_AI_OUTPUT_MESSAGES,
- GEN_AI_REQUEST_MODEL,
- GEN_AI_RESPONSE_MODEL,
- GEN_AI_TOOL_CALL_ARGUMENTS,
- GEN_AI_TOOL_CALL_RESULT,
- GEN_AI_TOOL_DEFINITIONS,
- GEN_AI_TOOL_DESCRIPTION,
- GEN_AI_TOOL_NAME,
- GEN_AI_USAGE_INPUT_TOKENS,
- GEN_AI_USAGE_OUTPUT_TOKENS,
- GEN_AI_USAGE_TOTAL_TOKENS,
- SENTRY_OP,
- SENTRY_ORIGIN,
-} from '@sentry/conventions/attributes';
-import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
-import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
-
-/**
- * Helper to match a typed attribute value in a SerializedStreamedSpan.
- * Streamed span attributes are `{ value: X, type: Y }` objects, unlike transaction
- * span `data` which stores values directly.
- */
-function attr(value: unknown) {
- return expect.objectContaining({ value });
-}
-
-const expectedOrigin = 'auto.vercelai.channel';
-
-// The channel path serializes the SDK-parsed args object (compact).
-const toolCallArgs = '{\\"location\\":\\"San Francisco\\"}';
-
-describe('Vercel AI integration (streaming v4)', () => {
- afterAll(() => {
- cleanupChildProcesses();
- });
-
- const EXPECTED_SPANS_DEFAULT_PII_FALSE = {
- items: expect.arrayContaining([
- // First span - invoke_agent for simple generateText
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Second span - generate_content for simple generateText
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Third span - invoke_agent for explicit telemetry generateText
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Fourth span - tool call invoke_agent
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Fifth span - tool call generate_content
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Sixth span - execute_tool
- // Note: gen_ai.tool.description is NOT present when genAI recording disabled because ai.prompt.tools is not recorded
- expect.objectContaining({
- name: 'execute_tool getWeather',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'),
- [GEN_AI_TOOL_NAME]: attr('getWeather'),
- [GEN_AI_OPERATION_NAME]: attr('execute_tool'),
- [SENTRY_OP]: attr('gen_ai.execute_tool'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- ]),
- };
-
- const EXPECTED_SPANS_DEFAULT_PII_TRUE = {
- items: expect.arrayContaining([
- // First span - invoke_agent with input/output messages (PII enabled)
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: attr('[{"role":"user","content":"Where is the first span?"}]'),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Second span - generate_content with input/output messages
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: expect.objectContaining({ value: expect.any(String) }),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Third span - explicit telemetry invoke_agent with messages
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: attr('[{"role":"user","content":"Where is the second span?"}]'),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Fourth span - tool call invoke_agent with messages
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: attr('[{"role":"user","content":"What is the weather in San Francisco?"}]'),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- `[{"role":"assistant","parts":[{"type":"text","content":"Tool call completed!"},{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"${toolCallArgs}"}],"finish_reason":"tool_call"}]`,
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Fifth span - tool call generate_content with tool definitions
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_DEFINITIONS]: expect.objectContaining({
- value: expect.stringContaining('getWeather'),
- }),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- // Sixth span - execute_tool with description and input/output
- expect.objectContaining({
- name: 'execute_tool getWeather',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'),
- [GEN_AI_TOOL_DESCRIPTION]: attr('Get the current weather for a location'),
- [GEN_AI_TOOL_CALL_ARGUMENTS]: expect.objectContaining({ value: expect.any(String) }),
- [GEN_AI_TOOL_NAME]: attr('getWeather'),
- [GEN_AI_TOOL_CALL_RESULT]: expect.objectContaining({ value: expect.any(String) }),
- [GEN_AI_OPERATION_NAME]: attr('execute_tool'),
- [SENTRY_OP]: attr('gen_ai.execute_tool'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- ]),
- };
-
- const EXPECTED_SPANS_ERROR_IN_TOOL = {
- items: expect.arrayContaining([
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'error',
- attributes: expect.objectContaining({
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- expect.objectContaining({
- name: 'execute_tool getWeather',
- status: 'error',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'),
- [GEN_AI_TOOL_NAME]: attr('getWeather'),
- [GEN_AI_OPERATION_NAME]: attr('execute_tool'),
- [SENTRY_OP]: attr('gen_ai.execute_tool'),
- [SENTRY_ORIGIN]: attr(expectedOrigin),
- }),
- }),
- ]),
- };
-
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
- test('creates ai related spans in streaming mode with genAI recording disabled', async () => {
- await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_FALSE }).start().completed();
- });
- });
-
- createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
- test('creates ai related spans in streaming mode with genAI recording enabled', async () => {
- await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_TRUE }).start().completed();
- });
- });
-
- createEsmAndCjsTests(__dirname, 'scenario-error-in-tool.mjs', 'instrument.mjs', (createRunner, test) => {
- test('normalizes error status in streaming mode', async () => {
- await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed();
- });
- });
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs
deleted file mode 100644
index 657bed0a3a8c..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument-with-pii.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: true, outputs: true } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs
deleted file mode 100644
index 5e0b6fb5592f..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/instrument.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { loggingTransport } from '@sentry-internal/node-integration-tests';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- release: '1.0',
- tracesSampleRate: 1.0,
- dataCollection: { genAI: { inputs: false, outputs: false } },
- transport: loggingTransport,
- traceLifecycle: 'stream',
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs
deleted file mode 100644
index 51bdae176158..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-error-in-tool.mjs
+++ /dev/null
@@ -1,47 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { generateText, tool } from 'ai';
-import { MockLanguageModelV3 } from 'ai/test';
-import { z } from 'zod';
-
-async function run() {
- await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
- try {
- await generateText({
- model: new MockLanguageModelV3({
- doGenerate: async () => ({
- finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
- usage: {
- inputTokens: { total: 15, noCache: 15, cached: 0 },
- outputTokens: { total: 25, noCache: 25, cached: 0 },
- totalTokens: { total: 40, noCache: 40, cached: 0 },
- },
- content: [
- {
- type: 'tool-call',
- toolCallId: 'call-1',
- toolName: 'getWeather',
- input: JSON.stringify({ location: 'San Francisco' }),
- },
- ],
- warnings: [],
- }),
- }),
- tools: {
- getWeather: tool({
- inputSchema: z.object({ location: z.string() }),
- execute: async () => {
- throw new Error('Error in tool');
- },
- }),
- },
- prompt: 'What is the weather in San Francisco?',
- });
- } catch {
- // Expected error - we want the spans to still be flushed
- }
- });
-
- await Sentry.flush(2000);
-}
-
-run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs
deleted file mode 100644
index bf5e43a32e65..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario.mjs
+++ /dev/null
@@ -1,95 +0,0 @@
-import * as Sentry from '@sentry/node';
-import { generateText, tool } from 'ai';
-import { MockLanguageModelV3 } from 'ai/test';
-import { z } from 'zod';
-
-async function run() {
- await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
- await generateText({
- model: new MockLanguageModelV3({
- doGenerate: async () => ({
- finishReason: { unified: 'stop', raw: 'stop' },
- usage: {
- inputTokens: { total: 10, noCache: 10, cached: 0 },
- outputTokens: { total: 20, noCache: 20, cached: 0 },
- totalTokens: { total: 30, noCache: 30, cached: 0 },
- },
- content: [{ type: 'text', text: 'First span here!' }],
- warnings: [],
- }),
- }),
- prompt: 'Where is the first span?',
- });
-
- // This span should have input and output prompts attached because telemetry is explicitly enabled.
- await generateText({
- experimental_telemetry: { isEnabled: true },
- model: new MockLanguageModelV3({
- doGenerate: async () => ({
- finishReason: { unified: 'stop', raw: 'stop' },
- usage: {
- inputTokens: { total: 10, noCache: 10, cached: 0 },
- outputTokens: { total: 20, noCache: 20, cached: 0 },
- totalTokens: { total: 30, noCache: 30, cached: 0 },
- },
- content: [{ type: 'text', text: 'Second span here!' }],
- warnings: [],
- }),
- }),
- prompt: 'Where is the second span?',
- });
-
- // This span should include tool calls and tool results
- await generateText({
- model: new MockLanguageModelV3({
- doGenerate: async () => ({
- finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
- usage: {
- inputTokens: { total: 15, noCache: 15, cached: 0 },
- outputTokens: { total: 25, noCache: 25, cached: 0 },
- totalTokens: { total: 40, noCache: 40, cached: 0 },
- },
- content: [
- {
- type: 'tool-call',
- toolCallId: 'call-1',
- toolName: 'getWeather',
- input: JSON.stringify({ location: 'San Francisco' }),
- },
- ],
- warnings: [],
- }),
- }),
- tools: {
- getWeather: tool({
- description: 'Get the current weather for a location',
- inputSchema: z.object({ location: z.string() }),
- execute: async ({ location }) => `Weather in ${location}: Sunny, 72°F`,
- }),
- },
- prompt: 'What is the weather in San Francisco?',
- });
-
- // This span should not be captured because we've disabled telemetry
- await generateText({
- experimental_telemetry: { isEnabled: false },
- model: new MockLanguageModelV3({
- doGenerate: async () => ({
- finishReason: { unified: 'stop', raw: 'stop' },
- usage: {
- inputTokens: { total: 10, noCache: 10, cached: 0 },
- outputTokens: { total: 20, noCache: 20, cached: 0 },
- totalTokens: { total: 30, noCache: 30, cached: 0 },
- },
- content: [{ type: 'text', text: 'Third span here!' }],
- warnings: [],
- }),
- }),
- prompt: 'Where is the third span?',
- });
- });
-
- await Sentry.flush(2000);
-}
-
-run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts
deleted file mode 100644
index 68c88b131783..000000000000
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts
+++ /dev/null
@@ -1,366 +0,0 @@
-import { afterAll, describe, expect } from 'vitest';
-import {
- GEN_AI_INPUT_MESSAGES,
- GEN_AI_OPERATION_NAME,
- GEN_AI_OUTPUT_MESSAGES,
- GEN_AI_REQUEST_MODEL,
- GEN_AI_RESPONSE_MODEL,
- GEN_AI_TOOL_CALL_ARGUMENTS,
- GEN_AI_TOOL_CALL_RESULT,
- GEN_AI_TOOL_DEFINITIONS,
- GEN_AI_TOOL_DESCRIPTION,
- GEN_AI_TOOL_NAME,
- GEN_AI_USAGE_INPUT_TOKENS,
- GEN_AI_USAGE_OUTPUT_TOKENS,
- GEN_AI_USAGE_TOTAL_TOKENS,
- SENTRY_OP,
- SENTRY_ORIGIN,
-} from '@sentry/conventions/attributes';
-import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
-import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
-
-/**
- * Helper to match a typed attribute value in a SerializedStreamedSpan.
- * Streamed span attributes are `{ value: X, type: Y }` objects, unlike transaction
- * span `data` which stores values directly.
- */
-function attr(value: unknown) {
- return expect.objectContaining({ value });
-}
-
-describe('Vercel AI integration (streaming, v6)', () => {
- afterAll(() => {
- cleanupChildProcesses();
- });
-
- const origin = 'auto.vercelai.channel';
-
- const EXPECTED_SPANS_DEFAULT_PII_FALSE = {
- items: expect.arrayContaining([
- // First span - invoke_agent for simple generateText
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Second span - generate_content for simple generateText
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Third span - invoke_agent for explicit telemetry generateText
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Fourth span - tool call invoke_agent
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Fifth span - tool call generate_content
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Sixth span - execute_tool
- // Note: gen_ai.tool.description is NOT present when genAI recording disabled because ai.prompt.tools is not recorded
- expect.objectContaining({
- name: 'execute_tool getWeather',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'),
- [GEN_AI_TOOL_NAME]: attr('getWeather'),
- [GEN_AI_OPERATION_NAME]: attr('execute_tool'),
- [SENTRY_OP]: attr('gen_ai.execute_tool'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- ]),
- };
-
- const EXPECTED_SPANS_DEFAULT_PII_TRUE = {
- items: expect.arrayContaining([
- // First span - invoke_agent with input/output messages (PII enabled)
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: attr('[{"role":"user","content":"Where is the first span?"}]'),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Second span - generate_content with input/output messages
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: expect.objectContaining({ value: expect.any(String) }),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_RESPONSE_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Third span - explicit telemetry invoke_agent with messages
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: attr('[{"role":"user","content":"Where is the second span?"}]'),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(10),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(20),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(30),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Fourth span - tool call invoke_agent with messages (V6: no text part, only tool_call)
- expect.objectContaining({
- name: 'invoke_agent',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_INPUT_MESSAGES]: attr('[{"role":"user","content":"What is the weather in San Francisco?"}]'),
- [GEN_AI_OUTPUT_MESSAGES]: attr(
- '[{"role":"assistant","parts":[{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"{\\"location\\":\\"San Francisco\\"}"}],"finish_reason":"tool_call"}]',
- ),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Fifth span - tool call generate_content with tool definitions
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_DEFINITIONS]: expect.objectContaining({
- value: expect.stringContaining('getWeather'),
- }),
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- // Sixth span - execute_tool with description and input/output
- expect.objectContaining({
- name: 'execute_tool getWeather',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'),
- [GEN_AI_TOOL_DESCRIPTION]: attr('Get the current weather for a location'),
- [GEN_AI_TOOL_CALL_ARGUMENTS]: expect.objectContaining({ value: expect.any(String) }),
- [GEN_AI_TOOL_NAME]: attr('getWeather'),
- [GEN_AI_TOOL_CALL_RESULT]: expect.objectContaining({ value: expect.any(String) }),
- [GEN_AI_OPERATION_NAME]: attr('execute_tool'),
- [SENTRY_OP]: attr('gen_ai.execute_tool'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- ]),
- };
-
- const EXPECTED_SPANS_ERROR_IN_TOOL = {
- items: expect.arrayContaining([
- expect.objectContaining({
- name: 'invoke_agent',
- attributes: expect.objectContaining({
- [GEN_AI_OPERATION_NAME]: attr('invoke_agent'),
- [SENTRY_OP]: attr('gen_ai.invoke_agent'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- expect.objectContaining({
- name: 'generate_content mock-model-id',
- status: 'ok',
- attributes: expect.objectContaining({
- [GEN_AI_REQUEST_MODEL]: attr('mock-model-id'),
- [GEN_AI_USAGE_INPUT_TOKENS]: attr(15),
- [GEN_AI_USAGE_OUTPUT_TOKENS]: attr(25),
- [GEN_AI_USAGE_TOTAL_TOKENS]: attr(40),
- [GEN_AI_OPERATION_NAME]: attr('generate_content'),
- [SENTRY_OP]: attr('gen_ai.generate_content'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- expect.objectContaining({
- name: 'execute_tool getWeather',
- status: 'error',
- attributes: expect.objectContaining({
- [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'),
- [GEN_AI_TOOL_NAME]: attr('getWeather'),
- [GEN_AI_OPERATION_NAME]: attr('execute_tool'),
- [SENTRY_OP]: attr('gen_ai.execute_tool'),
- [SENTRY_ORIGIN]: attr(origin),
- }),
- }),
- ]),
- };
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument.mjs',
- (createRunner, test) => {
- test('creates ai related spans in streaming mode with genAI recording disabled', async () => {
- await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_FALSE }).start().completed();
- });
- },
- {
- additionalDependencies: {
- ai: '^6.0.0',
- },
- },
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario.mjs',
- 'instrument-with-pii.mjs',
- (createRunner, test) => {
- test('creates ai related spans in streaming mode with genAI recording enabled', async () => {
- await createRunner().expect({ span: EXPECTED_SPANS_DEFAULT_PII_TRUE }).start().completed();
- });
- },
- {
- additionalDependencies: {
- ai: '^6.0.0',
- },
- },
- );
-
- function spanWithMetadata(name: string, op: string, requestId: string, extra: Record = {}) {
- return expect.objectContaining({
- name,
- attributes: expect.objectContaining({
- 'vercel.ai.telemetry.metadata.requestId': attr(requestId),
- 'vercel.ai.telemetry.metadata.tenantId': attr('acme'),
- ...extra,
- [SENTRY_OP]: attr(op),
- }),
- });
- }
-
- const EXPECTED_SPANS_TELEMETRY_METADATA = {
- items: expect.arrayContaining([
- spanWithMetadata('invoke_agent support-chat', 'gen_ai.invoke_agent', 'req_generate', {
- 'vercel.ai.telemetry.metadata.attempt': { type: 'string', value: '2' },
- }),
- spanWithMetadata('generate_content mock-model-id', 'gen_ai.generate_content', 'req_generate', {
- 'vercel.ai.telemetry.metadata.attempt': { type: 'string', value: '2' },
- }),
- spanWithMetadata('invoke_agent support-stream', 'gen_ai.invoke_agent', 'req_stream'),
- spanWithMetadata('generate_content mock-model-id', 'gen_ai.generate_content', 'req_stream'),
- spanWithMetadata('embeddings mock-model-id', 'gen_ai.embeddings', 'req_embed'),
- spanWithMetadata('embeddings mock-model-id', 'gen_ai.embeddings', 'req_embed_many'),
- ]),
- };
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-telemetry-metadata.mjs',
- 'instrument.mjs',
- (createRunner, test) => {
- test('records experimental_telemetry.metadata as span attributes', async () => {
- await createRunner().expect({ span: EXPECTED_SPANS_TELEMETRY_METADATA }).start().completed();
- });
- },
- {
- additionalDependencies: {
- ai: '^6.0.0',
- },
- },
- );
-
- createEsmAndCjsTests(
- __dirname,
- 'scenario-error-in-tool.mjs',
- 'instrument.mjs',
- (createRunner, test) => {
- test('normalizes error status in streaming mode', async () => {
- await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed();
- });
- },
- {
- additionalDependencies: {
- ai: '^6.0.0',
- },
- },
- );
-});
diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/instrument-with-pii.mjs
similarity index 83%
rename from dev-packages/node-integration-tests/suites/tracing/amqplib/instrument-span-streaming.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/instrument-with-pii.mjs
index 54c513068360..43ea2c5b8d86 100644
--- a/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument-span-streaming.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/instrument-with-pii.mjs
@@ -2,9 +2,10 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'stream',
+ traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
+ dataCollection: {},
transport: loggingTransport,
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/instrument.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/instrument.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-conversation-id.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-conversation-id.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-conversation-id.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-embeddings.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-embeddings.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-embeddings.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-error-in-tool-express.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-error-in-tool-express.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-error-in-tool.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-error-in-tool.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-generate-object.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-generate-object.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-stream-text.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-stream-text.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-stream-text.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-stream-text.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-system-instructions.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-system-instructions.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-system-instructions.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario-system-instructions.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/scenario.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts
similarity index 57%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts
index e6ec70fa35ff..99604cb8a599 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v4-static/test.ts
@@ -1,8 +1,10 @@
import type { Event } from '@sentry/node';
import { afterAll, describe, expect } from 'vitest';
import {
+ GEN_AI_CONVERSATION_ID,
GEN_AI_EMBEDDINGS_INPUT,
GEN_AI_INPUT_MESSAGES,
+ GEN_AI_OPERATION_NAME,
GEN_AI_OUTPUT_MESSAGES,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MODEL,
@@ -16,12 +18,15 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
-import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
-import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
-import { getStringAttributeValue } from '../../../utils';
+import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+import { getStringAttributeValue } from '../../../../utils';
const expectedOrigin = 'auto.vercelai.channel';
+const toolCallArgs = '{\\"location\\":\\"San Francisco\\"}';
describe('Vercel AI integration (v4)', () => {
afterAll(() => {
@@ -42,17 +47,17 @@ describe('Vercel AI integration (v4)', () => {
span.attributes[GEN_AI_INPUT_MESSAGES] === undefined &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS].value === 10,
);
- expect(firstInvokeAgentSpan).toBeDefined();
- expect(firstInvokeAgentSpan!.name).toBe('invoke_agent');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
expect(firstInvokeAgentSpan!.status).toBe('ok');
- expect(firstInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(firstInvokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(firstInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('mock-model-id');
expect(firstInvokeAgentSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('mock-model-id');
- expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(20);
expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(30);
- expect(firstInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
const firstGenerateContentSpan = container.items.find(
span =>
@@ -61,16 +66,33 @@ describe('Vercel AI integration (v4)', () => {
span.attributes[GEN_AI_INPUT_MESSAGES] === undefined &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS].value === 10,
);
- expect(firstGenerateContentSpan).toBeDefined();
- expect(firstGenerateContentSpan!.name).toBe('generate_content mock-model-id');
+ expect(firstGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(firstGenerateContentSpan!.status).toBe('ok');
- expect(firstGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(firstGenerateContentSpan!.attributes['vercel.ai.operationId'].value).toBe(
- 'ai.generateText.doGenerate',
- );
+ expect(firstGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(firstGenerateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('mock-provider');
- expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(firstGenerateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
const secondInvokeAgentSpan = container.items.find(
span =>
@@ -78,13 +100,29 @@ describe('Vercel AI integration (v4)', () => {
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the second span?"}]',
);
- expect(secondInvokeAgentSpan).toBeDefined();
- expect(secondInvokeAgentSpan!.name).toBe('invoke_agent');
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(secondInvokeAgentSpan!.status).toBe('ok');
- expect(secondInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(secondInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"Where is the second span?"}]',
- );
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(secondInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe(
'[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
);
@@ -94,21 +132,25 @@ describe('Vercel AI integration (v4)', () => {
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value)?.includes('Second span here!'),
);
- expect(secondGenerateContentSpan).toBeDefined();
- expect(secondGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(secondGenerateContentSpan!.status).toBe('ok');
- expect(secondGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(secondGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(secondGenerateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
expect(secondGenerateContentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toContain('Second span here!');
const toolInvokeAgentSpan = container.items.find(
span => span.name === 'invoke_agent' && span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 15,
);
- expect(toolInvokeAgentSpan).toBeDefined();
- expect(toolInvokeAgentSpan!.name).toBe('invoke_agent');
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolInvokeAgentSpan!.status).toBe('ok');
- expect(toolInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(25);
expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(40);
@@ -117,17 +159,37 @@ describe('Vercel AI integration (v4)', () => {
span.name === 'generate_content mock-model-id' &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 15,
);
- expect(toolGenerateContentSpan).toBeDefined();
- expect(toolGenerateContentSpan!.name).toBe('generate_content mock-model-id');
+ expect(toolGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolGenerateContentSpan!.status).toBe('ok');
- expect(toolGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
+ expect(toolGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
const toolExecutionSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolExecutionSpan).toBeDefined();
- expect(toolExecutionSpan!.name).toBe('execute_tool getWeather');
+ expect(toolExecutionSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolExecutionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'execute_tool',
+ });
expect(toolExecutionSpan!.status).toBe('ok');
- expect(toolExecutionSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
+ expect(toolExecutionSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE].value).toBe('call-1');
},
@@ -150,14 +212,28 @@ describe('Vercel AI integration (v4)', () => {
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the first span?"}]',
);
- expect(firstInvokeAgentSpan).toBeDefined();
- expect(firstInvokeAgentSpan!.name).toBe('invoke_agent');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 30 });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 10 });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(firstInvokeAgentSpan!.status).toBe('ok');
- expect(firstInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(firstInvokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText');
- expect(firstInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"Where is the first span?"}]',
- );
expect(firstInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe(
'[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
);
@@ -167,15 +243,45 @@ describe('Vercel AI integration (v4)', () => {
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value)?.includes('First span here!'),
);
- expect(firstGenerateContentSpan).toBeDefined();
- expect(firstGenerateContentSpan!.name).toBe('generate_content mock-model-id');
+ expect(firstGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(firstGenerateContentSpan!.status).toBe('ok');
- expect(firstGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(firstGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(firstGenerateContentSpan!.attributes['vercel.ai.operationId'].value).toBe(
'ai.generateText.doGenerate',
);
- expect(firstGenerateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(firstGenerateContentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toContain('First span here!');
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toEqual(expect.any(String));
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value:
+ '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
+ });
const secondInvokeAgentSpan = container.items.find(
span =>
@@ -183,23 +289,42 @@ describe('Vercel AI integration (v4)', () => {
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the second span?"}]',
);
- expect(secondInvokeAgentSpan).toBeDefined();
- expect(secondInvokeAgentSpan!.name).toBe('invoke_agent');
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value:
+ '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
+ });
expect(secondInvokeAgentSpan!.status).toBe('ok');
- expect(secondInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(secondInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"Where is the second span?"}]',
- );
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
const secondGenerateContentSpan = container.items.find(
span =>
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value)?.includes('Second span here!'),
);
- expect(secondGenerateContentSpan).toBeDefined();
- expect(secondGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(secondGenerateContentSpan!.status).toBe('ok');
- expect(secondGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(secondGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
const toolInvokeAgentSpan = container.items.find(
span =>
@@ -207,38 +332,76 @@ describe('Vercel AI integration (v4)', () => {
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"What is the weather in San Francisco?"}]',
);
- expect(toolInvokeAgentSpan).toBeDefined();
- expect(toolInvokeAgentSpan!.name).toBe('invoke_agent');
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 40 });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ type: 'integer', value: 25 });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 15 });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolInvokeAgentSpan!.status).toBe('ok');
- expect(toolInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(toolInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"What is the weather in San Francisco?"}]',
- );
- expect(toolInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeDefined();
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: `[{"role":"assistant","parts":[{"type":"text","content":"Tool call completed!"},{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"${toolCallArgs}"}],"finish_reason":"tool_call"}]`,
+ });
const toolGenerateContentSpan = container.items.find(
span =>
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_TOOL_DEFINITIONS]?.value)?.includes('getWeather'),
);
- expect(toolGenerateContentSpan).toBeDefined();
- expect(toolGenerateContentSpan!.name).toBe('generate_content mock-model-id');
+ expect(toolGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolGenerateContentSpan!.status).toBe('ok');
- expect(toolGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(toolGenerateContentSpan!.attributes[GEN_AI_TOOL_DEFINITIONS].value).toContain('getWeather');
+ expect(toolGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_TOOL_DEFINITIONS]).toEqual({
+ type: 'string',
+ value: expect.stringContaining('getWeather'),
+ });
expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
const toolExecutionSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolExecutionSpan).toBeDefined();
- expect(toolExecutionSpan!.name).toBe('execute_tool getWeather');
+ expect(toolExecutionSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolExecutionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'execute_tool',
+ });
+ expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]).toEqual({
+ type: 'string',
+ value: 'call-1',
+ });
expect(toolExecutionSpan!.status).toBe('ok');
- expect(toolExecutionSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
+ expect(toolExecutionSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_DESCRIPTION].value).toBe(
'Get the current weather for a location',
);
- expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]).toBeDefined();
- expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_RESULT]).toBeDefined();
+ expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toEqual(expect.any(String));
+ expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toEqual(expect.any(String));
},
})
.start()
@@ -266,25 +429,37 @@ describe('Vercel AI integration (v4)', () => {
const invokeAgentSpan = container.items.find(
span => span.name === 'invoke_agent' && span.status === 'error',
);
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan!.name).toBe('invoke_agent');
- expect(invokeAgentSpan!.status).toBe('error');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(invokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(invokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText');
const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.name).toBe('generate_content mock-model-id');
+ expect(generateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({ type: 'integer', value: 40 });
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({ type: 'integer', value: 25 });
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({ type: 'integer', value: 15 });
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(generateContentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText.doGenerate');
const toolSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolSpan).toBeDefined();
- expect(toolSpan!.name).toBe('execute_tool getWeather');
+ expect(toolSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'execute_tool' });
+ expect(toolSpan!.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]).toEqual({ type: 'string', value: 'call-1' });
expect(toolSpan!.status).toBe('error');
- expect(toolSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
- expect(toolSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
+ expect(toolSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
+ expect(toolSpan!.attributes[SENTRY_ORIGIN].value).toBe(expectedOrigin);
expect(toolSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
},
})
@@ -296,10 +471,8 @@ describe('Vercel AI integration (v4)', () => {
.start()
.completed();
- expect(transactionEvent).toBeDefined();
expect(transactionEvent!.transaction).toBe('main');
- expect(errorEvent).toBeDefined();
expect(errorEvent!.tags).toMatchObject({ 'test-tag': 'test-value' });
// The tool error bubbles out of the `ai` call as the SDK's wrapped `AI_ToolExecutionError`. The
@@ -339,23 +512,16 @@ describe('Vercel AI integration (v4)', () => {
const invokeAgentSpan = container.items.find(
span => span.name === 'invoke_agent' && span.status === 'error',
);
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan!.name).toBe('invoke_agent');
- expect(invokeAgentSpan!.status).toBe('error');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(invokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText');
const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.name).toBe('generate_content mock-model-id');
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
const toolSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolSpan).toBeDefined();
- expect(toolSpan!.name).toBe('execute_tool getWeather');
expect(toolSpan!.status).toBe('error');
- expect(toolSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
+ expect(toolSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
expect(toolSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
},
})
@@ -369,11 +535,9 @@ describe('Vercel AI integration (v4)', () => {
await runner.makeRequest('get', '/test/error-in-tool', { expectError: true });
await runner.completed();
- expect(transactionEvent).toBeDefined();
expect(transactionEvent!.transaction).toBe('GET /test/error-in-tool');
expect(transactionEvent!.tags).toMatchObject({ 'test-tag': 'test-value' });
- expect(errorEvent).toBeDefined();
expect(errorEvent!.tags).toMatchObject({ 'test-tag': 'test-value' });
expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id);
@@ -411,17 +575,13 @@ describe('Vercel AI integration (v4)', () => {
span: container => {
expect(container.items).toHaveLength(2);
const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent');
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan!.name).toBe('invoke_agent');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(invokeAgentSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe(
JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]),
);
const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.name).toBe('generate_content mock-model-id');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
},
})
.start()
@@ -438,21 +598,17 @@ describe('Vercel AI integration (v4)', () => {
span: container => {
expect(container.items).toHaveLength(2);
const embedSpan = container.items.find(span => span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 10);
- expect(embedSpan).toBeDefined();
expect(embedSpan!.name).toBe('embeddings mock-model-id');
expect(embedSpan!.status).toBe('ok');
- expect(embedSpan!.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
+ expect(embedSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
expect(embedSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('mock-model-id');
- expect(embedSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
const embedManySpan = container.items.find(
span => span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 20,
);
- expect(embedManySpan).toBeDefined();
expect(embedManySpan!.name).toBe('embeddings mock-model-id');
expect(embedManySpan!.status).toBe('ok');
- expect(embedManySpan!.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
- expect(embedManySpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(20);
+ expect(embedManySpan!.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
},
})
.start()
@@ -470,20 +626,16 @@ describe('Vercel AI integration (v4)', () => {
const embedSpan = container.items.find(
span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!',
);
- expect(embedSpan).toBeDefined();
expect(embedSpan!.name).toBe('embeddings mock-model-id');
expect(embedSpan!.status).toBe('ok');
- expect(embedSpan!.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
- expect(embedSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT].value).toBe('Embedding test!');
+ expect(embedSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
const embedManySpan = container.items.find(
span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === '["First input","Second input"]',
);
- expect(embedManySpan).toBeDefined();
expect(embedManySpan!.name).toBe('embeddings mock-model-id');
expect(embedManySpan!.status).toBe('ok');
- expect(embedManySpan!.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
- expect(embedManySpan!.attributes[GEN_AI_EMBEDDINGS_INPUT].value).toBe('["First input","Second input"]');
+ expect(embedManySpan!.attributes[SENTRY_OP].value).toBe('gen_ai.embeddings');
},
})
.start()
@@ -499,16 +651,12 @@ describe('Vercel AI integration (v4)', () => {
span: container => {
expect(container.items).toHaveLength(2);
const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent');
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan!.name).toBe('invoke_agent');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(invokeAgentSpan!.attributes['gen_ai.conversation.id'].value).toBe('conv-a');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[GEN_AI_CONVERSATION_ID].value).toBe('conv-a');
const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.name).toBe('generate_content mock-model-id');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(generateContentSpan!.attributes['gen_ai.conversation.id'].value).toBe('conv-a');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[GEN_AI_CONVERSATION_ID].value).toBe('conv-a');
},
})
.start()
@@ -525,10 +673,9 @@ describe('Vercel AI integration (v4)', () => {
expect(container.items).toHaveLength(2);
const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent');
- expect(invokeAgentSpan).toBeDefined();
expect(invokeAgentSpan!.status).toBe('ok');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(invokeAgentSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN].value).toBe(expectedOrigin);
expect(invokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.streamText');
expect(invokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('mock-model-id');
// Aggregated over the drained stream: v4 reports `promptTokens`/`completionTokens`, which the
@@ -539,10 +686,9 @@ describe('Vercel AI integration (v4)', () => {
expect(invokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toContain('Stream response!');
const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(generateContentSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_ORIGIN].value).toBe(expectedOrigin);
expect(generateContentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.streamText.doStream');
},
})
@@ -561,31 +707,29 @@ describe('Vercel AI integration (v4)', () => {
// generateObject (invoke_agent)
const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent');
- expect(invokeAgentSpan).toBeDefined();
expect(invokeAgentSpan!.status).toBe('ok');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(invokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateObject');
- expect(invokeAgentSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
- expect(invokeAgentSpan!.attributes['gen_ai.operation.name'].value).toBe('invoke_agent');
- expect(invokeAgentSpan!.attributes['gen_ai.response.model'].value).toBe('mock-model-id');
- expect(invokeAgentSpan!.attributes['gen_ai.usage.input_tokens'].value).toBe(15);
- expect(invokeAgentSpan!.attributes['gen_ai.usage.output_tokens'].value).toBe(25);
- expect(invokeAgentSpan!.attributes['gen_ai.usage.total_tokens'].value).toBe(40);
+ expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN].value).toBe(expectedOrigin);
+ expect(invokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent');
+ expect(invokeAgentSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('mock-model-id');
+ expect(invokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
+ expect(invokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(25);
+ expect(invokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(40);
// generateObject.doGenerate (generate_content)
const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(generateContentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateObject.doGenerate');
- expect(generateContentSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
- expect(generateContentSpan!.attributes['gen_ai.operation.name'].value).toBe('generate_content');
- expect(generateContentSpan!.attributes['gen_ai.provider.name'].value).toBe('mock-provider');
- expect(generateContentSpan!.attributes['gen_ai.request.model'].value).toBe('mock-model-id');
- expect(generateContentSpan!.attributes['gen_ai.response.model'].value).toBe('mock-model-id');
- expect(generateContentSpan!.attributes['gen_ai.usage.input_tokens'].value).toBe(15);
- expect(generateContentSpan!.attributes['gen_ai.usage.output_tokens'].value).toBe(25);
- expect(generateContentSpan!.attributes['gen_ai.usage.total_tokens'].value).toBe(40);
+ expect(generateContentSpan!.attributes[SENTRY_ORIGIN].value).toBe(expectedOrigin);
+ expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content');
+ expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('mock-provider');
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('mock-model-id');
+ expect(generateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('mock-model-id');
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(25);
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(40);
},
})
.start()
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs
index 7ca11a34673e..7a021162097c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs
index 42052d281304..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts
index 00430e451229..b608a1f717fc 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts
@@ -1,3 +1,4 @@
+import type { SerializedStreamedSpanContainer } from '@sentry/core';
import type { Event } from '@sentry/node';
import { afterAll, describe, expect } from 'vitest';
import {
@@ -14,13 +15,13 @@ import {
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
import { getStringAttributeValue } from '../../../../utils';
-const expectedOrigin = 'auto.vercelai.channel';
-
describe('Vercel AI integration (v5)', () => {
afterAll(() => {
cleanupChildProcesses();
@@ -33,99 +34,76 @@ describe('Vercel AI integration (v5)', () => {
(createRunner, test) => {
test('creates ai spans when dataCollection.genAi has inputs and outputs disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(7);
- const firstInvokeAgentSpan = container.items.find(
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ expect(spans).toHaveLength(7);
+ const firstInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes['vercel.ai.operationId'].value === 'ai.generateText' &&
span.attributes[GEN_AI_INPUT_MESSAGES] === undefined &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS].value === 10,
);
- expect(firstInvokeAgentSpan).toBeDefined();
- expect(firstInvokeAgentSpan!.name).toBe('invoke_agent');
expect(firstInvokeAgentSpan!.status).toBe('ok');
- expect(firstInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(firstInvokeAgentSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
- expect(firstInvokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(firstInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('mock-model-id');
expect(firstInvokeAgentSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('mock-model-id');
- expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(20);
expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(30);
- expect(firstInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- const firstGenerateContentSpan = container.items.find(
+ const firstGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes['vercel.ai.operationId'].value === 'ai.generateText.doGenerate' &&
span.attributes[GEN_AI_INPUT_MESSAGES] === undefined &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS].value === 10,
);
- expect(firstGenerateContentSpan).toBeDefined();
- expect(firstGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(firstGenerateContentSpan!.status).toBe('ok');
- expect(firstGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
- expect(firstGenerateContentSpan!.attributes['vercel.ai.operationId'].value).toBe(
- 'ai.generateText.doGenerate',
- );
+ expect(firstGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(firstGenerateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('mock-provider');
- expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
- expect(firstGenerateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- const secondInvokeAgentSpan = container.items.find(
+ const secondInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the second span?"}]',
);
- expect(secondInvokeAgentSpan).toBeDefined();
- expect(secondInvokeAgentSpan!.name).toBe('invoke_agent');
expect(secondInvokeAgentSpan!.status).toBe('ok');
- expect(secondInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(secondInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"Where is the second span?"}]',
- );
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(secondInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe(
'[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
);
- const secondGenerateContentSpan = container.items.find(
+ const secondGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value)?.includes(
'Second span here!',
),
);
- expect(secondGenerateContentSpan).toBeDefined();
- expect(secondGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(secondGenerateContentSpan!.status).toBe('ok');
- expect(secondGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(secondGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
- const toolInvokeAgentSpan = container.items.find(
+ const toolInvokeAgentSpan = spans.find(
span => span.name === 'invoke_agent' && span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 15,
);
- expect(toolInvokeAgentSpan).toBeDefined();
- expect(toolInvokeAgentSpan!.name).toBe('invoke_agent');
expect(toolInvokeAgentSpan!.status).toBe('ok');
- const toolGenerateContentSpan = container.items.find(
+ const toolGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 15,
);
- expect(toolGenerateContentSpan).toBeDefined();
- expect(toolGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(toolGenerateContentSpan!.status).toBe('ok');
- const toolExecutionSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolExecutionSpan).toBeDefined();
- expect(toolExecutionSpan!.name).toBe('execute_tool getWeather');
+ const toolExecutionSpan = spans.find(span => span.name === 'execute_tool getWeather');
expect(toolExecutionSpan!.status).toBe('ok');
- expect(toolExecutionSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
- expect(toolExecutionSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
+ expect(toolExecutionSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE].value).toBe('call-1');
},
@@ -148,99 +126,80 @@ describe('Vercel AI integration (v5)', () => {
(createRunner, test) => {
test('creates ai spans for dataCollection defaults', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- expect(container.items).toHaveLength(7);
- const firstInvokeAgentSpan = container.items.find(
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ expect(spans).toHaveLength(7);
+ const firstInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the first span?"}]',
);
- expect(firstInvokeAgentSpan).toBeDefined();
- expect(firstInvokeAgentSpan!.name).toBe('invoke_agent');
expect(firstInvokeAgentSpan!.status).toBe('ok');
- expect(firstInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
expect(firstInvokeAgentSpan!.attributes['vercel.ai.operationId'].value).toBe('ai.generateText');
- expect(firstInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"Where is the first span?"}]',
- );
expect(firstInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe(
'[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
);
- const firstGenerateContentSpan = container.items.find(
+ const firstGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value)?.includes('First span here!'),
);
- expect(firstGenerateContentSpan).toBeDefined();
- expect(firstGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(firstGenerateContentSpan!.status).toBe('ok');
- expect(firstGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(firstGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(firstGenerateContentSpan!.attributes['vercel.ai.operationId'].value).toBe(
'ai.generateText.doGenerate',
);
expect(firstGenerateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
expect(firstGenerateContentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toContain('First span here!');
- const secondInvokeAgentSpan = container.items.find(
+ const secondInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the second span?"}]',
);
- expect(secondInvokeAgentSpan).toBeDefined();
- expect(secondInvokeAgentSpan!.name).toBe('invoke_agent');
expect(secondInvokeAgentSpan!.status).toBe('ok');
- expect(secondInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
- expect(secondInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"Where is the second span?"}]',
- );
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
- const secondGenerateContentSpan = container.items.find(
+ const secondGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
getStringAttributeValue(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value)?.includes(
'Second span here!',
),
);
- expect(secondGenerateContentSpan).toBeDefined();
- expect(secondGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(secondGenerateContentSpan!.status).toBe('ok');
- expect(secondGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(secondGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
- const toolInvokeAgentSpan = container.items.find(
+ const toolInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"What is the weather in San Francisco?"}]',
);
- expect(toolInvokeAgentSpan).toBeDefined();
- expect(toolInvokeAgentSpan!.name).toBe('invoke_agent');
expect(toolInvokeAgentSpan!.status).toBe('ok');
- expect(toolInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(
- '[{"role":"user","content":"What is the weather in San Francisco?"}]',
- );
- const toolGenerateContentSpan = container.items.find(
+ const toolGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes[GEN_AI_TOOL_DEFINITIONS] !== undefined,
);
- expect(toolGenerateContentSpan).toBeDefined();
- expect(toolGenerateContentSpan!.name).toBe('generate_content mock-model-id');
expect(toolGenerateContentSpan!.status).toBe('ok');
- expect(toolGenerateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
+ expect(toolGenerateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
expect(toolGenerateContentSpan!.attributes[GEN_AI_TOOL_DEFINITIONS]).toBeDefined();
expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15);
- const toolExecutionSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolExecutionSpan).toBeDefined();
- expect(toolExecutionSpan!.name).toBe('execute_tool getWeather');
+ const toolExecutionSpan = spans.find(span => span.name === 'execute_tool getWeather');
expect(toolExecutionSpan!.status).toBe('ok');
- expect(toolExecutionSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
+ expect(toolExecutionSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_DESCRIPTION].value).toBe(
'Get the current weather for a location',
@@ -266,39 +225,17 @@ describe('Vercel AI integration (v5)', () => {
'instrument.mjs',
(createRunner, test) => {
test('captures error in tool', async () => {
- let transactionEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
let errorEvent: Event | undefined;
await createRunner()
// The tool error is captured while the tool is running (mid-transaction), so the error event
- // and the transaction/span envelopes can arrive in either order — assert content, not wire order.
+ // and the span containers can arrive in either order — assert content, not wire order.
.unordered()
- .expect({
- transaction: transaction => {
- transactionEvent = transaction;
- },
- })
.expect({
span: container => {
- expect(container.items).toHaveLength(3);
- const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent');
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan!.name).toBe('invoke_agent');
- expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent');
-
- const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan!.name).toBe('generate_content mock-model-id');
- expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content');
-
- const toolSpan = container.items.find(span => span.name === 'execute_tool getWeather');
- expect(toolSpan).toBeDefined();
- expect(toolSpan!.name).toBe('execute_tool getWeather');
- expect(toolSpan!.status).toBe('error');
- expect(toolSpan!.attributes['sentry.op'].value).toBe('gen_ai.execute_tool');
- expect(toolSpan!.attributes['sentry.origin'].value).toBe(expectedOrigin);
- expect(toolSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.expect({
@@ -309,20 +246,29 @@ describe('Vercel AI integration (v5)', () => {
.start()
.completed();
- expect(transactionEvent).toBeDefined();
- expect(transactionEvent!.transaction).toBe('main');
+ const segment = receivedSpans.find(span => span.is_segment && span.name === 'main');
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel');
+ expect(spans).toHaveLength(3);
+ const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.invoke_agent');
+
+ const generateContentSpan = spans.find(span => span.name === 'generate_content mock-model-id');
+ expect(generateContentSpan!.status).toBe('ok');
+ expect(generateContentSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.generate_content');
+
+ const toolSpan = spans.find(span => span.name === 'execute_tool getWeather');
+ expect(toolSpan!.status).toBe('error');
+ expect(toolSpan!.attributes[SENTRY_OP].value).toBe('gen_ai.execute_tool');
+ expect(toolSpan!.attributes[GEN_AI_TOOL_NAME].value).toBe('getWeather');
- expect(errorEvent).toBeDefined();
expect(errorEvent!.level).toBe('error');
- expect(errorEvent!.tags).toEqual(
- expect.objectContaining({
- 'vercel.ai.tool.name': 'getWeather',
- 'vercel.ai.tool.callId': 'call-1',
- }),
- );
+ expect(errorEvent!.tags).toEqual({
+ 'vercel.ai.tool.name': 'getWeather',
+ 'vercel.ai.tool.callId': 'call-1',
+ });
- // Trace id should be the same for the transaction and error event
- expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id);
+ // Trace id should be the same for the segment and error event
+ expect(segment!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id);
});
},
{
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-with-pii.mjs
index 7ca11a34673e..7a021162097c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-with-pii.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-with-pii.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument.mjs
index 42052d281304..cc192fb89834 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument.mjs
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument.mjs
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
Sentry.init({
- traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-telemetry-metadata.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-telemetry-metadata.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/scenario-telemetry-metadata.mjs
rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-telemetry-metadata.mjs
diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts
index 805a65313a66..893cea265ebb 100644
--- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts
@@ -1,3 +1,4 @@
+import type { SerializedStreamedSpanContainer } from '@sentry/core';
import type { Event } from '@sentry/node';
import { afterAll, describe, expect } from 'vitest';
import {
@@ -22,6 +23,8 @@ import {
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
GEN_AI_USAGE_TOTAL_TOKENS,
+ SENTRY_OP,
+ SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes';
import { cleanupChildProcesses, createEsmAndCjsTests, createEsmTests } from '../../../../utils/runner';
@@ -47,104 +50,239 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('creates ai spans for dataCollection defaults', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- expect(container.items).toHaveLength(7);
- const firstInvokeAgentSpan = container.items.find(
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ expect(spans).toHaveLength(7);
+ const firstInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the first span?"}]',
)!;
- expect(firstInvokeAgentSpan).toBeDefined();
- expect(firstInvokeAgentSpan.name).toBe('invoke_agent');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(firstInvokeAgentSpan.status).toBe('ok');
- expect(firstInvokeAgentSpan.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
+ expect(firstInvokeAgentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
expect(firstInvokeAgentSpan.attributes['vercel.ai.operationId']?.value).toBe('ai.generateText');
- expect(firstInvokeAgentSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe(
- '[{"role":"user","content":"Where is the first span?"}]',
- );
expect(firstInvokeAgentSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toBe(
'[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
);
- const firstGenerateContentSpan = container.items.find(
+ const firstGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string | undefined)?.includes('First span here!'),
)!;
- expect(firstGenerateContentSpan).toBeDefined();
- expect(firstGenerateContentSpan.name).toBe('generate_content mock-model-id');
+ expect(firstGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 10,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(firstGenerateContentSpan.status).toBe('ok');
- expect(firstGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
+ expect(firstGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
expect(firstGenerateContentSpan.attributes['vercel.ai.operationId']?.value).toBe(
'ai.generateText.doGenerate',
);
- expect(firstGenerateContentSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
- expect(firstGenerateContentSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('First span here!');
-
- const secondInvokeAgentSpan = container.items.find(
+ expect(firstGenerateContentSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value).toEqual(expect.any(String));
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value:
+ '[{"role":"assistant","parts":[{"type":"text","content":"First span here!"}],"finish_reason":"stop"}]',
+ });
+
+ const secondInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"Where is the second span?"}]',
)!;
- expect(secondInvokeAgentSpan).toBeDefined();
- expect(secondInvokeAgentSpan.name).toBe('invoke_agent');
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 183,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 92,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 91,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value:
+ '[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value: '[{"role":"user","content":"Where is the second span?"}]',
+ });
expect(secondInvokeAgentSpan.status).toBe('ok');
- expect(secondInvokeAgentSpan.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
+ expect(secondInvokeAgentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
- const secondGenerateContentSpan = container.items.find(
+ const secondGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
(span.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string | undefined)?.includes('Second span here!'),
)!;
- expect(secondGenerateContentSpan).toBeDefined();
- expect(secondGenerateContentSpan.name).toBe('generate_content mock-model-id');
expect(secondGenerateContentSpan.status).toBe('ok');
- expect(secondGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
+ expect(secondGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
- const toolInvokeAgentSpan = container.items.find(
+ const toolInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes[GEN_AI_INPUT_MESSAGES]?.value ===
'[{"role":"user","content":"What is the weather in San Francisco?"}]',
)!;
- expect(toolInvokeAgentSpan).toBeDefined();
- expect(toolInvokeAgentSpan.name).toBe('invoke_agent');
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.invoke_agent',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]).toEqual({
+ type: 'string',
+ value:
+ '[{"role":"assistant","parts":[{"type":"tool_call","id":"call-1","name":"getWeather","arguments":"{\\"location\\":\\"San Francisco\\"}"}],"finish_reason":"tool_call"}]',
+ });
expect(toolInvokeAgentSpan.status).toBe('ok');
- expect(toolInvokeAgentSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe(
- '[{"role":"user","content":"What is the weather in San Francisco?"}]',
- );
- const toolGenerateContentSpan = container.items.find(
+ const toolGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes[GEN_AI_TOOL_DEFINITIONS] !== undefined,
)!;
- expect(toolGenerateContentSpan).toBeDefined();
- expect(toolGenerateContentSpan.name).toBe('generate_content mock-model-id');
+ expect(toolGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolGenerateContentSpan.status).toBe('ok');
- expect(toolGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
- expect(toolGenerateContentSpan.attributes[GEN_AI_TOOL_DEFINITIONS]).toBeDefined();
+ expect(toolGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_TOOL_DEFINITIONS]).toEqual({
+ type: 'string',
+ value: expect.stringContaining('getWeather'),
+ });
expect(toolGenerateContentSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(15);
- const toolExecutionSpan = container.items.find(span => span.name === 'execute_tool getWeather')!;
- expect(toolExecutionSpan).toBeDefined();
- expect(toolExecutionSpan.name).toBe('execute_tool getWeather');
+ const toolExecutionSpan = spans.find(span => span.name === 'execute_tool getWeather')!;
+ expect(toolExecutionSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolExecutionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'execute_tool',
+ });
+ expect(toolExecutionSpan!.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]).toEqual({
+ type: 'string',
+ value: 'call-1',
+ });
expect(toolExecutionSpan.status).toBe('ok');
- expect(toolExecutionSpan.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
+ expect(toolExecutionSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.execute_tool');
expect(toolExecutionSpan.attributes[GEN_AI_TOOL_NAME]?.value).toBe('getWeather');
expect(toolExecutionSpan.attributes[GEN_AI_TOOL_DESCRIPTION]?.value).toBe(
'Get the current weather for a location',
);
- expect(toolExecutionSpan.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]).toBeDefined();
- expect(toolExecutionSpan.attributes[GEN_AI_TOOL_CALL_RESULT]).toBeDefined();
+ expect(toolExecutionSpan.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toEqual(expect.any(String));
+ expect(toolExecutionSpan.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toEqual(expect.any(String));
},
})
.start()
@@ -165,62 +303,104 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('creates ai spans when dataCollection.genAi has inputs and outputs disabled', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- expect(container.items).toHaveLength(7);
- const firstInvokeAgentSpan = container.items.find(
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ expect(spans).toHaveLength(7);
+ const firstInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes['vercel.ai.operationId']?.value === 'ai.generateText' &&
span.attributes[GEN_AI_INPUT_MESSAGES] === undefined &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 10,
)!;
- expect(firstInvokeAgentSpan).toBeDefined();
- expect(firstInvokeAgentSpan.name).toBe('invoke_agent');
+ expect(firstInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(firstInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
expect(firstInvokeAgentSpan.status).toBe('ok');
- expect(firstInvokeAgentSpan.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
- expect(firstInvokeAgentSpan.attributes['vercel.ai.operationId']?.value).toBe('ai.generateText');
+ expect(firstInvokeAgentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
expect(firstInvokeAgentSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mock-model-id');
expect(firstInvokeAgentSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('mock-model-id');
- expect(firstInvokeAgentSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10);
expect(firstInvokeAgentSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20);
expect(firstInvokeAgentSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30);
- expect(firstInvokeAgentSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- const firstGenerateContentSpan = container.items.find(
+ const firstGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes['vercel.ai.operationId']?.value === 'ai.generateText.doGenerate' &&
span.attributes[GEN_AI_INPUT_MESSAGES] === undefined &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 10,
)!;
- expect(firstGenerateContentSpan).toBeDefined();
- expect(firstGenerateContentSpan.name).toBe('generate_content mock-model-id');
+ expect(firstGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 30,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 20,
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_RESPONSE_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(firstGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(firstGenerateContentSpan.status).toBe('ok');
- expect(firstGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
- expect(firstGenerateContentSpan.attributes['vercel.ai.operationId']?.value).toBe(
- 'ai.generateText.doGenerate',
- );
+ expect(firstGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
expect(firstGenerateContentSpan.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('mock-provider');
- expect(firstGenerateContentSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10);
- expect(firstGenerateContentSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
- const secondInvokeAgentSpan = container.items.find(
+ const secondInvokeAgentSpan = spans.find(
span =>
span.name === 'invoke_agent' &&
span.attributes['vercel.ai.operationId']?.value === 'ai.generateText' &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 91,
)!;
- expect(secondInvokeAgentSpan).toBeDefined();
- expect(secondInvokeAgentSpan.name).toBe('invoke_agent');
+ expect(secondInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 183,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 92,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 91,
+ });
+ expect(secondInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(secondInvokeAgentSpan.status).toBe('ok');
- expect(secondInvokeAgentSpan.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
+ expect(secondInvokeAgentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
expect(secondInvokeAgentSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe(
'[{"role":"user","content":"Where is the second span?"}]',
);
@@ -228,16 +408,14 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
'[{"role":"assistant","parts":[{"type":"text","content":"Second span here!"}],"finish_reason":"stop"}]',
);
- const secondGenerateContentSpan = container.items.find(
+ const secondGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes['vercel.ai.operationId']?.value === 'ai.generateText.doGenerate' &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 91,
)!;
- expect(secondGenerateContentSpan).toBeDefined();
- expect(secondGenerateContentSpan.name).toBe('generate_content mock-model-id');
expect(secondGenerateContentSpan.status).toBe('ok');
- expect(secondGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
+ expect(secondGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
expect(secondGenerateContentSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined();
expect(secondGenerateContentSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value as string).toContain(
'Where is the second span?',
@@ -246,27 +424,79 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
'Second span here!',
);
- const toolInvokeAgentSpan = container.items.find(
+ const toolInvokeAgentSpan = spans.find(
span => span.name === 'invoke_agent' && span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 15,
)!;
- expect(toolInvokeAgentSpan).toBeDefined();
- expect(toolInvokeAgentSpan.name).toBe('invoke_agent');
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolInvokeAgentSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.invoke_agent',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(toolInvokeAgentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolInvokeAgentSpan.status).toBe('ok');
- const toolGenerateContentSpan = container.items.find(
+ const toolGenerateContentSpan = spans.find(
span =>
span.name === 'generate_content mock-model-id' &&
span.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value === 15,
)!;
- expect(toolGenerateContentSpan).toBeDefined();
- expect(toolGenerateContentSpan.name).toBe('generate_content mock-model-id');
+ expect(toolGenerateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({
+ type: 'string',
+ value: expectedOrigin,
+ });
+ expect(toolGenerateContentSpan!.attributes[SENTRY_OP]).toEqual({
+ type: 'string',
+ value: 'gen_ai.generate_content',
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(toolGenerateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
expect(toolGenerateContentSpan.status).toBe('ok');
- const toolExecutionSpan = container.items.find(span => span.name === 'execute_tool getWeather')!;
- expect(toolExecutionSpan).toBeDefined();
- expect(toolExecutionSpan.name).toBe('execute_tool getWeather');
+ const toolExecutionSpan = spans.find(span => span.name === 'execute_tool getWeather')!;
+ expect(toolExecutionSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolExecutionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'execute_tool',
+ });
expect(toolExecutionSpan.status).toBe('ok');
- expect(toolExecutionSpan.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
+ expect(toolExecutionSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.execute_tool');
expect(toolExecutionSpan.attributes[GEN_AI_TOOL_NAME]?.value).toBe('getWeather');
expect(toolExecutionSpan.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]?.value).toBe('call-1');
},
@@ -288,39 +518,15 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
'instrument.mjs',
(createRunner, test) => {
test('captures error in tool', async () => {
- let transactionEvent: Event | undefined;
+ let receivedSpans: SerializedStreamedSpanContainer['items'] = [];
let errorEvent: Event | undefined;
await createRunner()
- .expect({
- transaction: transaction => {
- transactionEvent = transaction;
- },
- })
+ .unordered()
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- expect(container.items).toHaveLength(3);
- const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent')!;
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan.name).toBe('invoke_agent');
- expect(invokeAgentSpan.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
-
- const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id')!;
- expect(generateContentSpan).toBeDefined();
- expect(generateContentSpan.name).toBe('generate_content mock-model-id');
- expect(generateContentSpan.status).toBe('ok');
- expect(generateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
-
- const toolSpan = container.items.find(span => span.name === 'execute_tool getWeather')!;
- expect(toolSpan).toBeDefined();
- expect(toolSpan.name).toBe('execute_tool getWeather');
- expect(toolSpan.status).toBe('error');
- expect(toolSpan.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
- expect(toolSpan.attributes[GEN_AI_TOOL_NAME]?.value).toBe('getWeather');
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
+ receivedSpans = container.items;
},
})
.expect({
@@ -331,20 +537,58 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
.start()
.completed();
- expect(transactionEvent).toBeDefined();
- expect(transactionEvent!.transaction).toBe('main');
+ const segment = receivedSpans.find(span => span.is_segment && span.name === 'main');
+ const spans = receivedSpans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel');
+ expect(spans).toHaveLength(3);
+ const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent')!;
+ expect(invokeAgentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(invokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'invoke_agent',
+ });
+ expect(invokeAgentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
+
+ const generateContentSpan = spans.find(span => span.name === 'generate_content mock-model-id')!;
+ expect(generateContentSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({
+ type: 'string',
+ value: 'generate_content',
+ });
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toEqual({
+ type: 'integer',
+ value: 40,
+ });
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 25,
+ });
+ expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]).toEqual({
+ type: 'integer',
+ value: 15,
+ });
+ expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({
+ type: 'string',
+ value: 'mock-model-id',
+ });
+ expect(generateContentSpan.status).toBe('ok');
+ expect(generateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
+
+ const toolSpan = spans.find(span => span.name === 'execute_tool getWeather')!;
+ expect(toolSpan!.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: expectedOrigin });
+ expect(toolSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'execute_tool' });
+ expect(toolSpan!.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]).toEqual({ type: 'string', value: 'call-1' });
+ expect(toolSpan.status).toBe('error');
+ expect(toolSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.execute_tool');
+ expect(toolSpan.attributes[GEN_AI_TOOL_NAME]?.value).toBe('getWeather');
- expect(errorEvent).toBeDefined();
expect(errorEvent!.level).toBe('error');
- expect(errorEvent!.tags).toEqual(
- expect.objectContaining({
- 'vercel.ai.tool.name': 'getWeather',
- 'vercel.ai.tool.callId': 'call-1',
- }),
- );
-
- // Trace id should be the same for the transaction and error event
- expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id);
+ expect(errorEvent!.tags).toEqual({
+ 'vercel.ai.tool.name': 'getWeather',
+ 'vercel.ai.tool.callId': 'call-1',
+ });
+
+ // Trace id should be the same for the segment and error event
+ expect(segment!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id);
});
},
{
@@ -361,27 +605,25 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('creates ai related spans', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- expect(container.items).toHaveLength(7);
- const invokeAgentSpans = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ expect(spans).toHaveLength(7);
+ const invokeAgentSpans = spans.filter(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent',
);
expect(invokeAgentSpans).toHaveLength(3);
- const generateContentSpans = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const generateContentSpans = spans.filter(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
);
expect(generateContentSpans).toHaveLength(3);
- const toolSpan = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.execute_tool',
- );
+ const toolSpan = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.execute_tool');
expect(toolSpan).toBeDefined();
},
})
@@ -403,46 +645,40 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('creates spans for ToolLoopAgent with tool calls', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- expect(container.items).toHaveLength(4);
- const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent weather_agent')!;
- expect(invokeAgentSpan).toBeDefined();
- expect(invokeAgentSpan.name).toBe('invoke_agent weather_agent');
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ expect(spans).toHaveLength(4);
+ const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent weather_agent')!;
expect(invokeAgentSpan.status).toBe('ok');
- expect(invokeAgentSpan.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
expect(invokeAgentSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mock-model-id');
- const toolCallsGenerateContentSpan = container.items.find(
+ const toolCallsGenerateContentSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["tool-calls"]',
)!;
- expect(toolCallsGenerateContentSpan).toBeDefined();
expect(toolCallsGenerateContentSpan.name).toBe('generate_content mock-model-id');
expect(toolCallsGenerateContentSpan.status).toBe('ok');
- expect(toolCallsGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
+ expect(toolCallsGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
expect(toolCallsGenerateContentSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10);
expect(toolCallsGenerateContentSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20);
- const toolSpan = container.items.find(span => span.name === 'execute_tool getWeather')!;
- expect(toolSpan).toBeDefined();
- expect(toolSpan.name).toBe('execute_tool getWeather');
+ const toolSpan = spans.find(span => span.name === 'execute_tool getWeather')!;
expect(toolSpan.status).toBe('ok');
- expect(toolSpan.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool');
+ expect(toolSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.execute_tool');
expect(toolSpan.attributes[GEN_AI_TOOL_NAME]?.value).toBe('getWeather');
expect(toolSpan.attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]?.value).toBe('call-1');
- const finalGenerateContentSpan = container.items.find(
+ const finalGenerateContentSpan = spans.find(
span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["stop"]',
)!;
- expect(finalGenerateContentSpan).toBeDefined();
expect(finalGenerateContentSpan.name).toBe('generate_content mock-model-id');
expect(finalGenerateContentSpan.status).toBe('ok');
- expect(finalGenerateContentSpan.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
+ expect(finalGenerateContentSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
expect(finalGenerateContentSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(15);
expect(finalGenerateContentSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(25);
},
@@ -465,18 +701,16 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('parents concurrent calls that share one model instance correctly', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- const invokeAgents = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
);
- const generateContents = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const invokeAgents = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent');
+ const generateContents = spans.filter(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
);
// Two concurrent operations -> two invoke_agent + two generate_content spans.
@@ -516,14 +750,16 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
// collapse onto whichever operation resolved the shared model last.
test('parents concurrent streamText calls that share one model instance correctly', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- const invokeAgents = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
);
- const generateContents = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const invokeAgents = spans.filter(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent');
+ const generateContents = spans.filter(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
);
// Two concurrent operations -> two invoke_agent + two generate_content spans.
@@ -560,23 +796,19 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('creates streamText spans with the model call parented to invoke_agent', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- )!;
- expect(invokeAgent).toBeDefined();
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!;
expect(invokeAgent.attributes['vercel.ai.operationId']?.value).toBe('ai.streamText');
- const generateContent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const generateContent = spans.find(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
)!;
- expect(generateContent).toBeDefined();
expect(generateContent.parent_span_id).toBe(invokeAgent.span_id);
expect(generateContent.attributes['vercel.ai.operationId']?.value).toBe('ai.streamText.doStream');
@@ -619,13 +851,14 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('captures usage, tool calls and output across a multi-step streamText', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- )!;
- expect(invokeAgent).toBeDefined();
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!;
expect(invokeAgent.status).toBe('ok');
expect(invokeAgent.attributes['vercel.ai.operationId']?.value).toBe('ai.streamText');
// Usage is summed across the two streamed model calls (10+15, 20+25, 30+40).
@@ -633,8 +866,8 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
expect(invokeAgent.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(45);
expect(invokeAgent.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(70);
- const generateContents = container.items.filter(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const generateContents = spans.filter(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
);
expect(generateContents).toHaveLength(2);
generateContents.forEach(span => expect(span.parent_span_id).toBe(invokeAgent.span_id));
@@ -643,7 +876,6 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
const toolStep = generateContents.find(
span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["tool-calls"]',
)!;
- expect(toolStep).toBeDefined();
expect(toolStep.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10);
expect(toolStep.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20);
const toolStepOutput = toolStep.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string;
@@ -654,15 +886,13 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
const textStep = generateContents.find(
span => span.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value === '["stop"]',
)!;
- expect(textStep).toBeDefined();
expect(textStep.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(15);
expect(textStep.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('Sunny, 72°F.');
// A tool span is emitted for the streamed tool call. Its parent and recorded input/output
// vary by path during stream consumption (tool i/o is covered by the non-stream scenario
// and the v7 path), so here we just assert the span exists with the right name/status.
- const executeTool = container.items.find(span => span.name === 'execute_tool getWeather')!;
- expect(executeTool).toBeDefined();
+ const executeTool = spans.find(span => span.name === 'execute_tool getWeather')!;
expect(executeTool.status).toBe('ok');
expect(executeTool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('getWeather');
},
@@ -685,21 +915,21 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('captures streamed structured output (streamText with experimental_output)', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- )!;
- expect(invokeAgent).toBeDefined();
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!;
expect(invokeAgent.status).toBe('ok');
expect(invokeAgent.attributes['vercel.ai.operationId']?.value).toBe('ai.streamText');
expect(invokeAgent.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30);
- const generateContent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const generateContent = spans.find(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
)!;
- expect(generateContent).toBeDefined();
expect(generateContent.parent_span_id).toBe(invokeAgent.span_id);
expect(generateContent.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(12);
expect(generateContent.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(18);
@@ -729,25 +959,21 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('finishes spans with an error status when the operation rejects', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
// The model throws, so the operation rejects. The spans must still be *finished* (and
// therefore present in the transaction) with an error status — not left open.
- const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- )!;
- expect(invokeAgent).toBeDefined();
+ const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!;
expect(invokeAgent.status).toBe('error');
- const generateContent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const generateContent = spans.find(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
)!;
- expect(generateContent).toBeDefined();
expect(generateContent.status).toBe('error');
},
})
@@ -769,17 +995,16 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('derives provider-metadata token breakdown and system instructions', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
- const generateContent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ const generateContent = spans.find(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
)!;
- expect(generateContent).toBeDefined();
// Cache/reasoning token breakdown is derived from the model's `providerMetadata` by the
// channel subscriber, which v6 reaches through the orchestrion adapter, so the shape is the
@@ -789,9 +1014,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
// The per-response `responseId` is not a conversation id and must not be recorded as one.
expect(generateContent.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();
- const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- )!;
+ const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!;
expect(invokeAgent).toBeDefined();
// The system prompt is supplied via the v7-only `instructions` option. Only the channel
@@ -821,17 +1044,15 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('derives gen_ai.conversation.id from the OpenAI `conversation` provider option', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
+ expect(container.items.find(span => span.is_segment && span.name === 'main')).toBeDefined();
const genAiSpans = container.items.filter(s =>
- String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'),
+ String(s.attributes[SENTRY_OP]?.value ?? '').startsWith('gen_ai.'),
);
const conversationIdOf = (span: (typeof genAiSpans)[number]) =>
span.attributes[GEN_AI_CONVERSATION_ID]?.value;
- const invokeAgentSpans = genAiSpans.filter(
- s => s.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- );
+ const invokeAgentSpans = genAiSpans.filter(s => s.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent');
expect(invokeAgentSpans).toHaveLength(4);
const [firstTurn, secondTurn, chainedTurn, apiTurn] = invokeAgentSpans.sort(
(a, b) => a.start_timestamp - b.start_timestamp,
@@ -850,7 +1071,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
// Model-call and tool spans carry their operation's id, even though their start events
// do not carry `providerOptions`.
const modelCallSpans = genAiSpans.filter(
- s => s.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ s => s.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
);
expect(modelCallSpans.map(conversationIdOf).sort()).toEqual([
'conv-from-api',
@@ -858,7 +1079,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
'conv_azure',
undefined,
]);
- const toolSpan = genAiSpans.find(s => s.attributes['sentry.op']?.value === 'gen_ai.execute_tool')!;
+ const toolSpan = genAiSpans.find(s => s.attributes[SENTRY_OP]?.value === 'gen_ai.execute_tool')!;
expect(toolSpan).toBeDefined();
expect(conversationIdOf(toolSpan)).toBe('conv_azure');
},
@@ -881,21 +1102,21 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
(createRunner, test) => {
test('reads cache token counts from the SDK usage object', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- const generateContent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
+ const generateContent = spans.find(
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
)!;
- expect(generateContent).toBeDefined();
expect(generateContent.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(120);
expect(generateContent.attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]?.value).toBe(80);
expect(generateContent.attributes[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]?.value).toBe(20);
- const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
- )!;
- expect(invokeAgent).toBeDefined();
+ const invokeAgent = spans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent')!;
expect(invokeAgent.attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]?.value).toBe(80);
expect(invokeAgent.attributes[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]?.value).toBe(20);
},
@@ -924,32 +1145,30 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
test('creates embeddings spans for embed and embedMany', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
- const embedSpan = container.items.find(
+ const embedSpan = spans.find(
span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === 'Embedding test!',
)!;
- expect(embedSpan).toBeDefined();
expect(embedSpan.name).toBe('embeddings mock-model-id');
expect(embedSpan.status).toBe('ok');
- expect(embedSpan.attributes['sentry.op']?.value).toBe('gen_ai.embeddings');
+ expect(embedSpan.attributes[SENTRY_OP]?.value).toBe('gen_ai.embeddings');
expect(embedSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mock-model-id');
expect(embedSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10);
- const embedManySpan = container.items.find(
+ const embedManySpan = spans.find(
span => span.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value === '["First input","Second input"]',
);
if (embedManyInstrumented) {
- expect(embedManySpan).toBeDefined();
expect(embedManySpan!.name).toBe('embeddings mock-model-id');
expect(embedManySpan!.status).toBe('ok');
- expect(embedManySpan!.attributes['sentry.op']?.value).toBe('gen_ai.embeddings');
+ expect(embedManySpan!.attributes[SENTRY_OP]?.value).toBe('gen_ai.embeddings');
expect(embedManySpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(20);
} else {
expect(embedManySpan).toBeUndefined();
@@ -978,28 +1197,26 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
// channel directly — v6 is exercised here, v4 in the base suite.
test.skipIf(version === '7')('creates spans for generateObject', async () => {
await createRunner()
- .expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
- // Every emitted gen_ai span carries the version-appropriate origin.
- container.items
- .filter(s => String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'))
- .forEach(s => expect(s.attributes['sentry.origin']?.value).toBe(expectedOrigin));
+ const segment = container.items.find(span => span.is_segment && span.name === 'main');
+ expect(segment).toBeDefined();
+ const spans = container.items.filter(
+ span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.vercelai.channel',
+ );
- const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent');
- expect(invokeAgentSpan).toBeDefined();
+ const invokeAgentSpan = spans.find(span => span.name === 'invoke_agent');
expect(invokeAgentSpan!.status).toBe('ok');
- expect(invokeAgentSpan!.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent');
+ expect(invokeAgentSpan!.attributes[SENTRY_OP]?.value).toBe('gen_ai.invoke_agent');
expect(invokeAgentSpan!.attributes['vercel.ai.operationId']?.value).toBe('ai.generateObject');
expect(invokeAgentSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('mock-model-id');
expect(invokeAgentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(15);
expect(invokeAgentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(25);
expect(invokeAgentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(40);
- const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id');
- expect(generateContentSpan).toBeDefined();
+ const generateContentSpan = spans.find(span => span.name === 'generate_content mock-model-id');
expect(generateContentSpan!.status).toBe('ok');
- expect(generateContentSpan!.attributes['sentry.op']?.value).toBe('gen_ai.generate_content');
+ expect(generateContentSpan!.attributes[SENTRY_OP]?.value).toBe('gen_ai.generate_content');
expect(generateContentSpan!.attributes['vercel.ai.operationId']?.value).toBe(
'ai.generateObject.doGenerate',
);
@@ -1032,9 +1249,8 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
.expect({
span: container => {
const invokeAgent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.invoke_agent',
)!;
- expect(invokeAgent).toBeDefined();
expect(invokeAgent.status).toBe('error');
expect(invokeAgent.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mock-model-id');
expect(invokeAgent.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined();
@@ -1042,9 +1258,8 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
expect(invokeAgent.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
const generateContent = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content',
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.generate_content',
)!;
- expect(generateContent).toBeDefined();
expect(generateContent.status).toBe('error');
},
})
@@ -1072,16 +1287,14 @@ describe('Vercel AI integration experimental_evaluate', () => {
(createRunner, test) => {
test('creates an evaluate span', async () => {
await createRunner()
- .unordered()
.expect({
span: container => {
const evaluateSpan = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.evaluate',
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate',
)!;
- expect(evaluateSpan).toBeDefined();
expect(evaluateSpan.name).toBe('evaluate typesafe-ai/jev');
expect(evaluateSpan.status).toBe('ok');
- expect(evaluateSpan.attributes['sentry.origin']?.value).toBe('auto.vercelai.channel');
+ expect(evaluateSpan.attributes[SENTRY_ORIGIN]?.value).toBe('auto.vercelai.channel');
expect(evaluateSpan.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate');
expect(evaluateSpan.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('gateway');
expect(evaluateSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev');
@@ -1145,13 +1358,11 @@ describe('Vercel AI integration experimental_evaluate', () => {
(createRunner, test) => {
test('does not record inputs or outputs when recording is off', async () => {
await createRunner()
- .unordered()
.expect({
span: container => {
const evaluateSpan = container.items.find(
- span => span.attributes['sentry.op']?.value === 'gen_ai.evaluate',
+ span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate',
)!;
- expect(evaluateSpan).toBeDefined();
expect(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined();
expect(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined();
// State, questions and answers must not come back through another attribute. Only the attributes
@@ -1172,3 +1383,52 @@ describe('Vercel AI integration experimental_evaluate', () => {
},
);
});
+
+describe('Vercel AI telemetry metadata (v6)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario-telemetry-metadata.mjs',
+ 'instrument.mjs',
+ (createRunner, test) => {
+ test('records experimental_telemetry.metadata as span attributes', async () => {
+ await createRunner()
+ .expect({
+ span: container => {
+ for (const [name, op, requestId] of [
+ ['invoke_agent support-chat', 'gen_ai.invoke_agent', 'req_generate'],
+ ['generate_content mock-model-id', 'gen_ai.generate_content', 'req_generate'],
+ ['invoke_agent support-stream', 'gen_ai.invoke_agent', 'req_stream'],
+ ['generate_content mock-model-id', 'gen_ai.generate_content', 'req_stream'],
+ ['embeddings mock-model-id', 'gen_ai.embeddings', 'req_embed'],
+ ['embeddings mock-model-id', 'gen_ai.embeddings', 'req_embed_many'],
+ ]) {
+ const span = container.items.find(
+ span =>
+ span.name === name &&
+ span.attributes['vercel.ai.telemetry.metadata.requestId']?.value === requestId,
+ );
+ expect(span?.attributes[SENTRY_OP]).toEqual({ type: 'string', value: op });
+ expect(span?.attributes['vercel.ai.telemetry.metadata.tenantId']).toEqual({
+ type: 'string',
+ value: 'acme',
+ });
+ }
+ const generateSpans = container.items.filter(
+ span => span.attributes['vercel.ai.telemetry.metadata.requestId']?.value === 'req_generate',
+ );
+ for (const span of generateSpans) {
+ expect(span.attributes['vercel.ai.telemetry.metadata.attempt']).toEqual({ type: 'string', value: '2' });
+ }
+ },
+ })
+ .start()
+ .completed();
+ });
+ },
+ { additionalDependencies: { ai: '^6.0.0' } },
+ );
+});
diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts
index ed94c1e1bd5a..de845c3063ac 100644
--- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts
+++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts
@@ -8,6 +8,7 @@ import type {
SerializedLogContainer,
SerializedMetricContainer,
SerializedSession,
+ SerializedStreamedSpan,
SerializedStreamedSpanContainer,
SessionAggregates,
TransactionEvent,
@@ -19,7 +20,7 @@ import { existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { inspect } from 'util';
-import { onTestFailed } from 'vitest';
+import { onTestFailed, onTestFinished } from 'vitest';
import type { DeepPartial } from './../assertions';
import {
assertEnvelopeHeader,
@@ -37,6 +38,15 @@ import {
type VoidFunction = () => void;
+type SegmentMatcher = string | ((span: SerializedStreamedSpan) => boolean);
+
+type SpanCollector = {
+ done: boolean;
+ spanCount: number;
+ add(spans: SerializedStreamedSpan[]): void;
+ reject(error: Error): void;
+};
+
type ExpectedEvent = Partial | ((event: Event) => void);
type ExpectedTransaction = DeepPartial | ((event: TransactionEvent) => void);
type ExpectedSession = Partial | ((event: SerializedSession) => void);
@@ -158,6 +168,10 @@ export function createRunner(...paths: string[]) {
}
const expectedEnvelopes: Expected[] = [];
+ const spanCollectors: SpanCollector[] = [];
+ let started = false;
+ let cleanupError: Error | undefined;
+ let stopRunner: ((error: Error) => void) | undefined;
let expectedEnvelopeHeaders: ExpectedEnvelopeHeader[] | undefined = undefined;
const flags: string[] = [];
// By default, we ignore session & sessions
@@ -190,14 +204,72 @@ export function createRunner(...paths: string[]) {
CLEANUP_STEPS.add(step);
}
+ function cleanup(): void {
+ cleanupError ??= new Error('Test runner cleaned up before collection completed.');
+ stopRunner?.(cleanupError);
+ for (const collector of spanCollectors) {
+ if (!collector.done) collector.reject(cleanupError);
+ }
+ for (const step of runnerCleanupSteps) {
+ step();
+ CLEANUP_STEPS.delete(step);
+ }
+ runnerCleanupSteps.clear();
+ }
+
+ function collectStreamedSpans(
+ isDone: (spansOfTrace: SerializedStreamedSpan[]) => boolean,
+ ): Promise {
+ if (started || cleanupError) {
+ throw new Error('Register span collectors before calling start() or cleanup().');
+ }
+
+ const promise = new Promise((resolve, reject) => {
+ const spansByTrace = new Map();
+ const collector: SpanCollector = {
+ done: false,
+ spanCount: 0,
+ reject(error) {
+ spansByTrace.clear();
+ reject(error);
+ },
+ add(spans) {
+ collector.spanCount += spans.length;
+ for (const span of spans) {
+ const traceSpans = spansByTrace.get(span.trace_id);
+ if (traceSpans) {
+ traceSpans.push(span);
+ } else {
+ spansByTrace.set(span.trace_id, [span]);
+ }
+ }
+ for (const traceSpans of spansByTrace.values()) {
+ if (isDone(traceSpans)) {
+ collector.done = true;
+ resolve(traceSpans);
+ spansByTrace.clear();
+ return;
+ }
+ }
+ },
+ };
+ spanCollectors.push(collector);
+ });
+ // completed() can reject before the caller reaches await spansPromise.
+ promise.catch(() => {});
+ return promise;
+ }
+
return {
/** Run (and de-register) only the cleanup steps registered by this runner. */
- cleanup: function (): void {
- for (const step of runnerCleanupSteps) {
- step();
- CLEANUP_STEPS.delete(step);
- }
- runnerCleanupSteps.clear();
+ cleanup,
+ /** Register before start(). Collect across envelopes until one trace satisfies the predicate. */
+ collectStreamedSpans,
+ /** Collect until a matching segment arrives; children arriving later are not included. */
+ collectStreamedSpansUntilSegment(segment: SegmentMatcher = () => true): Promise {
+ const matchesSegment =
+ typeof segment === 'string' ? (span: SerializedStreamedSpan) => span.name === segment : segment;
+ return collectStreamedSpans(spans => spans.some(span => span.is_segment && matchesSegment(span)));
},
expect: function (expected: Expected) {
if (ensureNoErrorOutput) {
@@ -274,10 +346,25 @@ export function createRunner(...paths: string[]) {
return this;
},
start: function (): StartResult {
+ if (started || cleanupError) {
+ throw new Error('A test runner can only be started once, before cleanup().');
+ }
+ if (
+ spanCollectors.length > 0 &&
+ (ensureNoErrorOutput || expectedEnvelopeHeaders || expectedEnvelopes.some(expected => 'span' in expected))
+ ) {
+ const error = new Error(
+ 'Span collectors cannot be combined with span/header expectations or ensureNoErrorOutput().',
+ );
+ for (const collector of spanCollectors) collector.reject(error);
+ throw error;
+ }
+ started = true;
let isComplete = false;
let completeError: Error | undefined;
const expectedEnvelopeCount = Math.max(expectedEnvelopes.length, (expectedEnvelopeHeaders || []).length);
+ const hasExpectations = expectedEnvelopeCount > 0 || spanCollectors.length > 0;
let envelopeCount = 0;
let scenarioServerPort: number | undefined;
@@ -301,6 +388,10 @@ export function createRunner(...paths: string[]) {
dumpCapturedLogs();
}
});
+ if (spanCollectors.length > 0) {
+ onTestFinished(cleanup);
+ }
+ stopRunner = complete;
function complete(error?: Error): void {
if (isComplete) {
@@ -309,6 +400,11 @@ export function createRunner(...paths: string[]) {
isComplete = true;
completeError = error || undefined;
+ if (error) {
+ for (const collector of spanCollectors) {
+ if (!collector.done) collector.reject(error);
+ }
+ }
stopChild();
completedDeferred.resolve();
}
@@ -350,6 +446,7 @@ export function createRunner(...paths: string[]) {
`pid=${child?.pid ?? 'none'}`,
hasExited ? `exited (${exitStatus})` : 'running',
`envelopes=${envelopeCount}/${expectedEnvelopeCount}`,
+ ...(spanCollectors.length > 0 ? [collectorProgress()] : []),
`ms since spawn=${spawnedAt ? now - spawnedAt : 'not spawned'}`,
`ms since last output=${lastOutputAt ? now - lastOutputAt : 'no output'}`,
].join(', ');
@@ -369,20 +466,48 @@ export function createRunner(...paths: string[]) {
console.log('--- End of captured child process output ---\n');
}
+ function collectorProgress(): string {
+ const pending = spanCollectors.filter(collector => !collector.done);
+ return `pending span collectors=${pending.length}, received spans=[${pending.map(c => c.spanCount).join(', ')}]`;
+ }
+
+ function checkComplete(): void {
+ if (
+ hasExpectations &&
+ envelopeCount === expectedEnvelopeCount &&
+ spanCollectors.every(collector => collector.done)
+ ) {
+ complete();
+ }
+ }
+
/** Called after each expect callback to check if we're complete */
function expectCallbackCalled(): void {
envelopeCount++;
- if (envelopeCount === expectedEnvelopeCount) {
- complete();
- }
+ checkComplete();
}
function newEnvelope(envelope: Envelope): void {
if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true));
for (const item of envelope[1]) {
+ if (isComplete) return;
const envelopeItemType = item[0].type;
+ if (envelopeItemType === 'span' && spanCollectors.length > 0) {
+ try {
+ const container = item[1] as SerializedStreamedSpanContainer;
+ for (const collector of spanCollectors) {
+ if (!collector.done) collector.add(container.items);
+ }
+ } catch (error) {
+ complete(error instanceof Error ? error : new Error(String(error)));
+ return;
+ }
+ checkComplete();
+ continue;
+ }
+
if (ignored.has(envelopeItemType)) {
continue;
}
@@ -425,6 +550,7 @@ export function createRunner(...paths: string[]) {
});
if (matchIndex < 0) {
+ if (spanCollectors.length > 0) continue;
return;
}
@@ -436,6 +562,7 @@ export function createRunner(...paths: string[]) {
// Catch any error or failed assertions and pass them to done to end the test quickly
try {
if (!expected) {
+ if (spanCollectors.length > 0) continue;
return;
}
@@ -466,6 +593,10 @@ export function createRunner(...paths: string[]) {
serverStartup
.then(([mockServerPort, mockServerClose]) => {
+ if (isComplete) {
+ mockServerClose?.();
+ return;
+ }
if (mockServerClose) {
registerCleanupStep(() => {
mockServerClose();
@@ -497,8 +628,7 @@ export function createRunner(...paths: string[]) {
// `should-exit`, `ensureNoErrorOutput`) verify the child exits
// naturally and auto-flush would delay that with retrying HTTP
// requests to the fake DSN.
- const wantsAutoFlush =
- !ensureNoErrorOutput && (expectedEnvelopes.length > 0 || (expectedEnvelopeHeaders?.length ?? 0) > 0);
+ const wantsAutoFlush = !ensureNoErrorOutput && hasExpectations;
const runtime = getRuntime();
const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags;
const entryPath = buildScenario(runtime, testPath);
@@ -550,12 +680,12 @@ export function createRunner(...paths: string[]) {
// child output is dumped by `completed()`. In the success path `complete()` has already
// run (so `isComplete` short-circuits this), server-style tests are killed by `complete()`
// first, and tests that expect no envelopes drive completion some other way.
- if (!isComplete && expectedEnvelopeCount > 0) {
+ if (!isComplete && hasExpectations) {
const how = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
complete(
new Error(
`Scenario exited (${how}) after ${envelopeCount}/${expectedEnvelopeCount} expected ` +
- 'envelope(s), before the test completed.',
+ `envelope(s), before the test completed.${spanCollectors.length > 0 ? ` ${collectorProgress()}` : ''}`,
),
);
}
@@ -616,6 +746,7 @@ export function createRunner(...paths: string[]) {
try {
await waitForEvent(completedDeferred.promise, 120_000, 'Timed out waiting for test to complete');
} catch (e) {
+ complete(e instanceof Error ? e : new Error(String(e)));
// On timeout, dump the captured child output (same info `DEBUG=1` would have streamed live)
// so CI failures are diagnosable without re-running locally with DEBUG enabled.
dumpCapturedLogs();
diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts
index 3da52584017a..9b245bd74ee5 100644
--- a/dev-packages/test-utils/src/index.ts
+++ b/dev-packages/test-utils/src/index.ts
@@ -32,7 +32,7 @@ export type { OutputScanOptions } from './build-output';
export { assertBundlerInstrumentation } from './bundler-instrumentation';
export type { InstrumentationFixture } from './bundler-instrumentation';
-export { hidePage } from './page';
+export { hidePage, waitForSoftNavigation } from './page';
export { getPlaywrightConfig } from './playwright-config';
export { getRuntime } from './runtime';
export type { Runtime } from './runtime';
diff --git a/dev-packages/test-utils/src/page.ts b/dev-packages/test-utils/src/page.ts
index 83344967bcd4..450165f13f4a 100644
--- a/dev-packages/test-utils/src/page.ts
+++ b/dev-packages/test-utils/src/page.ts
@@ -76,3 +76,32 @@ export async function hidePage(page: Page): Promise {
});
/* oxlint-enable no-restricted-globals */
}
+
+/**
+ * Waits until the browser has delivered the `soft-navigation` entry for a client-side navigation.
+ *
+ * The entry only arrives once the new route has painted, which can be after the click resolves.
+ * web-vitals starts the soft navigation's metrics when it sees the entry, so hiding the page before
+ * that reports the vitals for the previous navigation, and those of the soft navigation are never
+ * finalized.
+ */
+export async function waitForSoftNavigation(page: Page): Promise {
+ /* oxlint-disable no-restricted-globals */
+ await page.evaluate(() => {
+ return new Promise(resolve => {
+ if (!PerformanceObserver.supportedEntryTypes.includes('soft-navigation')) {
+ resolve();
+ return;
+ }
+
+ // The SDK's observer was registered first, so it is notified first. Resolving from a task after
+ // ours runs lets it handle the entry before the caller continues.
+ const observer = new PerformanceObserver(() => {
+ observer.disconnect();
+ setTimeout(resolve, 0);
+ });
+ observer.observe({ type: 'soft-navigation', buffered: true });
+ });
+ });
+ /* oxlint-enable no-restricted-globals */
+}
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index ed0b1b540bb4..7c997a3de32f 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -102,6 +102,7 @@ export {
langGraphIntegration,
createFlueInstrumentation,
mastraIntegration,
+ piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
parameterize,
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index 6f17bcc02c0c..d5aabe372bd0 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -67,6 +67,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
+ piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
createFlueInstrumentation,
diff --git a/packages/browser-utils/src/performance/entries.ts b/packages/browser-utils/src/performance/entries.ts
index ce03b8405d7c..668a0fb3a077 100644
--- a/packages/browser-utils/src/performance/entries.ts
+++ b/packages/browser-utils/src/performance/entries.ts
@@ -2,9 +2,9 @@
import type { Span, SpanAttributes } from '@sentry/core';
import {
BROWSER_NAVIGATION_TIMING_SPAN_NAMES,
- browserPerformanceTimeOrigin,
getActiveSpan,
parseUrl,
+ _INTERNAL_performanceTimeToSeconds,
RESOURCE_SPAN_NAME_FALLBACK,
setMeasurement,
spanToJSON,
@@ -108,7 +108,10 @@ export function startTrackingLongTasks(): void {
const { attributes: parentAttributes, start_timestamp: parentStartTimestamp } = spanToJSON(parent);
for (const entry of entries) {
- const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
+ const startTime = _INTERNAL_performanceTimeToSeconds(entry.startTime);
+ if (!startTime) {
+ continue;
+ }
const duration = msToSec(entry.duration);
if (parentAttributes[SENTRY_OP] === 'navigation' && parentStartTimestamp && startTime < parentStartTimestamp) {
@@ -143,12 +146,11 @@ export function startTrackingLongAnimationFrames(): void {
return;
}
for (const entry of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
- if (!entry.scripts[0]) {
+ const startTime = _INTERNAL_performanceTimeToSeconds(entry.startTime);
+ if (!startTime || !entry.scripts[0]) {
continue;
}
- const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
-
const {
start_timestamp: parentStartTimestamp,
attributes: { [SENTRY_OP]: parentOp },
@@ -209,22 +211,22 @@ interface AddPerformanceEntriesOptions {
/** Add performance related spans to a transaction */
export function addPerformanceEntries(span: Span, options: AddPerformanceEntriesOptions): void {
const performance = getBrowserPerformanceAPI();
- const origin = browserPerformanceTimeOrigin();
- if (!performance?.getEntries || !origin) {
+ if (!performance?.getEntries) {
// Gatekeeper if performance API not available
return;
}
const { spanStreamingEnabled, ignoreResourceSpans } = options;
- const timeOrigin = msToSec(origin);
-
const performanceEntries = performance.getEntries();
const { attributes, start_timestamp: transactionStartTime } = spanToJSON(span);
performanceEntries.slice(_performanceCursor).forEach(entry => {
- const startTime = msToSec(entry.startTime);
+ const startTimestamp = _INTERNAL_performanceTimeToSeconds(entry.startTime);
+ if (!startTimestamp) {
+ return;
+ }
const duration = msToSec(
// Inexplicably, Chrome sometimes emits a negative duration. We need to work around this.
// There is a SO post attempting to explain this, but it leaves one with open questions: https://stackoverflow.com/questions/23191918/peformance-getentries-and-negative-duration-display
@@ -233,21 +235,17 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
Math.max(0, entry.duration),
);
- if (
- attributes[SENTRY_OP] === 'navigation' &&
- transactionStartTime &&
- timeOrigin + startTime < transactionStartTime
- ) {
+ if (attributes[SENTRY_OP] === 'navigation' && transactionStartTime && startTimestamp < transactionStartTime) {
return;
}
switch (entry.entryType) {
case 'navigation': {
- _addNavigationSpans(span, entry as PerformanceNavigationTiming, timeOrigin, spanStreamingEnabled);
+ _addNavigationSpans(span, entry as PerformanceNavigationTiming, spanStreamingEnabled);
break;
}
case 'paint': {
- _addPaintSpan(span, entry, startTime, duration, timeOrigin);
+ _addPaintSpan(span, entry, startTimestamp, duration);
break;
}
case 'resource': {
@@ -255,9 +253,8 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
span,
entry as PerformanceResourceTiming,
entry.name,
- startTime,
+ startTimestamp,
duration,
- timeOrigin,
ignoreResourceSpans,
spanStreamingEnabled,
);
@@ -276,15 +273,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
* Create a span for a browser paint performance entry.
* Exported only for tests.
*/
-export function _addPaintSpan(
- span: Span,
- entry: PerformanceEntry,
- startTime: number,
- duration: number,
- timeOrigin: number,
-): void {
- const startTimestamp = timeOrigin + startTime;
-
+export function _addPaintSpan(span: Span, entry: PerformanceEntry, startTimestamp: number, duration: number): void {
startAndEndSpan(span, startTimestamp, startTimestamp + duration, {
// The entry name (`first-paint`, `first-contentful-paint`) is already the low-cardinality name
// the conventions ask for, so only the attribute backing it has to be added.
@@ -304,19 +293,27 @@ export function _addPaintSpan(
export function _addNavigationSpans(
span: Span,
entry: PerformanceNavigationTiming,
- timeOrigin: number,
spanStreamingEnabled?: boolean,
): void {
- _addPerformanceNavigationTiming(span, entry, 'unloadEvent', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'redirect', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'domContentLoadedEvent', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'loadEvent', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'connect', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'secureConnection', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'fetch', timeOrigin, spanStreamingEnabled);
- _addPerformanceNavigationTiming(span, entry, 'domainLookup', timeOrigin, spanStreamingEnabled);
-
- _addRequest(span, entry, timeOrigin, spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'unloadEvent', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'redirect', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'domContentLoadedEvent', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'loadEvent', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'connect', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'secureConnection', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'fetch', spanStreamingEnabled);
+ _addPerformanceNavigationTiming(span, entry, 'domainLookup', spanStreamingEnabled);
+
+ _addRequest(span, entry, spanStreamingEnabled);
+}
+
+/**
+ * Navigations can happen long after page load, after the time origin was corrected for drift. We use the origin from
+ * the entry's start for all its timings, so its duration stays correct.
+ */
+function _navigationTimeToSeconds(entry: PerformanceNavigationTiming, time: number): number {
+ // The cast is safe: `addPerformanceEntries` only adds navigation spans if the time origin is available.
+ return _INTERNAL_performanceTimeToSeconds(time, entry.startTime) as number;
}
type StartEventName =
@@ -354,7 +351,6 @@ function _addPerformanceNavigationTiming(
span: Span,
entry: PerformanceNavigationTiming,
event: StartEventName,
- timeOrigin: number,
spanStreamingEnabled: boolean | undefined,
): void {
const eventEnd = _getEndPropertyNameForNavigationTiming(event) satisfies keyof PerformanceNavigationTiming;
@@ -364,7 +360,7 @@ function _addPerformanceNavigationTiming(
return;
}
const op = NAVIGATION_TIMING_SPAN_OPS[event];
- startAndEndSpan(span, timeOrigin + msToSec(start), timeOrigin + msToSec(end), {
+ startAndEndSpan(span, _navigationTimeToSeconds(entry, start), _navigationTimeToSeconds(entry, end), {
// With span streaming, span names have to be low cardinality, so we can't fall back to the
// document URL. `url.full` keeps it, and is what Relay derives the description from.
name: spanStreamingEnabled ? BROWSER_NAVIGATION_TIMING_SPAN_NAMES[op] : entry.name,
@@ -388,15 +384,10 @@ function _getEndPropertyNameForNavigationTiming(event: StartEventName): EndEvent
}
/** Create request and response related spans */
-function _addRequest(
- span: Span,
- entry: PerformanceNavigationTiming,
- timeOrigin: number,
- spanStreamingEnabled: boolean | undefined,
-): void {
- const requestStartTimestamp = timeOrigin + msToSec(entry.requestStart);
- const responseEndTimestamp = timeOrigin + msToSec(entry.responseEnd);
- const responseStartTimestamp = timeOrigin + msToSec(entry.responseStart);
+function _addRequest(span: Span, entry: PerformanceNavigationTiming, spanStreamingEnabled: boolean | undefined): void {
+ const requestStartTimestamp = _navigationTimeToSeconds(entry, entry.requestStart);
+ const responseEndTimestamp = _navigationTimeToSeconds(entry, entry.responseEnd);
+ const responseStartTimestamp = _navigationTimeToSeconds(entry, entry.responseStart);
if (entry.responseEnd) {
// It is possible that we are collecting these metrics when the page hasn't finished loading yet, for example when the HTML slowly streams in.
// In this case, ie. when the document request hasn't finished yet, `entry.responseEnd` will be 0.
@@ -435,9 +426,8 @@ export function _addResourceSpans(
span: Span,
entry: PerformanceResourceTiming,
resourceUrl: string,
- startTime: number,
+ startTimestamp: number,
duration: number,
- timeOrigin: number,
ignoredResourceSpanOps?: Array,
spanStreamingEnabled?: boolean,
): void {
@@ -497,7 +487,6 @@ export function _addResourceSpans(
const attributesWithResourceTiming: SpanAttributes = { ...attributes, ...resourceTimingToSpanAttributes(entry) };
- const startTimestamp = timeOrigin + startTime;
const endTimestamp = startTimestamp + duration;
startAndEndSpan(span, startTimestamp, endTimestamp, {
diff --git a/packages/browser-utils/src/performance/interactions.ts b/packages/browser-utils/src/performance/interactions.ts
index 7a3cc89a981d..33913a40c619 100644
--- a/packages/browser-utils/src/performance/interactions.ts
+++ b/packages/browser-utils/src/performance/interactions.ts
@@ -16,13 +16,13 @@ import {
import { UI_ACTION_CLICK, UI_INTERACTION_CLICK } from '@sentry/conventions/op';
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core';
import {
- browserPerformanceTimeOrigin,
debug,
defineIntegration,
filterCollectedUrl,
getActiveSpan,
getRootSpan,
hasSpanStreamingEnabled,
+ _INTERNAL_performanceTimeToSeconds,
spanToJSON,
UI_ACTION_CLICK_SPAN_NAME_FALLBACK,
UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK,
@@ -242,7 +242,10 @@ function trackInteractionsAsSpans(client: Client): void {
}
for (const entry of entries) {
if (entry.name === 'click') {
- const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
+ const startTime = _INTERNAL_performanceTimeToSeconds(entry.startTime);
+ if (!startTime) {
+ continue;
+ }
const duration = msToSec(entry.duration);
const selector = htmlTreeAsString(entry.target);
diff --git a/packages/browser-utils/src/performance/resourceTiming.ts b/packages/browser-utils/src/performance/resourceTiming.ts
index 5a711d307cf3..172a454f31da 100644
--- a/packages/browser-utils/src/performance/resourceTiming.ts
+++ b/packages/browser-utils/src/performance/resourceTiming.ts
@@ -1,12 +1,6 @@
import type { SpanAttributes } from '@sentry/core';
-import { browserPerformanceTimeOrigin } from '@sentry/core';
-import { extractNetworkProtocol, getBrowserPerformanceAPI } from './utils';
-
-function getAbsoluteTime(time: number | undefined): number | undefined {
- // falsy values should be preserved so that we can later on drop undefined values and
- // preserve 0 vals for cross-origin resources without proper `Timing-Allow-Origin` header.
- return time ? ((browserPerformanceTimeOrigin() || performance.timeOrigin) + time) / 1000 : time;
-}
+import { _INTERNAL_performanceTimeToSeconds } from '@sentry/core';
+import { extractNetworkProtocol, msToSec } from './utils';
/**
* Converts a PerformanceResourceTiming entry to span data for the resource span. Most importantly,
@@ -28,10 +22,16 @@ export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResour
timingSpanData['network.protocol.name'] = name;
}
- if (!(browserPerformanceTimeOrigin() || getBrowserPerformanceAPI()?.timeOrigin)) {
+ if (_INTERNAL_performanceTimeToSeconds(resourceTiming.startTime) === undefined) {
return timingSpanData;
}
+ // Use the origin from the request start for all timings, so the durations between them stay correct.
+ const getAbsoluteTime = (time: number | undefined): number | undefined =>
+ // falsy values should be preserved so that we can later on drop undefined values and
+ // preserve 0 vals for cross-origin resources without proper `Timing-Allow-Origin` header.
+ time ? _INTERNAL_performanceTimeToSeconds(time, resourceTiming.startTime) : time;
+
return dropUndefinedKeysFromObject({
...timingSpanData,
@@ -58,7 +58,7 @@ export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResour
// This way, TTFB always measures the "first page load" experience.
// see: https://web.dev/articles/ttfb#measure-resource-requests
'http.request.time_to_first_byte':
- resourceTiming.responseStart != null ? resourceTiming.responseStart / 1000 : undefined,
+ resourceTiming.responseStart != null ? msToSec(resourceTiming.responseStart) : undefined,
});
}
diff --git a/packages/browser-utils/src/performance/userTiming.ts b/packages/browser-utils/src/performance/userTiming.ts
index 02d6fc25694d..9c970f0dea78 100644
--- a/packages/browser-utils/src/performance/userTiming.ts
+++ b/packages/browser-utils/src/performance/userTiming.ts
@@ -1,13 +1,13 @@
import { SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import type { IntegrationFn, Span, SpanAttributes, SpanAttributeValue } from '@sentry/core';
import {
- browserPerformanceTimeOrigin,
defineIntegration,
isPrimitive,
+ _INTERNAL_performanceTimeToSeconds,
spanToJSON,
stringMatchesSomePattern,
} from '@sentry/core';
-import { getBrowserPerformanceAPI, msToSec, startAndEndSpan } from './utils';
+import { getBrowserPerformanceAPI, startAndEndSpan } from './utils';
import { getNavigationEntry } from '../web-vitals/utils';
interface UserTimingOptions {
@@ -26,11 +26,9 @@ const _userTimingIntegration = ((options: UserTimingOptions = {}) => {
name: INTEGRATION_NAME,
setup(client) {
const performance = getBrowserPerformanceAPI();
- const timeOrigin = browserPerformanceTimeOrigin();
- if (!performance?.getEntries || !timeOrigin) {
+ if (!performance?.getEntries) {
return;
}
- const timeOriginInSeconds = msToSec(timeOrigin);
let performanceCursor = 0;
client.on('beforeIdleSpanEnd', idleSpan => {
@@ -41,7 +39,7 @@ const _userTimingIntegration = ((options: UserTimingOptions = {}) => {
return;
}
- const requestTime = msToSec(getNavigationEntry(false)?.requestStart ?? 0);
+ const requestStart = getNavigationEntry(false)?.requestStart ?? 0;
const performanceEntries = performance.getEntries();
for (const entry of performanceEntries.slice(performanceCursor)) {
@@ -49,22 +47,25 @@ const _userTimingIntegration = ((options: UserTimingOptions = {}) => {
continue;
}
- const startTime = msToSec(entry.startTime);
- const absoluteStartTime = timeOriginInSeconds + startTime;
+ const startTimestamp = _INTERNAL_performanceTimeToSeconds(entry.startTime);
+ if (!startTimestamp) {
+ continue;
+ }
- if (parentOp === 'navigation' && parentStartTimestamp && absoluteStartTime < parentStartTimestamp) {
+ if (parentOp === 'navigation' && parentStartTimestamp && startTimestamp < parentStartTimestamp) {
continue;
}
- _addUserTimingSpan(
- idleSpan,
- entry,
- startTime,
- msToSec(Math.max(0, entry.duration)),
- timeOriginInSeconds,
- requestTime,
- options.ignore ?? [],
- );
+ const endTimestamp = _INTERNAL_performanceTimeToSeconds(
+ entry.startTime + Math.max(0, entry.duration),
+ entry.startTime,
+ ) as number;
+
+ // We convert the request start with the entry's time origin, so that the clamp in `_addUserTimingSpan` compares
+ // timestamps on the same timeline, even if the time origin was corrected between the request and the entry.
+ const requestTimestamp = _INTERNAL_performanceTimeToSeconds(requestStart, entry.startTime) as number;
+
+ _addUserTimingSpan(idleSpan, entry, startTimestamp, endTimestamp, requestTimestamp, options.ignore ?? []);
}
performanceCursor = performanceEntries.length;
@@ -100,10 +101,9 @@ export const userTimingIntegration = defineIntegration(_userTimingIntegration);
export function _addUserTimingSpan(
parentSpan: Span,
entry: PerformanceEntry,
- startTime: number,
- duration: number,
- timeOrigin: number,
- requestTime: number,
+ startTimestamp: number,
+ endTimestamp: number,
+ requestTimestamp: number,
ignore: Array,
): void {
if (isReact19MeasureEntry(entry) || stringMatchesSomePattern(entry.name, ignore)) {
@@ -111,15 +111,13 @@ export function _addUserTimingSpan(
}
// Measures can reference arbitrary timestamps, including timestamps before the page request started.
- const spanStartTimestamp = timeOrigin + Math.max(startTime, requestTime);
- const originalStartTimestamp = timeOrigin + startTime;
- const spanEndTimestamp = originalStartTimestamp + duration;
+ const spanStartTimestamp = Math.max(startTimestamp, requestTimestamp);
const attributes: SpanAttributes = {
[SENTRY_ORIGIN]: `auto.browser.user_timing.${entry.entryType}`,
};
- if (spanStartTimestamp !== originalStartTimestamp) {
+ if (spanStartTimestamp !== startTimestamp) {
attributes['sentry.browser.measure_happened_before_request'] = true;
attributes['sentry.browser.measure_start_time'] = spanStartTimestamp;
}
@@ -127,8 +125,8 @@ export function _addUserTimingSpan(
addDetailToSpanAttributes(attributes, entry as PerformanceMeasure);
// Third-party measurements can contain timestamps which would produce invalid spans.
- if (spanStartTimestamp <= spanEndTimestamp) {
- startAndEndSpan(parentSpan, spanStartTimestamp, spanEndTimestamp, {
+ if (spanStartTimestamp <= endTimestamp) {
+ startAndEndSpan(parentSpan, spanStartTimestamp, endTimestamp, {
name: entry.name,
op: entry.entryType,
attributes,
diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts
index 0e60188b456f..5c44ea8d1078 100644
--- a/packages/browser-utils/src/web-vitals/spans.ts
+++ b/packages/browser-utils/src/web-vitals/spans.ts
@@ -7,6 +7,7 @@ import {
getClient,
getRootSpan,
hasSpanStreamingEnabled,
+ _INTERNAL_performanceTimeToSeconds,
spanToJSON,
timestampInSeconds,
UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK,
@@ -190,14 +191,17 @@ export function _sendLcpSpan(
DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);
- const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0;
// A soft navigation's LCP is measured from the triggering interaction, not the document time
// origin. Starting the span there too keeps it inside the navigation span it is parented to and
- // keeps its duration equal to the value it reports.
- const startTime = msToSec(performanceTimeOrigin + (navigationStartTime || 0));
+ // keeps its duration equal to the reported value. The span's end uses the same origin, even if
+ // the time origin was corrected in between.
+ const navigationStart = navigationStartTime || 0;
+ const startTime = _INTERNAL_performanceTimeToSeconds(navigationStart) ?? msToSec(navigationStart);
// Without an entry there is no render time to end at, so the span lasts the value it reports,
// like an entry-less INP does. Ending at the time origin instead would invert the span.
- const endTime = entry ? msToSec(performanceTimeOrigin + entry.startTime) : startTime + msToSec(lcpValue);
+ const endTime = entry
+ ? (_INTERNAL_performanceTimeToSeconds(entry.startTime, navigationStart) ?? msToSec(entry.startTime))
+ : startTime + msToSec(lcpValue);
const selector = entry ? htmlTreeAsString(entry.element) : undefined;
const componentName = entry?.element ? getComponentName(entry.element) : null;
const client = getClient();
@@ -294,12 +298,12 @@ export function _sendClsSpan(
): void {
DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`);
- const performanceTimeOrigin = browserPerformanceTimeOrigin();
// A CLS of 0 has no shift to place the span at. It is reported when the navigation it was
// measured on is already over - the next soft navigation, or pagehide - so the current time would
// land it outside that navigation, on the route that follows it.
const offset = entry?.startTime ?? navigationStartTime ?? 0;
- const startTime = performanceTimeOrigin ? msToSec(performanceTimeOrigin + offset) : timestampInSeconds();
+ // CLS is only reported on pagehide, so we use the time origin from when the layout shift happened.
+ const startTime = _INTERNAL_performanceTimeToSeconds(offset) ?? timestampInSeconds();
const firstSourceNode = entry?.sources[0]?.node;
const selector = entry ? htmlTreeAsString(firstSourceNode) : undefined;
const componentName = firstSourceNode ? getComponentName(firstSourceNode) : null;
@@ -406,9 +410,12 @@ export function _sendInpSpan(
// A web vital span carries the metric, not a real interaction timing, so an INP without an entry
// is still worth reporting. It just has no element or interaction type to describe, and is placed
// at the start of the navigation it belongs to rather than at the interaction.
- const startTime = msToSec(
- (browserPerformanceTimeOrigin() as number) + (entry?.startTime ?? metric?.navigationStartTime ?? 0),
- );
+ // INP is reported on pagehide, often long after the interaction, so we use the time origin from when the
+ // interaction happened.
+ const startTime = _INTERNAL_performanceTimeToSeconds(entry?.startTime ?? metric?.navigationStartTime ?? 0);
+ if (!startTime) {
+ return;
+ }
const duration = msToSec(inpValue);
// An INP without an entry has no interaction type to report. It still has to land inside the
// `ui.interaction.*` family, because falling outside it would hide exactly the fast navigations
diff --git a/packages/browser-utils/test/performance/addPerformanceEntries.test.ts b/packages/browser-utils/test/performance/addPerformanceEntries.test.ts
new file mode 100644
index 000000000000..8d37db8b992b
--- /dev/null
+++ b/packages/browser-utils/test/performance/addPerformanceEntries.test.ts
@@ -0,0 +1,83 @@
+import { HTTP_REQUEST_RESPONSE_END, SENTRY_OP } from '@sentry/conventions/attributes';
+import type { Span } from '@sentry/core';
+import * as SentryCore from '@sentry/core';
+import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { addPerformanceEntries } from '../../src/performance/entries';
+import { WINDOW } from '../../src/types';
+import { getDefaultClientOptions, TestClient } from '../utils/TestClient';
+
+vi.mock('@sentry/core', async () => {
+ const actual = await vi.importActual('@sentry/core');
+ const browserPerformanceTimeOrigin = vi.fn();
+ return {
+ ...actual,
+ browserPerformanceTimeOrigin,
+ _INTERNAL_performanceTimeToSeconds: (time: number, entryStartTime = time) =>
+ (browserPerformanceTimeOrigin(entryStartTime) + time) / 1000,
+ };
+});
+
+const pageloadOriginMs = 1_000_000;
+const sleepDurationMs = 3_600_000;
+// The `performance.now()` time from which the corrected time origin applies.
+const correctionFromMs = 10_000;
+
+describe('addPerformanceEntries', () => {
+ beforeEach(() => {
+ getMainCarrier().__SENTRY__ = undefined;
+ const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 }));
+ setCurrentClient(client);
+ client.init();
+
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockImplementation((monotonicTimeInMs = 0) =>
+ monotonicTimeInMs < correctionFromMs ? pageloadOriginMs : pageloadOriginMs + sleepDurationMs,
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('keeps the resource spans of a navigation that happens after a time origin correction', () => {
+ const resourceStartTime = 12_000;
+ vi.stubGlobal('addEventListener', vi.fn());
+ vi.stubGlobal('location', { origin: 'https://example.com' });
+ vi.spyOn(WINDOW.performance, 'getEntries').mockReturnValue([
+ {
+ entryType: 'resource',
+ name: 'https://example.com/app.js',
+ initiatorType: 'script',
+ startTime: resourceStartTime,
+ duration: 100,
+ responseEnd: resourceStartTime + 100,
+ toJSON: () => ({}),
+ } as PerformanceResourceTiming,
+ ]);
+
+ const navigationStartTimestamp = (pageloadOriginMs + sleepDurationMs + resourceStartTime - 50) / 1000;
+ const span = new SentrySpan({
+ op: 'navigation',
+ name: '/next',
+ sampled: true,
+ startTimestamp: navigationStartTimestamp,
+ });
+
+ const endedSpans: Span[] = [];
+ getClient()?.on('spanEnd', endedSpan => void endedSpans.push(endedSpan));
+
+ addPerformanceEntries(span, { ignoreResourceSpans: [] });
+
+ expect(endedSpans.map(spanToJSON)).toEqual([
+ expect.objectContaining({
+ start_timestamp: (pageloadOriginMs + sleepDurationMs + resourceStartTime) / 1000,
+ end_timestamp: (pageloadOriginMs + sleepDurationMs + resourceStartTime + 100) / 1000,
+ attributes: expect.objectContaining({
+ [SENTRY_OP]: 'resource.script',
+ [HTTP_REQUEST_RESPONSE_END]: (pageloadOriginMs + sleepDurationMs + resourceStartTime + 100) / 1000,
+ }),
+ }),
+ ]);
+ });
+});
diff --git a/packages/browser-utils/test/performance/browserMetrics.test.ts b/packages/browser-utils/test/performance/browserMetrics.test.ts
index f9dcccabb0b4..7a5455aa491d 100644
--- a/packages/browser-utils/test/performance/browserMetrics.test.ts
+++ b/packages/browser-utils/test/performance/browserMetrics.test.ts
@@ -173,7 +173,7 @@ describe('_addResourceSpans', () => {
renderBlockingStatus: 'non-blocking',
nextHopProtocol: 'http/1.1',
});
- _addResourceSpans(span, entry, resourceEntryName, 123, 456, 100);
+ _addResourceSpans(span, entry, resourceEntryName, 123, 456);
expect(spans).toHaveLength(0);
});
@@ -193,7 +193,7 @@ describe('_addResourceSpans', () => {
renderBlockingStatus: 'non-blocking',
nextHopProtocol: 'http/1.1',
});
- _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 456, 100);
+ _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 456);
expect(spans).toHaveLength(0);
});
@@ -226,18 +226,17 @@ describe('_addResourceSpans', () => {
workerStart: 1006,
});
- const timeOrigin = 100;
- const startTime = 23;
+ const startTimestamp = 123;
const duration = 356;
- _addResourceSpans(span, entry, resourceEntryName, startTime, duration, timeOrigin);
+ _addResourceSpans(span, entry, resourceEntryName, startTimestamp, duration);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!)).toEqual(
expect.objectContaining({
name: '/assets/to/css',
- start_timestamp: timeOrigin + startTime,
- end_timestamp: timeOrigin + startTime + duration,
+ start_timestamp: startTimestamp,
+ end_timestamp: startTimestamp + duration,
attributes: {
[SENTRY_OP]: 'resource.css',
[SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
@@ -282,7 +281,7 @@ describe('_addResourceSpans', () => {
nextHopProtocol: 'http/1.1',
});
- _addResourceSpans(span, entry, 'https://example.com/assets/app.js?v=42#main', 100, 23, 345);
+ _addResourceSpans(span, entry, 'https://example.com/assets/app.js?v=42#main', 100, 23);
expect(spans).toHaveLength(1);
const json = spanToJSON(spans[0]!);
@@ -302,7 +301,7 @@ describe('_addResourceSpans', () => {
nextHopProtocol: 'http/1.1',
});
- _addResourceSpans(span, entry, 'https://cdn.example.org/static/logo.png', 100, 23, 345);
+ _addResourceSpans(span, entry, 'https://cdn.example.org/static/logo.png', 100, 23);
expect(spans).toHaveLength(1);
const json = spanToJSON(spans[0]!);
@@ -346,7 +345,7 @@ describe('_addResourceSpans', () => {
initiatorType,
nextHopProtocol: 'http/1.1',
});
- _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 234, 465);
+ _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 234);
expect(spans).toHaveLength(i + 1);
expect(spanToJSON(spans[i]!).attributes).toEqual(expect.objectContaining({ [SENTRY_OP]: op }));
@@ -389,7 +388,7 @@ describe('_addResourceSpans', () => {
initiatorType,
nextHopProtocol: 'http/1.1',
});
- _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 234, 465, ignoredResourceSpans);
+ _addResourceSpans(span, entry, 'https://example.com/assets/to/me', 123, 234, ignoredResourceSpans);
}
expect(spans).toHaveLength(table.length - ignoredResourceSpans.length);
const spanOps = new Set(
@@ -416,7 +415,7 @@ describe('_addResourceSpans', () => {
nextHopProtocol: 'h2',
});
- _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345);
+ _addResourceSpans(span, entry, resourceEntryName, 100, 23);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!)).toEqual(
@@ -455,7 +454,7 @@ describe('_addResourceSpans', () => {
nextHopProtocol: 'h3',
});
- _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345);
+ _addResourceSpans(span, entry, resourceEntryName, 100, 23);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!)).toEqual(
@@ -472,8 +471,8 @@ describe('_addResourceSpans', () => {
['network.protocol.version']: '3',
}),
name: '/assets/to/css',
- end_timestamp: 468,
- start_timestamp: 445,
+ end_timestamp: 123,
+ start_timestamp: 100,
}),
);
});
@@ -507,7 +506,7 @@ describe('_addResourceSpans', () => {
workerStart: 1006,
} as unknown as PerformanceResourceTiming;
- _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345);
+ _addResourceSpans(span, entry, resourceEntryName, 100, 23);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!)).toEqual(
@@ -537,8 +536,8 @@ describe('_addResourceSpans', () => {
'http.request.worker_start': expect.any(Number),
},
name: '/assets/to/css',
- end_timestamp: 468,
- start_timestamp: 445,
+ end_timestamp: 123,
+ start_timestamp: 100,
}),
);
});
@@ -563,7 +562,7 @@ describe('_addResourceSpans', () => {
nextHopProtocol: 'h3',
});
- _addResourceSpans(span, entry, resourceEntryName, 100, 23, 345);
+ _addResourceSpans(span, entry, resourceEntryName, 100, 23);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!).attributes).toMatchObject({
@@ -586,7 +585,7 @@ describe('_addResourceSpans', () => {
const entry = mockPerformanceResourceTiming({ initiatorType: 'css', nextHopProtocol: 'h2' });
- _addResourceSpans(span, entry, url, 100, 23, 345, undefined, true);
+ _addResourceSpans(span, entry, url, 100, 23, undefined, true);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!)).toEqual(
@@ -609,7 +608,7 @@ describe('_addResourceSpans', () => {
const entry = mockPerformanceResourceTiming({ initiatorType: 'script', nextHopProtocol: 'h2' });
- _addResourceSpans(span, entry, 'blob:0f6b3f0a-1e2d-4d1a-9c3f-2a5c1d7b8e90', 100, 23, 345, undefined, true);
+ _addResourceSpans(span, entry, 'blob:0f6b3f0a-1e2d-4d1a-9c3f-2a5c1d7b8e90', 100, 23, undefined, true);
expect(spans).toHaveLength(1);
const spanJson = spanToJSON(spans[0]!);
@@ -688,7 +687,7 @@ describe('_addNavigationSpans', () => {
spans.push(span);
});
- _addNavigationSpans(pageloadSpan, entry, 999);
+ _addNavigationSpans(pageloadSpan, entry);
const trace_id = pageloadSpan.spanContext().traceId;
const parent_span_id = pageloadSpan.spanContext().spanId;
@@ -811,7 +810,7 @@ describe('_addNavigationSpans', () => {
});
// `unloadEventStart`/`End` are 0 in the shared entry, so that span is never created.
- _addNavigationSpans(pageloadSpan, { ...entry, unloadEventStart: 1, unloadEventEnd: 2 }, 999, true);
+ _addNavigationSpans(pageloadSpan, { ...entry, unloadEventStart: 1, unloadEventEnd: 2 }, true);
const spanJson = spans.map(spanToJSON).find(span => span.attributes[SENTRY_OP] === op);
@@ -846,7 +845,7 @@ describe('_addPaintSpan', () => {
duration: 0,
} as PerformanceEntry;
- _addPaintSpan(pageloadSpan, entry, 12, 0, 999);
+ _addPaintSpan(pageloadSpan, entry, 12, 0);
expect(spans).toHaveLength(1);
const spanJson = spanToJSON(spans[0]!);
diff --git a/packages/browser-utils/test/performance/resourceTiming.test.ts b/packages/browser-utils/test/performance/resourceTiming.test.ts
index c6749c6455aa..d1e6426e837a 100644
--- a/packages/browser-utils/test/performance/resourceTiming.test.ts
+++ b/packages/browser-utils/test/performance/resourceTiming.test.ts
@@ -11,6 +11,10 @@ describe('resourceTimingToSpanAttributes', () => {
beforeEach(() => {
vi.clearAllMocks();
browserPerformanceTimeOriginSpy = vi.spyOn(utils, 'browserPerformanceTimeOrigin');
+ vi.spyOn(utils, '_INTERNAL_performanceTimeToSeconds').mockImplementation((time, entryStartTime = time) => {
+ const origin = utils.browserPerformanceTimeOrigin(entryStartTime);
+ return origin ? (origin + time) / 1000 : undefined;
+ });
extractNetworkProtocolSpy = vi.spyOn(browserMetricsUtils, 'extractNetworkProtocol');
});
@@ -367,117 +371,6 @@ describe('resourceTimingToSpanAttributes', () => {
});
});
- describe('fallback to performance.timeOrigin', () => {
- it('uses performance.timeOrigin when browserPerformanceTimeOrigin returns null', () => {
- // Mock browserPerformanceTimeOrigin to return null for the main check
- browserPerformanceTimeOriginSpy.mockReturnValue(null);
-
- extractNetworkProtocolSpy.mockReturnValue({
- name: '',
- version: 'unknown',
- });
-
- const mockResourceTiming = createMockResourceTiming({
- nextHopProtocol: '',
- redirectStart: 20,
- fetchStart: 40,
- domainLookupStart: 60,
- domainLookupEnd: 80,
- connectStart: 100,
- secureConnectionStart: 120,
- connectEnd: 140,
- requestStart: 160,
- responseStart: 300,
- responseEnd: 400,
- });
-
- const result = resourceTimingToSpanAttributes(mockResourceTiming);
-
- // When browserPerformanceTimeOrigin returns null, function returns early with only network protocol attributes
- expect(result).toEqual({
- 'network.protocol.version': 'unknown',
- 'network.protocol.name': '',
- });
- });
-
- it('uses performance.timeOrigin fallback in getAbsoluteTime when available', () => {
- // Mock browserPerformanceTimeOrigin to return 500000 for the main check
- browserPerformanceTimeOriginSpy.mockReturnValue(500000);
-
- extractNetworkProtocolSpy.mockReturnValue({
- name: '',
- version: 'unknown',
- });
-
- const mockResourceTiming = createMockResourceTiming({
- nextHopProtocol: '',
- redirectStart: 20,
- redirectEnd: 30,
- workerStart: 35,
- fetchStart: 40,
- domainLookupStart: 60,
- domainLookupEnd: 80,
- connectStart: 100,
- secureConnectionStart: 120,
- connectEnd: 140,
- requestStart: 160,
- responseStart: 300,
- responseEnd: 400,
- });
-
- const result = resourceTimingToSpanAttributes(mockResourceTiming);
-
- expect(result).toEqual({
- 'network.protocol.version': 'unknown',
- 'network.protocol.name': '',
- 'http.request.redirect_start': 500.02, // (500000 + 20) / 1000
- 'http.request.redirect_end': 500.03, // (500000 + 30) / 1000
- 'http.request.worker_start': 500.035, // (500000 + 35) / 1000
- 'http.request.fetch_start': 500.04, // (500000 + 40) / 1000
- 'http.request.domain_lookup_start': 500.06, // (500000 + 60) / 1000
- 'http.request.domain_lookup_end': 500.08, // (500000 + 80) / 1000
- 'http.request.connect_start': 500.1, // (500000 + 100) / 1000
- 'http.request.secure_connection_start': 500.12, // (500000 + 120) / 1000
- 'http.request.connection_end': 500.14, // (500000 + 140) / 1000
- 'http.request.request_start': 500.16, // (500000 + 160) / 1000
- 'http.request.response_start': 500.3, // (500000 + 300) / 1000
- 'http.request.response_end': 500.4, // (500000 + 400) / 1000
- 'http.request.time_to_first_byte': 0.3, // 300 / 1000
- });
- });
-
- it('handles case when neither browserPerformanceTimeOrigin nor performance.timeOrigin is available', () => {
- browserPerformanceTimeOriginSpy.mockReturnValue(null);
-
- extractNetworkProtocolSpy.mockReturnValue({
- name: '',
- version: 'unknown',
- });
-
- // Mock performance.timeOrigin as undefined
- const originalPerformance = global.performance;
- global.performance = {
- ...originalPerformance,
- timeOrigin: undefined,
- } as any;
-
- const mockResourceTiming = createMockResourceTiming({
- nextHopProtocol: '',
- });
-
- const result = resourceTimingToSpanAttributes(mockResourceTiming);
-
- // When neither timing source is available, should return network protocol attributes for empty string
- expect(result).toEqual({
- 'network.protocol.version': 'unknown',
- 'network.protocol.name': '',
- });
-
- // Restore global performance
- global.performance = originalPerformance;
- });
- });
-
describe('edge cases', () => {
it("doesn't include undefined timing values", () => {
browserPerformanceTimeOriginSpy.mockReturnValue(1000000);
diff --git a/packages/browser-utils/test/performance/userTiming.test.ts b/packages/browser-utils/test/performance/userTiming.test.ts
index 4fb0ffd204e5..4ac90fbdd816 100644
--- a/packages/browser-utils/test/performance/userTiming.test.ts
+++ b/packages/browser-utils/test/performance/userTiming.test.ts
@@ -1,10 +1,23 @@
+import type * as SentryCore from '@sentry/core';
import type { Span } from '@sentry/core';
-import { getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core';
+import {
+ getMainCarrier,
+ _INTERNAL_performanceTimeToSeconds,
+ SentrySpan,
+ setCurrentClient,
+ spanToJSON,
+} from '@sentry/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { _addUserTimingSpan, userTimingIntegration } from '../../src/performance/userTiming';
import * as utils from '../../src/performance/utils';
+import * as webVitalsUtils from '../../src/web-vitals/utils';
import { getDefaultClientOptions, TestClient } from '../utils/TestClient';
+vi.mock('@sentry/core', async importOriginal => {
+ const actual = await importOriginal();
+ return { ...actual, _INTERNAL_performanceTimeToSeconds: vi.fn(actual._INTERNAL_performanceTimeToSeconds) };
+});
+
describe('userTimingIntegration', () => {
let client: TestClient;
let performanceEntries: PerformanceEntry[];
@@ -12,6 +25,7 @@ describe('userTimingIntegration', () => {
beforeEach(() => {
vi.restoreAllMocks();
+ vi.mocked(_INTERNAL_performanceTimeToSeconds).mockReset();
getMainCarrier().__SENTRY__ = undefined;
client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 }));
@@ -124,6 +138,45 @@ describe('userTimingIntegration', () => {
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!).name).toBe('current-route');
});
+
+ it('gives measures starting before the request the exact request span start timestamp', () => {
+ userTimingIntegration().setup?.(client);
+
+ // Many values, because only some of them round differently with the old conversion.
+ for (let i = 0; i < 50; i++) {
+ const requestStart = 10.1 + i * 0.37;
+ vi.spyOn(webVitalsUtils, 'getNavigationEntry').mockReturnValue({
+ startTime: 0,
+ requestStart,
+ } as PerformanceNavigationTiming);
+ performanceEntries.push(createPerformanceEntry('measure', `before-request-${i}`, 0, 1000));
+
+ client.emit('beforeIdleSpanEnd', new SentrySpan({ op: 'pageload', name: '/', sampled: true }));
+
+ expect(spanToJSON(spans[i]!).start_timestamp).toBe(_INTERNAL_performanceTimeToSeconds(requestStart, 0));
+ }
+ });
+
+ it('keeps measures recorded after the wall clock was corrected backwards', () => {
+ const timeOrigin = performance.timeOrigin;
+ // The time origin was corrected 60s backwards at 1000ms, e.g. by NTP.
+ vi.mocked(_INTERNAL_performanceTimeToSeconds).mockImplementation(
+ (monotonicTimeInMs, entryStartTimeInMs = monotonicTimeInMs) =>
+ ((entryStartTimeInMs < 1000 ? timeOrigin : timeOrigin - 60_000) + monotonicTimeInMs) / 1000,
+ );
+ vi.spyOn(webVitalsUtils, 'getNavigationEntry').mockReturnValue({
+ startTime: 0,
+ requestStart: 10,
+ } as PerformanceNavigationTiming);
+
+ userTimingIntegration().setup?.(client);
+ performanceEntries.push(createPerformanceEntry('measure', 'after-correction', 2000, 10));
+ client.emit('beforeIdleSpanEnd', new SentrySpan({ op: 'pageload', name: '/', sampled: true }));
+
+ expect(spans).toHaveLength(1);
+ expect(spanToJSON(spans[0]!).start_timestamp).toBe((timeOrigin - 60_000 + 2000) / 1000);
+ expect(spanToJSON(spans[0]!).attributes['sentry.browser.measure_happened_before_request']).toBeUndefined();
+ });
});
describe('_addUserTimingSpan', () => {
@@ -154,7 +207,7 @@ describe('_addUserTimingSpan', () => {
},
} as PerformanceMeasure;
- _addUserTimingSpan(parentSpan, entry, 0.012, 0.01, 100, 0, []);
+ _addUserTimingSpan(parentSpan, entry, 100.012, 100.022, 0, []);
expect(spans).toHaveLength(1);
expect(spanToJSON(spans[0]!).attributes).toEqual({
@@ -175,7 +228,7 @@ describe('_addUserTimingSpan', () => {
},
} as PerformanceMeasure;
- _addUserTimingSpan(parentSpan, entry, 0.012, 0.01, 100, 0, []);
+ _addUserTimingSpan(parentSpan, entry, 100.012, 100.022, 0, []);
expect(spans).toHaveLength(0);
});
@@ -184,10 +237,9 @@ describe('_addUserTimingSpan', () => {
_addUserTimingSpan(
parentSpan,
createPerformanceEntry('measure', 'before-request', 10, 10),
- 0.01,
- 0.01,
- 100,
- 0.05,
+ 100.01,
+ 100.02,
+ 100.05,
[],
);
diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts
index f9182b52d153..05a20d5f2d2d 100644
--- a/packages/browser-utils/test/web-vitals/spans.test.ts
+++ b/packages/browser-utils/test/web-vitals/spans.test.ts
@@ -22,6 +22,7 @@ vi.mock('@sentry/core', async () => {
return {
...actual,
browserPerformanceTimeOrigin: vi.fn(),
+ _INTERNAL_performanceTimeToSeconds: vi.fn(),
timestampInSeconds: vi.fn(),
getCurrentScope: vi.fn(),
getClient: vi.fn(),
@@ -378,6 +379,9 @@ describe('_sendLcpSpan', () => {
beforeEach(() => {
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore._INTERNAL_performanceTimeToSeconds).mockImplementation(
+ (time, entryStartTime = time) => (SentryCore.browserPerformanceTimeOrigin(entryStartTime)! + time) / 1000,
+ );
vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.spanToJSON).mockReturnValue({
@@ -475,6 +479,22 @@ describe('_sendLcpSpan', () => {
expect(mockSpan.end).toHaveBeenCalledWith(3.25);
});
+ it('uses the time origin from the start of a soft navigation for LCP', () => {
+ // The soft navigation happens after a time origin correction.
+ const sleepDurationMs = 3_600_000;
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockImplementation((monotonicTimeInMs = 0) =>
+ monotonicTimeInMs < 1500 ? 1000 : 1000 + sleepDurationMs,
+ );
+ const entry = { element: { tagName: 'img' } as Element, startTime: 2250 } as LargestContentfulPaint;
+
+ _sendLcpSpan(250, entry, undefined, 2, 'soft-navigation', 2000);
+
+ expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({ startTime: (1000 + sleepDurationMs + 2000) / 1000 }),
+ );
+ expect(mockSpan.end).toHaveBeenCalledWith((1000 + sleepDurationMs + 2250) / 1000);
+ });
+
it('drops implausible LCP values', () => {
_sendLcpSpan(0, undefined);
_sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined);
@@ -497,6 +517,7 @@ describe('_sendClsSpan', () => {
beforeEach(() => {
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore._INTERNAL_performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000);
vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5);
vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any);
@@ -590,6 +611,7 @@ describe('_sendClsSpan', () => {
it('falls back to the current time when there is no performance time origin', () => {
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined);
+ vi.mocked(SentryCore._INTERNAL_performanceTimeToSeconds).mockReturnValue(undefined);
_sendClsSpan(0, undefined);
@@ -612,6 +634,7 @@ describe('_sendInpSpan', () => {
beforeEach(() => {
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore._INTERNAL_performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000);
vi.mocked(htmlTreeAsString).mockReturnValue('