Skip to content

Commit f66fbb0

Browse files
committed
feat(sdk): stamp gen_ai.conversation.id on chat spans and metrics
Adds `taskContext.setConversationId()` to the core API so the chat.task and chat.agent run boots can flag a chat run with the OTel GenAI `gen_ai.conversation.id` semantic attribute. The TaskContextSpanProcessor stamps it on every span at start and TaskContextMetricExporter copies it into every metric data point — `ctx.*` is filtered by the OTLP ingest, but `gen_ai.*` survives to the stored attributes column without a schema migration. Lets dashboard span/metric views correlate by chat conversation across multiple runs. Closes TRI-9082.
1 parent d1b27a9 commit f66fbb0

6 files changed

Lines changed: 150 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Stamp `gen_ai.conversation.id` (the chat id) on every span and metric emitted from inside a `chat.task` or `chat.agent` run. Lets you filter dashboard spans, runs, and metrics by the chat conversation that produced them — independent of the run boundary, so multi-run chats correlate cleanly. No code changes required on the user side.

packages/core/src/v3/semanticInternalAttributes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const SemanticInternalAttributes = {
1313
RUN_ID: "ctx.run.id",
1414
RUN_IS_TEST: "ctx.run.isTest",
1515
RUN_IS_REPLAY: "ctx.run.isReplay",
16+
GEN_AI_CONVERSATION_ID: "gen_ai.conversation.id",
1617
ORIGINAL_RUN_ID: "$original_run_id",
1718
BATCH_ID: "ctx.batch.id",
1819
TASK_SLUG: "ctx.task.id",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { unregisterGlobal } from "../utils/globals.js";
3+
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
4+
import { TaskContextAPI } from "./index.js";
5+
6+
const FAKE_CTX = {
7+
attempt: { id: "attempt_1", number: 1, startedAt: new Date(), status: "EXECUTING" as const },
8+
run: {
9+
id: "run_1",
10+
payload: undefined,
11+
payloadType: "application/json",
12+
context: undefined,
13+
createdAt: new Date(),
14+
tags: [],
15+
isTest: false,
16+
isReplay: false,
17+
startedAt: new Date(),
18+
durationMs: 0,
19+
costInCents: 0,
20+
baseCostInCents: 0,
21+
},
22+
task: { id: "my-task", filePath: "src/trigger/task.ts", exportName: "myTask" },
23+
queue: { id: "queue_1", name: "default" },
24+
environment: { id: "env_1", slug: "dev", type: "DEVELOPMENT" as const },
25+
organization: { id: "org_1", slug: "acme", name: "Acme" },
26+
project: { id: "proj_1", ref: "proj_xyz", slug: "demo", name: "Demo" },
27+
machine: {
28+
name: "small-1x" as const,
29+
cpu: 0.5,
30+
memory: 0.5,
31+
centsPerMs: 0.0001,
32+
},
33+
} as never;
34+
35+
const FAKE_WORKER = { id: "worker_1", version: "1.0.0", contentHash: "abc" } as never;
36+
37+
describe("TaskContextAPI conversation id", () => {
38+
afterEach(() => {
39+
unregisterGlobal("task-context");
40+
TaskContextAPI.getInstance().setConversationId(undefined);
41+
});
42+
43+
it("returns no conversation attribute when setConversationId was never called", () => {
44+
const api = TaskContextAPI.getInstance();
45+
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
46+
47+
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined();
48+
});
49+
50+
it("includes gen_ai.conversation.id after setConversationId", () => {
51+
const api = TaskContextAPI.getInstance();
52+
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
53+
54+
api.setConversationId("chat_123");
55+
56+
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBe("chat_123");
57+
});
58+
59+
it("clears the conversation attribute when called with undefined", () => {
60+
const api = TaskContextAPI.getInstance();
61+
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
62+
api.setConversationId("chat_123");
63+
64+
api.setConversationId(undefined);
65+
66+
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined();
67+
expect(api.conversationId).toBeUndefined();
68+
});
69+
70+
it("returns no attributes when there is no task context", () => {
71+
const api = TaskContextAPI.getInstance();
72+
api.setConversationId("chat_123");
73+
74+
expect(api.attributes).toEqual({});
75+
});
76+
77+
it("clears conversation id when a new task context is registered (warm restart)", () => {
78+
const api = TaskContextAPI.getInstance();
79+
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
80+
api.setConversationId("chat_old");
81+
82+
api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER });
83+
84+
expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined();
85+
});
86+
});

packages/core/src/v3/taskContext/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const API_NAME = "task-context";
99
export class TaskContextAPI {
1010
private static _instance?: TaskContextAPI;
1111
private _runDisabled = false;
12+
private _conversationId?: string;
1213

1314
private constructor() {}
1415

@@ -45,13 +46,27 @@ export class TaskContextAPI {
4546
return {
4647
...this.contextAttributes,
4748
...this.workerAttributes,
49+
...this.conversationAttributes,
4850
[SemanticInternalAttributes.WARM_START]: !!this.isWarmStart,
4951
};
5052
}
5153

5254
return {};
5355
}
5456

