Skip to content

Commit 14003f0

Browse files
feat(api): upgrade AI SDK to v7 and trace AI calls with GenAI semantic conventions (#3261)
## Summary Upgrades the AI SDK to v7 in `packages/api` and `packages/hdx-eval`, and moves AI-call tracing to the new `@ai-sdk/otel` integration. **Why:** v7 no longer emits OpenTelemetry spans on its own. Without a registered integration, AI assistant calls would stop appearing in traces. **Tracing** - `api-app.ts` registers `@ai-sdk/otel`'s `OpenTelemetry` integration once at startup (`utils/aiTelemetry.ts`); repeat calls are no-ops. - Spans now follow the GenAI semantic conventions (`invoke_agent` / `step` / `chat` / `execute_tool` with `gen_ai.*` attributes, tracer scope `gen_ai`) instead of the legacy `ai.*` shape. The LLM dashboard already reads these attributes. - v7 removed `telemetry.metadata`. Attribution now goes through `runtimeContext`, and an `enrichSpan` hook stamps `hyperdx.team.id`, `user.id` and (when known) `gen_ai.conversation.id` on every span. - v7 hides `runtimeContext` keys from integrations unless they are listed in `includeRuntimeContext`; `llmTelemetry()` sets both from a single key list. - `ai.telemetry.metadata.*` is no longer emitted, so saved searches or alerts keyed on it, or on `ai.*` span names, need updating. **Packaging** - The v7 packages are ESM-only. The compiled CJS output loads them through Node's `require(esm)` (Node ≥ 22.12). Jest transpiles `ai`, `@ai-sdk/*` and `@workflow/serde`. - hdx-eval's tsconfig moves from `module: node16`, which refuses to compile imports of ESM packages, to `node20`. - hdx-eval's judge passes `instructions` instead of the deprecated `system`. ### How to test locally 1. `cd packages/api && yarn ci:unit` and `cd packages/hdx-eval && yarn ci:unit` 2. Run the chart assistant with an AI provider configured, then check `otel_traces` for `ScopeName = 'gen_ai'` spans carrying `hyperdx.team.id` / `user.id`. New tests: - `aiTelemetry.test.ts` runs a real `generateText` against an in-memory span exporter. It checks attribution and `gen_ai.*` content attributes on every span, and that global registration emits each span exactly once. - `judge.sdk.test.ts` drives the judge through the real SDK with a mock model. It checks that the rubric reaches the model as the system message and that v7 token usage maps correctly.
1 parent 07c9365 commit 14003f0

17 files changed

Lines changed: 521 additions & 118 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
feat: upgrade the AI SDK to v7 and trace AI calls with GenAI semantic conventions
6+
7+
AI assistant calls now emit OpenTelemetry spans that follow the GenAI semantic conventions (`invoke_agent` / `chat` / `execute_tool` spans with `gen_ai.*` attributes) instead of the legacy `ai.*` span shape. Team and user attribution moves from `ai.telemetry.metadata.*` to `hyperdx.team.id` and `user.id` (plus `gen_ai.conversation.id` when a session is known). Saved searches, alerts or dashboards keyed on the old `ai.*` span names or attributes need updating.

‎packages/api/jest.config.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const tsJestTransformCfg = createJsWithTsPreset({
55
// TypeScript 6 requires an explicit rootDir when compiling a subset of
66
// files (ts-jest compiles per-file), otherwise it errors with TS5011.
77
rootDir: './src',
8+
// The AI SDK (v7+) ships ESM-only builds that Jest's CJS loader can't
9+
// parse, so they are transpiled like source (see transformIgnorePatterns).
10+
allowJs: true,
811
},
912
});
1013

@@ -22,4 +25,6 @@ module.exports = {
2225
moduleNameMapper: {
2326
'@/(.*)$': '<rootDir>/$1',
2427
},
28+
// Keep in sync with jest.int.config.js.
29+
transformIgnorePatterns: ['/node_modules/(?!(ai|@ai-sdk|@workflow/serde)/)'],
2530
};

‎packages/api/jest.int.config.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ module.exports = {
2121
'^.+\\.mjs$': esmDepsTransformCfg.transform['^.+\\.[tj]sx?$'],
2222
},
2323
moduleFileExtensions: ['js', 'mjs', 'json', 'ts', 'tsx', 'node'],
24+
// Superset of the unit config's list (AI SDK packages) plus the proxy deps.
2425
transformIgnorePatterns: [
25-
'/node_modules/(?!(http-proxy-middleware|httpxy|is-plain-obj)/)',
26+
'/node_modules/(?!(ai|@ai-sdk|@workflow/serde|http-proxy-middleware|httpxy|is-plain-obj)/)',
2627
],
2728
testMatch: ['**/__tests__/**/*.int.test.ts?(x)'],
2829
// Override the unit config's ignore list: it excludes `.int.test.ts`, which

