Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
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());
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to
) && spansOfTrace.some(isAgentServerSpan),
);

await runAgentTurn(baseURL!, 'What is the weather in Paris?');
const sessionId = await runAgentTurn(baseURL!, 'What is the weather in Paris?');

const traceSpans = await traceSpansPromise;

Expand Down Expand Up @@ -66,6 +66,14 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to
// The tool returns `{ city, condition: 'Sunny', temperatureC: 22 }`.
expect(executeTool?.attributes?.['gen_ai.tool.call.result']?.value).toContain('Sunny');

// `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
// 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]) {
expect(span?.attributes?.['gen_ai.conversation.id']?.value).toBe(sessionId);
}

// The agent turn is captured as an http.server span on one of eve's two agent
// request paths (the other http.server spans — health and the event stream —
// are not in this trace).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import { expect } from '@playwright/test';
* settle, so the agent has finished and its spans have been flushed before we
* assert. eve runs the turn in a durable workflow, so the POST only needs to be
* accepted; we drain the event stream to know when the turn is done.
*
* Returns the eve session id, which the SDK also records as `gen_ai.conversation.id` on the turn's
* AI spans (see `agent/hooks/sentry.ts`), so a test can assert the two match.
*/
export async function runAgentTurn(baseURL: string, message: string): Promise<void> {
export async function runAgentTurn(baseURL: string, message: string): Promise<string> {
const createRes = await fetch(`${baseURL}/eve/v1/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down Expand Up @@ -36,4 +39,6 @@ export async function runAgentTurn(baseURL: string, message: string): Promise<vo
} finally {
clearTimeout(timer);
}

return sessionId;
}
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export {
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
metrics,
eveConversationHook,
} from '@sentry/node';

export { init } from './server/sdk';
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export {
withStaticSpan,
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
eveConversationHook,
} from '@sentry/node';

export {
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export {
withStaticSpan,
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
eveConversationHook,
} from '@sentry/node';

export {
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 @@ -134,6 +134,7 @@ export {
instrumentStateGraph,
instrumentCreateReactAgent,
vercelAIIntegration,
eveConversationHook,
} from '@sentry/server-utils';

export { instrumentWorkflowWithSentry } from './workflows';
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 @@ -144,6 +144,7 @@ export {
postgresIntegration,
postgresJsIntegration,
tediousIntegration,
eveConversationHook,
Comment thread
mydea marked this conversation as resolved.
} from '@sentry/server-utils';
export { openTelemetryIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels';
// Deprecated aliases kept for back-compat. Each forwards to the shared
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export {
withStaticSpan,
// oxlint-disable-next-line typescript/no-deprecated
withStreamedSpan,
eveConversationHook,
} from '@sentry/node';

export {
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export { makeNodeTransport } from './transports';
export { createGetModuleFromFilename } from './utils/module';

export { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes';
export { eveConversationHook } from '@sentry/server-utils';
export { httpServerIntegration } from './integrations/http/httpServerIntegration';
export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration';
export { processSessionIntegration } from './integrations/processSession';
Expand Down
62 changes: 62 additions & 0 deletions packages/server-utils/src/eve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { setConversationId } from '@sentry/core';

/**
* The subset of eve's hook context (`HookContext` from `eve/hooks`) this helper reads. Typed
* structurally rather than importing from `eve`, so the SDK carries no dependency on the framework —
* the shape is checked at the `defineHook(...)` call site in the user's app instead.
*/
interface EveHookContext {
session: { id: string };
}

type EveHookHandler = (event: unknown, context: EveHookContext) => void;

interface EveConversationHookOptions {
/**
* Derive the Sentry conversation id from the eve hook context. Defaults to the durable session id
* (`ctx.session.id`), which is stable across every turn of a session and so groups them into one
* conversation.
*/
getConversationId?: (context: EveHookContext) => string | null | undefined;
}

/**
* Builds the hook definition for an eve `agent/hooks/*.ts` file that tags a session's AI spans with
* a Sentry conversation id, linking every turn of the session in the Agents "Conversations" view.
*
* ```ts
* // agent/hooks/sentry.ts
* import * as Sentry from '@sentry/node';

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.

nit: I guess this can also come from non-node packages?

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.

jup, theoretically, but eve is mostly just documented for node, so I think it's fine to keep this as example/docs here?

* import { defineHook } from 'eve/hooks';
*
* export default defineHook(Sentry.eveConversationHook());
* ```
*
* The id is set on the isolation scope; the default (always-on) `conversationIdIntegration` then
* stamps `gen_ai.conversation.id` onto the gen_ai spans the Vercel AI instrumentation records for
* that turn. That indirection is why the id has to be set here and not on the AI call: eve's session
* id never reaches the AI SDK's telemetry channel, so it can only be attached via the scope.
*
* Subscribes to both `turn.started` and `step.started`. Each eve turn is a fresh durable-workflow
* request with its own isolation scope, and a turn that parks and resumes (approvals, compaction)
* resumes in yet another request; `turn.started` alone would miss the model calls after a resume.
* `step.started` fires before every model call, so together they cover each request that produces
* spans. Re-setting the same id is idempotent, so the overlap is harmless.
*/
export function eveConversationHook(options: EveConversationHookOptions = {}): {
events: Record<'turn.started' | 'step.started', EveHookHandler>;
} {
const { getConversationId } = options;

const setConversationIdFromContext: EveHookHandler = (_event, context) => {
const conversationId = getConversationId ? getConversationId(context) : context.session.id;
setConversationId(conversationId);
Comment thread
cursor[bot] marked this conversation as resolved.
};
Comment on lines +51 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The eveConversationHook calls setConversationId even when getConversationId returns null or undefined, which clears the conversation ID instead of being a no-op.
Severity: MEDIUM

Suggested Fix

Add a check to ensure setConversationId is only called when conversationId is a truthy value.

const conversationId = getConversationId ? getConversationId(context) : context.session.id;
if (conversationId) {
  setConversationId(conversationId);
}
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/server-utils/src/eve.ts#L51-L54

Potential issue: The `setConversationIdFromContext` function unconditionally calls
`setConversationId` with the result of the `getConversationId` callback. The callback is
typed to allow `null` or `undefined` return values, which are intended to be no-ops.
However, the `setConversationId` implementation treats `null` or `undefined` as a signal
to clear any existing conversation ID on the scope. This causes an unintended side
effect where opting out of setting a conversation ID for a specific event actively
deletes the existing one, breaking conversation grouping in Sentry.

Did we get this right? 👍 / 👎 to inform future reviews.

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.

should not be a no-op, if users use this, we use the conversation id as-is


return {
events: {
'turn.started': setConversationIdFromContext,
'step.started': setConversationIdFromContext,
},
};
}
1 change: 1 addition & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +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 {
fastifyIntegration,
// oxlint-disable-next-line typescript/no-deprecated
Expand Down
55 changes: 55 additions & 0 deletions packages/server-utils/test/eve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as SentryCore from '@sentry/core';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { eveConversationHook } from '../src/eve';

describe('eveConversationHook', () => {
afterEach(() => {
vi.restoreAllMocks();
});

test('subscribes to turn.started and step.started', () => {
const { events } = eveConversationHook();

expect(Object.keys(events).sort()).toEqual(['step.started', 'turn.started']);
});

test('sets the session id as the conversation id on turn.started', () => {
const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined);

eveConversationHook().events['turn.started'](undefined, { session: { id: 'sess_abc' } });

expect(setConversationId).toHaveBeenCalledWith('sess_abc');
});

test('sets it on step.started too, so model calls after a parked-turn resume are covered', () => {
const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined);

eveConversationHook().events['step.started'](undefined, { session: { id: 'sess_resumed' } });

expect(setConversationId).toHaveBeenCalledWith('sess_resumed');
});

test('honors a custom getConversationId', () => {
const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined);

eveConversationHook({ getConversationId: context => `conv-${context.session.id}` }).events['turn.started'](
undefined,
{ session: { id: 'xyz' } },
);

expect(setConversationId).toHaveBeenCalledWith('conv-xyz');
});

test.each([
['undefined', undefined],
['null', null],
])('unsets the conversation id when the resolver returns %s', (_label, returnValue) => {
const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined);

eveConversationHook({ getConversationId: () => returnValue }).events['turn.started'](undefined, {
session: { id: 'xyz' },
});

expect(setConversationId).toHaveBeenCalledWith(returnValue);
});
});
Loading