57+
get conversationAttributes(): Attributes {
58+
if (!this._conversationId) return {};
59+
return { [SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]: this._conversationId };
60+
}
61+
62+
get conversationId(): string | undefined {
63+
return this._conversationId;
64+
}
65+
66+
public setConversationId(conversationId: string | undefined): void {
67+
this._conversationId = conversationId || undefined;
68+
}
69+
5570
get resourceAttributes(): Attributes {
5671
if (this.ctx) {
5772
return {
@@ -109,6 +124,11 @@ export class TaskContextAPI {
109124

110125
public setGlobalTaskContext(taskContext: TaskContext): boolean {
111126
this._runDisabled = false;
127+
// Each run boot re-registers the global; clear any conversation id
128+
// left over from a previous run on this warm-restarted process so
129+
// attributes don't bleed across runs that don't call
130+
// `setConversationId` themselves.
131+
this._conversationId = undefined;
112132
return registerGlobal(API_NAME, taskContext, true);
113133
}
114134

packages/core/src/v3/taskContext/otelProcessors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ export class TaskContextSpanProcessor implements SpanProcessor {
3636
if (!taskContext.isRunDisabled && taskContext.ctx.run.tags?.length) {
3737
span.setAttribute(SemanticInternalAttributes.RUN_TAGS, taskContext.ctx.run.tags);
3838
}
39+
40+
// Stamp `gen_ai.conversation.id` (OTel GenAI semantic convention)
41+
// directly on every span so it survives the OTLP ingest's `ctx.*`
42+
// strip and lands in the stored attributes column without a schema
43+
// migration.
44+
if (taskContext.conversationId) {
45+
span.setAttribute(
46+
SemanticInternalAttributes.GEN_AI_CONVERSATION_ID,
47+
taskContext.conversationId
48+
);
49+
}
3950
}
4051

4152
if (!isPartialSpan(span) && !skipPartialSpan(span)) {
@@ -178,6 +189,11 @@ export class TaskContextMetricExporter implements PushMetricExporter {
178189
contextAttrs[SemanticInternalAttributes.RUN_TAGS] = ctx.run.tags;
179190
}
180191

192+
if (taskContext.conversationId) {
193+
contextAttrs[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID] =
194+
taskContext.conversationId;
195+
}
196+
181197
const modified: ResourceMetrics = {
182198
resource: metrics.resource,
183199
scopeMetrics: metrics.scopeMetrics.map((scope) => ({

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,20 @@ function getChatSession(): SessionHandle {
149149
return handle;
150150
}
151151

152+
/**
153+
* Stamp `gen_ai.conversation.id` on the active span at chat-run boot.
154+
* The run-level span is already alive when the run callback fires, so
155+
* `TaskContextSpanProcessor.onStart` (which stamps subsequent spans
156+
* automatically) won't catch it — set explicitly here.
157+
*/
158+
function stampConversationIdOnActiveSpan(
159+
conversationId: string | undefined,
160+
span = trace.getActiveSpan()
161+
): void {
162+
if (!span || !conversationId) return;
163+
span.setAttribute(SemanticInternalAttributes.GEN_AI_CONVERSATION_ID, conversationId);
164+
}
165+
152166
type ToolResultContent = Array<
153167
| {
154168
type: "text";
@@ -3697,6 +3711,8 @@ function chatCustomAgent<
36973711
// No client-side upsert needed.
36983712
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
36993713
locals.set(chatAgentRunContextKey, runOptions.ctx);
3714+
taskContext.setConversationId(payload.chatId);
3715+
stampConversationIdOnActiveSpan(payload.chatId);
37003716
return userRun(payload, runOptions);
37013717
},
37023718
});
@@ -3779,12 +3795,13 @@ function chatAgent<
37793795
// `chat.createStartSessionAction` or browser-direct) before this
37803796
// run is triggered — no client-side upsert needed here.
37813797
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
3798+
taskContext.setConversationId(payload.chatId);
37823799

3783-
// Set gen_ai.conversation.id on the run-level span for dashboard context
3800+
// Stamp `gen_ai.conversation.id` on the run-level span. Every
3801+
// nested span inherits the same attribute via
3802+
// `TaskContextSpanProcessor.onStart`.
37843803
const activeSpan = trace.getActiveSpan();
3785-
if (activeSpan) {
3786-
activeSpan.setAttribute("gen_ai.conversation.id", payload.chatId);
3787-
}
3804+
stampConversationIdOnActiveSpan(payload.chatId, activeSpan);
37883805

37893806
// Store static UIMessageStream options in locals so resolveUIMessageStreamOptions() can read them
37903807
if (uiMessageStreamOptions) {

0 commit comments

Comments
 (0)