‎packages/api/package.json‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
}
1717
},
1818
"dependencies": {
19-
"@ai-sdk/anthropic": "^3.0.58",
20-
"@ai-sdk/openai": "^3.0.47",
19+
"@ai-sdk/anthropic": "^4.0.65",
20+
"@ai-sdk/openai": "^4.0.78",
21+
"@ai-sdk/otel": "^1.0.116",
2122
"@braintree/sanitize-url": "^7.1.1",
2223
"@clickhouse/client-common": "1.23.0-head.fae5998.1",
2324
"@esm2cjs/p-queue": "^7.3.0",
@@ -30,7 +31,7 @@
3031
"@opentelemetry/sdk-metrics": "^1.30.1",
3132
"@slack/webhook": "^7.0.0",
3233
"@types/node": "^22.15.18",
33-
"ai": "^6.0.116",
34+
"ai": "^7.0.116",
3435
"aws4": "^1.13.2",
3536
"chrono-node": "^2.9.0",
3637
"compression": "^1.7.4",
@@ -65,6 +66,7 @@
6566
"zod-express-middleware": "^1.4.0"
6667
},
6768
"devDependencies": {
69+
"@opentelemetry/sdk-trace-base": "^1.30.1",
6870
"@stoplight/spectral-cli": "^6.15.0",
6971
"@types/compression": "^1.7.3",
7072
"@types/cors": "^2.8.14",

‎packages/api/src/api-app.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ import savedSearchRouter from './routers/api/savedSearch';
1919
import sourcesRouter from './routers/api/sources';
2020
import externalRoutersV2 from './routers/external-api/v2';
2121
import usageStats from './tasks/usageStats';
22+
import { registerAITelemetry } from './utils/aiTelemetry';
2223
import logger, { expressLogger } from './utils/logger';
2324
import passport from './utils/passport';
2425

26+
registerAITelemetry();
27+
2528
const app: express.Application = express();
2629

2730
const sess: session.SessionOptions & { cookie: session.CookieOptions } = {

‎packages/api/src/controllers/__tests__/ai.test.ts‎

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function setConfig(overrides: Record<string, string | undefined>) {
4848
Object.assign(mockConfig, overrides);
4949
}
5050

51-
import { getAIModel, llmTelemetry } from '@/controllers/ai';
51+
import { getAIModel } from '@/controllers/ai';
5252

5353
beforeEach(() => {
5454
setConfig({});
@@ -252,21 +252,3 @@ describe('openai provider', () => {
252252
});
253253
});
254254
});
255-
256-
describe('llmTelemetry', () => {
257-
it('enables telemetry and passes through string metadata', () => {
258-
expect(
259-
llmTelemetry({ sessionId: 'notebook-1', teamId: 't1', userId: 'u1' }),
260-
).toEqual({
261-
isEnabled: true,
262-
metadata: { sessionId: 'notebook-1', teamId: 't1', userId: 'u1' },
263-
});
264-
});
265-
266-
it('drops nullish metadata values', () => {
267-
expect(llmTelemetry({ sessionId: undefined, teamId: 't1' })).toEqual({
268-
isEnabled: true,
269-
metadata: { teamId: 't1' },
270-
});
271-
});
272-
});

‎packages/api/src/controllers/ai.ts‎

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,28 +73,6 @@ export function getAIModel(): LanguageModel {
7373
}
7474
}
7575

76-
/**
77-
* Vercel AI SDK telemetry settings for LLM calls. The SDK flattens each
78-
* metadata entry onto every span of the call as
79-
* `ai.telemetry.metadata.<key>`, which the LLM dashboard reads:
80-
* `sessionId` groups the call's spans into a session on the Sessions tab
81-
* (pass a stable conversation-scoped id, e.g. a notebook id) and `userId`
82-
* feeds per-user attribution. Nullish values are dropped.
83-
*/
84-
export function llmTelemetry(metadata: Record<string, string | undefined>): {
85-
isEnabled: boolean;
86-
metadata: Record<string, string>;
87-
} {
88-
return {
89-
isEnabled: true,
90-
metadata: Object.fromEntries(
91-
Object.entries(metadata).filter(
92-
(entry): entry is [string, string] => entry[1] != null,
93-
),
94-
),
95-
};
96-
}
97-
9876
export async function getAIMetadata(source: ISource) {
9977
const connectionId = source.connection.toString();
10078

‎packages/api/src/routers/api/ai.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import {
1111
getAIMetadata,
1212
getAIModel,
1313
getChartConfigFromResolvedConfig,
14-
llmTelemetry,
1514
} from '@/controllers/ai';
1615
import { getSource } from '@/controllers/sources';
1716
import { getNonNullUserWithTeam } from '@/middleware/auth';
17+
import { llmTelemetry } from '@/utils/aiTelemetry';
1818
import { Api404Error, Api500Error } from '@/utils/errors';
1919
import { withOperationMetrics } from '@/utils/instrumentation';
2020
import logger from '@/utils/logger';
@@ -106,11 +106,7 @@ ${JSON.stringify(allFieldsWithKeys.slice(0, 200).map(f => ({ field: f.key, type:
106106
output: Output.object({
107107
schema: AssistantLineTableConfigSchema,
108108
}),
109-
// teamId/userId flatten to ai.telemetry.metadata.* span
110-
// attributes for attribution on the LLM dashboard. Callers
111-
// with a conversation-scoped id should also pass sessionId so
112-
// their calls group into a session (see llmTelemetry).
113-
experimental_telemetry: llmTelemetry({
109+
...llmTelemetry({
114110
teamId: teamId.toString(),
115111
userId: userId.toString(),
116112
}),
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { trace } from '@opentelemetry/api';
2+
import {
3+
BasicTracerProvider,
4+
InMemorySpanExporter,
5+
SimpleSpanProcessor,
6+
} from '@opentelemetry/sdk-trace-base';
7+
import { generateText, isStepCount, tool } from 'ai';
8+
import { MockLanguageModelV4 } from 'ai/test';
9+
import { z } from 'zod';
10+
11+
import {
12+
createAITelemetry,
13+
enrichLLMSpan,
14+
type LLMAttribution,
15+
llmTelemetry,
16+
registerAITelemetry,
17+
} from '@/utils/aiTelemetry';
18+
19+
const usage: Awaited<ReturnType<MockLanguageModelV4['doGenerate']>>['usage'] = {
20+
inputTokens: {
21+
total: 10,
22+
noCache: 10,
23+
cacheRead: undefined,
24+
cacheWrite: undefined,
25+
},
26+
outputTokens: { total: 5, text: 5, reasoning: undefined },
27+
};
28+
29+
// Step 1 calls a tool, step 2 answers, so the call produces operation, step,
30+
// chat and execute_tool spans.
31+
const createModel = () => {
32+
let call = 0;
33+
return new MockLanguageModelV4({
34+
provider: 'anthropic',
35+
modelId: 'mock-model',
36+
doGenerate: async () => {
37+
call += 1;
38+
return call === 1
39+
? {
40+
content: [
41+
{
42+
type: 'tool-call',
43+
toolCallId: 'tc-1',
44+
toolName: 'lookup',
45+
input: '{"q":"x"}',
46+
},
47+
],
48+
finishReason: { unified: 'tool-calls', raw: undefined },
49+
usage,
50+
warnings: [],
51+
}
52+
: {
53+
content: [{ type: 'text', text: 'done' }],
54+
finishReason: { unified: 'stop', raw: undefined },
55+
usage,
56+
warnings: [],
57+
};
58+
},
59+
});
60+
};
61+
62+
const runToolCall = (telemetry: ReturnType<typeof llmTelemetry>) =>
63+
generateText({
64+
model: createModel(),
65+
prompt: 'hi',
66+
tools: {
67+
lookup: tool({
68+
inputSchema: z.object({ q: z.string() }),
69+
execute: async ({ q }) => ({ q }),
70+
}),
71+
},
72+
stopWhen: isStepCount(2),
73+
...telemetry,
74+
});
75+
76+
const runTracedCall = async (attribution: LLMAttribution) => {
77+
const exporter = new InMemorySpanExporter();
78+
const provider = new BasicTracerProvider({
79+
spanProcessors: [new SimpleSpanProcessor(exporter)],
80+
});
81+
await runToolCall(
82+
llmTelemetry(attribution, {
83+
functionId: 'test.fn',
84+
integrations: createAITelemetry({ tracer: provider.getTracer('test') }),
85+
}),
86+
);
87+
return exporter.getFinishedSpans();
88+
};
89+
90+
const operationNames = (spans: { attributes: Record<string, unknown> }[]) =>
91+
spans.map(s => s.attributes['gen_ai.operation.name']).sort();
92+
93+
describe('AI SDK OpenTelemetry integration', () => {
94+
it('stamps attribution onto every span of the call', async () => {
95+
const spans = await runTracedCall({
96+
sessionId: 'session-1',
97+
teamId: 'team-1',
98+
userId: 'user-1',
99+
});
100+
101+
const operations = spans.map(s => s.attributes['gen_ai.operation.name']);
102+
expect(operations).toEqual(
103+
expect.arrayContaining(['chat', 'execute_tool']),
104+
);
105+
for (const span of spans) {
106+
expect(span.attributes).toMatchObject({
107+
'gen_ai.conversation.id': 'session-1',
108+
'hyperdx.team.id': 'team-1',
109+
'user.id': 'user-1',
110+
});
111+
}
112+
});
113+
114+
it('omits the session attribute when only team and user are known', async () => {
115+
// The chart assistant has no conversation-scoped id.
116+
const spans = await runTracedCall({ teamId: 'team-1', userId: 'user-1' });
117+
118+
expect(spans.length).toBeGreaterThan(0);
119+
for (const span of spans) {
120+
expect(span.attributes).toMatchObject({
121+
'hyperdx.team.id': 'team-1',
122+
'user.id': 'user-1',
123+
});
124+
expect(span.attributes).not.toHaveProperty('gen_ai.conversation.id');
125+
}
126+
});
127+
128+
it('emits GenAI semconv attributes the LLM dashboard reads', async () => {
129+
const spans = await runTracedCall({ sessionId: 'session-1' });
130+
131+
const chat = spans.find(
132+
s => s.attributes['gen_ai.operation.name'] === 'chat',
133+
);
134+
expect(chat?.attributes).toMatchObject({
135+
'gen_ai.provider.name': 'anthropic',
136+
'gen_ai.request.model': 'mock-model',
137+
'gen_ai.usage.input_tokens': 10,
138+
'gen_ai.usage.output_tokens': 5,
139+
});
140+
expect(
141+
spans.some(s => s.attributes['gen_ai.agent.name'] === 'test.fn'),
142+
).toBe(true);
143+
144+
// Message content feeds the dashboard's Messages tab.
145+
expect(chat?.attributes['gen_ai.input.messages']).toEqual(
146+
expect.stringContaining('"content":"hi"'),
147+
);
148+
expect(chat?.attributes['gen_ai.output.messages']).toEqual(
149+
expect.stringContaining('"name":"lookup"'),
150+
);
151+
const toolSpan = spans.find(
152+
s => s.attributes['gen_ai.operation.name'] === 'execute_tool',
153+
);
154+
expect(toolSpan?.attributes).toMatchObject({
155+
'gen_ai.tool.name': 'lookup',
156+
'gen_ai.tool.call.arguments': expect.stringContaining('"q":"x"'),
157+
});
158+
});
159+
});
160+
161+
describe('enrichLLMSpan', () => {
162+
const enrich = (runtimeContext: Record<string, unknown> | undefined) =>
163+
enrichLLMSpan({
164+
spanType: 'languageModel',
165+
operationId: 'ai.generateText',
166+
callId: 'call-1',
167+
runtimeContext,
168+
});
169+
170+
it('skips missing, empty and non-string values', () => {
171+
expect(enrich({ sessionId: '', teamId: 42, userId: undefined })).toEqual(
172+
{},
173+
);
174+
expect(enrich(undefined)).toEqual({});
175+
});
176+
177+
it('ignores unrelated runtime context keys', () => {
178+
expect(enrich({ sessionId: 's1', other: 'x' })).toEqual({
179+
'gen_ai.conversation.id': 's1',
180+
});
181+
});
182+
});
183+
184+
describe('registerAITelemetry', () => {
185+
afterAll(() => trace.disable());
186+
187+
it('emits each span once through the global tracer provider, even if registered twice', async () => {
188+
const perCall = operationNames(
189+
await runTracedCall({ sessionId: 'session-1' }),
190+
);
191+
192+
const exporter = new InMemorySpanExporter();
193+
trace.setGlobalTracerProvider(
194+
new BasicTracerProvider({
195+
spanProcessors: [new SimpleSpanProcessor(exporter)],
196+
}),
197+
);
198+
registerAITelemetry();
199+
registerAITelemetry();
200+
await runToolCall(llmTelemetry({ sessionId: 'session-global' }));
201+
202+
const spans = exporter.getFinishedSpans();
203+
expect(perCall.length).toBeGreaterThan(0);
204+
expect(operationNames(spans)).toEqual(perCall);
205+
for (const span of spans) {
206+
expect(span.attributes['gen_ai.conversation.id']).toBe('session-global');
207+
}
208+
});
209+
});

0 commit comments

Comments
 (0)