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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"ai": "^7.0.82",
"dataloader": "^2.2.3",
"eve": "^0.52.3",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Do we still support the older versions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically yeah through the setup that is documented here (nothing about this changes really), for simplicity I'd just say we support this though and keep to a single setup (they are sadly mutually exclusive)

"eve": "^0.63.0",
"zod": "4.5.4"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export {
withStreamedSpan,
metrics,
eveConversationHook,
eveInstrumentation,
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/node';

Expand Down
2 changes: 2 additions & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
eveConversationHook,
eveInstrumentation,
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/node';

Expand Down
2 changes: 2 additions & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
eveConversationHook,
eveInstrumentation,
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/node';

Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export {
instrumentCreateReactAgent,
vercelAIIntegration,
eveConversationHook,
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/server-utils';

Expand Down
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export {
postgresJsIntegration,
tediousIntegration,
eveConversationHook,
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/server-utils';
export { openTelemetryIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels';
Expand Down
2 changes: 2 additions & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
eveConversationHook,
eveInstrumentation,
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/node';

Expand Down
78 changes: 78 additions & 0 deletions packages/node/src/eve.ts
Original file line number Diff line number Diff line change
@@ -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),
},
Comment thread
cursor[bot] marked this conversation as resolved.
};
}

function withEveIntegration(integrations: NodeOptions['integrations']): NodeOptions['integrations'] {
const eve = eveIntegration();
if (typeof integrations === 'function') {
return (defaults: Integration[]) => [...integrations(defaults), eve];
}
return [...(integrations ?? []), eve];
}
3 changes: 2 additions & 1 deletion packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
42 changes: 42 additions & 0 deletions packages/node/test/eve.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
15 changes: 14 additions & 1 deletion packages/server-utils/src/eve.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
},
};
});
2 changes: 1 addition & 1 deletion packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Client } from '@sentry/core';

const eveRecordingClients = new WeakSet<Client>();

/**
* 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
};
}

Expand All @@ -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;
}
Expand Down
19 changes: 18 additions & 1 deletion packages/server-utils/test/eve.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading