-
-
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
11932d1
0ec849a
1c2b20f
be4a3b1
9bd3558
af51063
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?
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. I agree to keep it for node only for now. I don't think Eve would be optimized too much for other runtimes - if we stay with Node for now we have the minimal setup working it's a great start |
||
| * 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.
|
||
| }; | ||
|
sentry[bot] marked this conversation as resolved.
|
||
|
|
||
| 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.