-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(node): Add eveConversationHook() to link eve sessions as Sentry conversations
#24247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ac90610
dabdfba
6aad244
7abe9a8
4bf7875
6110680
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I guess this can also come from non-node packages?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }; | ||
|
Comment on lines
+51
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The Suggested FixAdd a check to ensure const conversationId = getConversationId ? getConversationId(context) : context.session.id;
if (conversationId) {
setConversationId(conversationId);
}Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }, | ||
| }; | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.