diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts deleted file mode 100644 index 3e7b0ad0a5d5..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { defineHook } from 'eve/hooks'; - -// Tags every turn of an eve session with the session id as the Sentry conversation id, so a -// session's AI spans — which land in separate traces (each turn is its own durable workflow) — -// group into one conversation in Sentry. -export default defineHook(Sentry.eveConversationHook()); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts deleted file mode 100644 index e2d33fd49591..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as Sentry from '@sentry/node'; - -// eve auto-discovers `agent/instrumentation.ts` and runs it at server startup, -// before it loads the agent (and the `ai` SDK). That is early enough for the -// Sentry SDK to install its instrumentation, so no `--import` / `NODE_OPTIONS` -// bootstrap is needed. eve's own OpenTelemetry pipeline is intentionally left -// unused: the gen_ai spans come from Sentry's `ai` instrumentation, not OTel. -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1.0, - // Not a default integration. It only produces spans in the "orchestrion" test - // variant, where the server is started with - // `NODE_OPTIONS=--import=@sentry/node/import` so the orchestrion module - // transform is registered before `dataloader` loads. - integrations: [Sentry.dataloaderIntegration()], -}); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts new file mode 100644 index 000000000000..850b9983ac20 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { defineInstrumentation } from 'eve/instrumentation'; + +export default defineInstrumentation( + Sentry.eveInstrumentation({ + 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-eve/package.json b/dev-packages/e2e-tests/test-applications/node-eve/package.json index e95a1aaafe41..f51d32a86a5f 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -26,7 +26,7 @@ "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "ai": "^7.0.82", "dataloader": "^2.2.3", - "eve": "^0.52.3", + "eve": "^0.63.0", "zod": "4.5.4" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts index 14b458ca82d7..9099824bd7be 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts @@ -76,8 +76,8 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to expect(manualSpan?.trace_id).toBe(executeTool?.trace_id); expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); - // `agent/hooks/sentry.ts` sets the eve session id as the conversation id via - // `Sentry.eveConversationHook()`, so every gen_ai span in the turn is tagged with it — that is + // `Sentry.eveInstrumentation()` (see `agent/instrumentation/sentry.ts`) sets the eve session id as + // the conversation id on each turn, so every gen_ai span in the turn is tagged with it — that is // what links a multi-turn session (each turn is its own trace) into one Sentry conversation. expect(sessionId).toBeTruthy(); for (const span of [invokeAgent, generateContent, executeTool]) { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 6b5732fb46df..8f765fc367f8 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -189,6 +189,8 @@ export { withStreamedSpan, metrics, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 6d2ac72f15f6..c467d1b2266f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -175,6 +175,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 40df75b4c77a..10aa2d422844 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -192,6 +192,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index f1d5d3dbd5de..acd492c06c15 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -137,6 +137,7 @@ export { instrumentCreateReactAgent, vercelAIIntegration, eveConversationHook, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/server-utils'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index b5cc8b069b2d..3b44d8b6d875 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -151,6 +151,7 @@ export { postgresJsIntegration, tediousIntegration, eveConversationHook, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/server-utils'; export { openTelemetryIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 7e2e8c2baa97..e86252691726 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -175,6 +175,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/node/src/eve.ts b/packages/node/src/eve.ts new file mode 100644 index 000000000000..4e1d93f20e71 --- /dev/null +++ b/packages/node/src/eve.ts @@ -0,0 +1,78 @@ +import { type Integration, setConversationId } from '@sentry/core'; +import { eveIntegration } from '@sentry/server-utils'; +import { init } from './sdk'; +import type { NodeOptions } from './types'; + +/** + * Options for {@link eveInstrumentation}: every `Sentry.init` option, plus how to derive the + * conversation id. + */ +export interface EveInstrumentationOptions extends NodeOptions { + /** + * Derive the Sentry conversation id from the eve session. Defaults to the durable session id + * (`session.id`), which is stable across every turn of a session and so groups them into one + * conversation. Return `null`/`undefined` to skip setting it for a turn. + */ + getConversationId?: (context: { session: { id: string } }) => string | null | undefined; +} + +/** + * The `defineInstrumentation` provider shape this returns. Typed structurally rather than importing + * from `eve`, so the SDK carries no dependency on the framework — the shape is checked at the + * `defineInstrumentation(...)` call site in the user's app instead. + */ +interface EveInstrumentationProvider { + setup: () => void; + events: { + 'turn.started': (event: { sessionId: string }) => void; + 'step.attempt.started': (event: { scope: { sessionId: string } }) => void; + }; +} + +/** + * All-in-one Sentry setup for an [eve](https://eve.dev) agent, wired into a single + * `agent/instrumentation/*.ts` provider file. + * + * @example + * ```ts + * // agent/instrumentation/sentry.ts + * import * as Sentry from '@sentry/node'; + * import { defineInstrumentation } from 'eve/instrumentation'; + * + * export default defineInstrumentation(Sentry.eveInstrumentation({ dsn: '__DSN__' })); + * ``` + * + * It: + * - runs `Sentry.init` with the given options at server startup (`setup`), adding + * {@link eveIntegration} so gen_ai inputs/outputs are recorded by default (eve stamps every AI SDK + * call with `recordInputs`/`recordOutputs: false`); + * - tags each turn's AI spans with a Sentry conversation id via `turn.started` / `step.attempt.started` + * events — the instrumentation-provider equivalent of `eveConversationHook`. Provider event handlers + * receive the session id on the event (`event.sessionId` / `event.scope.sessionId`), unlike hook + * handlers, which read it from their context. + */ +export function eveInstrumentation(options: EveInstrumentationOptions = {}): EveInstrumentationProvider { + const { getConversationId, ...initOptions } = options; + + const setConversationIdFromSession = (sessionId: string): void => { + setConversationId(getConversationId ? getConversationId({ session: { id: sessionId } }) : sessionId); + }; + + return { + setup() { + init({ ...initOptions, integrations: withEveIntegration(initOptions.integrations) }); + }, + events: { + 'turn.started': event => setConversationIdFromSession(event.sessionId), + 'step.attempt.started': event => setConversationIdFromSession(event.scope.sessionId), + }, + }; +} + +function withEveIntegration(integrations: NodeOptions['integrations']): NodeOptions['integrations'] { + const eve = eveIntegration(); + if (typeof integrations === 'function') { + return (defaults: Integration[]) => [...integrations(defaults), eve]; + } + return [...(integrations ?? []), eve]; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 48e4c1d8a993..c4f2edc27610 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -220,7 +220,8 @@ export { defaultStackParser, getSentryRelease } from './sdk/api'; export { makeNodeTransport } from './transports'; export { createGetModuleFromFilename } from './utils/module'; -export { eveConversationHook, getInstrumentedModuleNames } from '@sentry/server-utils'; +export { eveConversationHook, eveIntegration, getInstrumentedModuleNames } from '@sentry/server-utils'; +export { eveInstrumentation } from './eve'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { processSessionIntegration } from './integrations/processSession'; diff --git a/packages/node/test/eve.test.ts b/packages/node/test/eve.test.ts new file mode 100644 index 000000000000..11176944a0a5 --- /dev/null +++ b/packages/node/test/eve.test.ts @@ -0,0 +1,42 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { eveInstrumentation } from '../src/eve'; + +describe('eveInstrumentation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('exposes a setup and turn.started / step.attempt.started events', () => { + const provider = eveInstrumentation(); + + expect(typeof provider.setup).toBe('function'); + expect(Object.keys(provider.events).sort()).toEqual(['step.attempt.started', 'turn.started']); + }); + + test('sets the session id as the conversation id from turn.started (event.sessionId)', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation().events['turn.started']({ sessionId: 'sess_abc' }); + + expect(setConversationId).toHaveBeenCalledWith('sess_abc'); + }); + + test('sets it from step.attempt.started (event.scope.sessionId) too', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation().events['step.attempt.started']({ scope: { sessionId: 'sess_resumed' } }); + + expect(setConversationId).toHaveBeenCalledWith('sess_resumed'); + }); + + test('honors a custom getConversationId', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation({ getConversationId: context => `conv-${context.session.id}` }).events['turn.started']({ + sessionId: 'xyz', + }); + + expect(setConversationId).toHaveBeenCalledWith('conv-xyz'); + }); +}); diff --git a/packages/server-utils/src/eve.ts b/packages/server-utils/src/eve.ts index 8ab70981f41b..5c35d858542d 100644 --- a/packages/server-utils/src/eve.ts +++ b/packages/server-utils/src/eve.ts @@ -1,4 +1,5 @@ -import { setConversationId } from '@sentry/core'; +import { defineIntegration, setConversationId } from '@sentry/core'; +import { markEveGenAiRecordingDefault } from './integrations/vercel-ai/gen-ai-recording-mode'; /** * The subset of eve's hook context (`HookContext` from `eve/hooks`) this helper reads. Typed @@ -60,3 +61,15 @@ export function eveConversationHook(options: EveConversationHookOptions = {}): { }, }; } + +/** + * Integration that handles tracing instrumentation of [eve](https://eve.dev) apps. + */ +export const eveIntegration = defineIntegration(() => { + return { + name: 'Eve' as const, + setup(client) { + markEveGenAiRecordingDefault(client); + }, + }; +}); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 65620fd403cb..54e82b0dae6f 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -13,7 +13,7 @@ export type { InstrumentationConfig } from './orchestrion/apmTypes'; // `orchestrion/bundler/moduleInjectedTransform.ts`); it is a plain runtime // helper with no orchestrion build-time dependency. export { orchestrionModuleInjected } from './utils/moduleInjected'; -export { eveConversationHook } from './eve'; +export { eveConversationHook, eveIntegration } from './eve'; export { getInstrumentedModuleNames } from './orchestrion/config'; export { fastifyIntegration, diff --git a/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts b/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts new file mode 100644 index 000000000000..719b6830dac7 --- /dev/null +++ b/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts @@ -0,0 +1,22 @@ +import type { Client } from '@sentry/core'; + +const eveRecordingClients = new WeakSet(); + +/** + * Mark this client as running under eve, so the Vercel AI channel subscriber records gen_ai + * inputs/outputs by default. + * + * eve stamps every AI SDK call with `recordInputs`/`recordOutputs: false` as its framework default + * (its content-capture default is "public conversations only"). That per-call flag on the + * `ai:telemetry` channel event otherwise outranks the global `dataCollection.genAI` setting, so + * message content would be dropped even when the user wants it. In this mode the per-call flag is + * treated as eve's default rather than an end-user decision: an explicit `recordInputs`/`recordOutputs` + * on the integration or a `dataCollection.genAI` setting still takes precedence. + */ +export function markEveGenAiRecordingDefault(client: Client): void { + eveRecordingClients.add(client); +} + +export function isEveGenAiRecordingDefault(client: Client): boolean { + return eveRecordingClients.has(client); +} diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 924cde857b6b..76b1ddda69fc 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -49,6 +49,7 @@ import { } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes'; +import { isEveGenAiRecordingDefault } from './gen-ai-recording-mode'; import type { GenAiOptions } from '../../ai/core/utils'; import { getProviderMetadataAttributes, LAST_STEP_ONLY_USAGE_KEYS } from '../../ai/vercel-ai'; import { WORKERS_AI_INTEGRATION_NAME } from '../../ai/workers-ai/constants'; @@ -750,11 +751,13 @@ function getRecordingOptions( recordInputs: boolean; recordOutputs: boolean; } { - const genAI = getClient()?.getDataCollectionOptions().genAI; + const client = getClient(); + const genAI = client?.getDataCollectionOptions().genAI; + const eveMode = client ? isEveGenAiRecordingDefault(client) : false; return { - recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs), - recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs), + recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs, eveMode), + recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs, eveMode), }; } @@ -767,11 +770,23 @@ function getRecordingOptions( * `experimental_telemetry: { isEnabled: true }`. The `ai:telemetry` channel does not expose `isEnabled` * (nor a resolved recording flag), so that per-call default cannot be reproduced here — v7 users who * want inputs/outputs recorded must enable `dataCollection.genAI` or set `recordInputs`/`recordOutputs`. + * + * Under `eveMode` (set by `eveIntegration()`) the per-call flag is eve's blanket framework default + * rather than an end-user decision, so it is skipped: an explicit `dataCollection.genAI` still wins, + * otherwise recording defaults to `true`. An integration-level option outranks both regardless. */ -function resolveRecording(integrationOption: unknown, perCallOption: unknown, globalDefault: unknown): boolean { +function resolveRecording( + integrationOption: unknown, + perCallOption: unknown, + globalDefault: unknown, + eveMode = false, +): boolean { if (typeof integrationOption === 'boolean') { return integrationOption; } + if (eveMode) { + return typeof globalDefault === 'boolean' ? globalDefault : true; + } if (typeof perCallOption === 'boolean') { return perCallOption; } diff --git a/packages/server-utils/test/eve.test.ts b/packages/server-utils/test/eve.test.ts index 2212f7b8e387..1e8b8dfa4bc2 100644 --- a/packages/server-utils/test/eve.test.ts +++ b/packages/server-utils/test/eve.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { eveConversationHook } from '../src/eve'; +import { eveConversationHook, eveIntegration } from '../src/eve'; +import { isEveGenAiRecordingDefault } from '../src/integrations/vercel-ai/gen-ai-recording-mode'; describe('eveConversationHook', () => { afterEach(() => { @@ -53,3 +54,19 @@ describe('eveConversationHook', () => { expect(setConversationId).toHaveBeenCalledWith(returnValue); }); }); + +describe('eveIntegration', () => { + test('is named Eve', () => { + expect(eveIntegration().name).toBe('Eve'); + }); + + test('marks the client so the Vercel AI subscriber records gen_ai content by default', () => { + const client = {} as SentryCore.Client; + + expect(isEveGenAiRecordingDefault(client)).toBe(false); + + eveIntegration().setup?.(client); + + expect(isEveGenAiRecordingDefault(client)).toBe(true); + }); +}); diff --git a/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts b/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts new file mode 100644 index 000000000000..4c40743e4aea --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts @@ -0,0 +1,64 @@ +import { GEN_AI_INPUT_MESSAGES } from '@sentry/conventions/attributes'; +import { getClient, getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { markEveGenAiRecordingDefault } from '../../../src/integrations/vercel-ai/gen-ai-recording-mode'; +import { createSpanFromMessage } from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +// eve stamps every AI SDK call with `recordInputs: false`, which the channel subscriber otherwise +// honors over the global default. `eveIntegration()` (via `markEveGenAiRecordingDefault`) flips that so +// content is recorded by default under eve, while explicit settings still win. +describe('Vercel AI recording under eve', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(dataCollection: { genAI?: { inputs?: boolean } } = {}): void { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + dataCollection, + }), + ); + setCurrentClient(client); + client.init(); + } + + // A `generateText` operation carrying input messages and eve's per-call `recordInputs: false`. + function recordedInputMessages(): unknown { + const message = { + type: 'generateText', + event: { + messages: [{ role: 'user', content: 'What is the weather in Paris?' }], + recordInputs: false, + }, + } as Parameters[0]; + const span = createSpanFromMessage(message, {}); + return (spanToStaticSpanJSON(span!).data ?? {})[GEN_AI_INPUT_MESSAGES]; + } + + it('honors the per-call recordInputs:false without eve mode', () => { + setupClient(); + + expect(recordedInputMessages()).toBeUndefined(); + }); + + it('records inputs by default under eve despite the per-call recordInputs:false', () => { + setupClient(); + markEveGenAiRecordingDefault(getClient()!); + + expect(recordedInputMessages()).toContain('What is the weather in Paris?'); + }); + + it('still honors an explicit dataCollection.genAI opt-out under eve', () => { + setupClient({ genAI: { inputs: false } }); + markEveGenAiRecordingDefault(getClient()!); + + expect(recordedInputMessages()).toBeUndefined(); + }); +});