Skip to content

Commit 2b5c8a4

Browse files
authored
feat(ai): improve integration with advanced telemetry (#272)
1 parent 583fab4 commit 2b5c8a4

9 files changed

Lines changed: 896 additions & 37 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'evlog': minor
3+
---
4+
5+
Add AI SDK telemetry integration (`createEvlogIntegration`), cost estimation, and enriched embedding capture. `createEvlogIntegration()` implements the AI SDK's `TelemetryIntegration` interface to capture per-tool execution timing/success/errors and total generation wall time. Cost estimation computes `ai.estimatedCost` from a user-provided pricing map. `captureEmbed` now accepts model ID, dimensions, and batch count for richer embedding observability.

AGENTS.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,32 @@ export default defineEventHandler(async (event) => {
139139
})
140140
```
141141

142+
For deeper observability (tool execution timing, total generation wall time), add `createEvlogIntegration()`:
143+
144+
```typescript
145+
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
146+
147+
const ai = createAILogger(log, {
148+
cost: { 'claude-sonnet-4.6': { input: 3, output: 15 } },
149+
})
150+
151+
const agent = new ToolLoopAgent({
152+
model: ai.wrap('anthropic/claude-sonnet-4.6'),
153+
tools: { searchWeb, queryDatabase },
154+
experimental_telemetry: {
155+
isEnabled: true,
156+
integrations: [createEvlogIntegration(ai)],
157+
},
158+
})
159+
```
160+
161+
This adds `ai.tools` (per-tool `{ name, durationMs, success, error? }`), `ai.totalDurationMs`, and `ai.estimatedCost` to the wide event.
162+
142163
For embedding calls, use `captureEmbed`:
143164

144165
```typescript
145166
const { embedding, usage } = await embed({ model: embeddingModel, value: query })
146-
ai.captureEmbed({ usage })
167+
ai.captureEmbed({ usage, model: 'text-embedding-3-small', dimensions: 1536 })
147168
```
148169

149170
### Structured Errors

apps/docs/content/2.logging/5.ai-sdk.md

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,21 @@ links:
1616
variant: subtle
1717
---
1818

19-
`evlog/ai` gives you full AI observability by wrapping your model with middleware. Token usage, tool calls, streaming performance, cache hits, reasoning tokens, all captured into the wide event automatically.
19+
`evlog/ai` gives you full AI observability by wrapping your model with middleware and an optional telemetry integration. Token usage, tool calls, tool execution timing, streaming performance, cache hits, reasoning tokens, cost estimation — all captured into the wide event automatically.
2020

2121
::code-collapse
2222

2323
```txt [Prompt]
2424
Add AI observability to my app with evlog.
2525
2626
- Install the AI SDK: pnpm add ai
27-
- Import createAILogger from 'evlog/ai'
27+
- Import createAILogger and createEvlogIntegration from 'evlog/ai'
2828
- Create an AI logger with createAILogger(log) where log is your request logger
2929
- Wrap your model with ai.wrap('anthropic/claude-sonnet-4.6') and pass it to generateText, streamText, etc.
3030
- Token usage, tool calls, streaming metrics, and errors are captured automatically into the wide event
31-
- For embedding calls, use ai.captureEmbed({ usage }) after embed() or embedMany()
31+
- For deeper observability (tool execution timing, total generation wall time), add createEvlogIntegration(ai) to experimental_telemetry.integrations
32+
- For embedding calls, use ai.captureEmbed({ usage, model, dimensions, count }) after embed() or embedMany()
33+
- For cost estimation, pass a cost map: createAILogger(log, { cost: { 'claude-sonnet-4.6': { input: 3, output: 15 } } })
3234
- Works with all frameworks: Nuxt, Express, Hono, Fastify, NestJS, Elysia, standalone
3335
3436
Docs: https://www.evlog.dev/logging/ai-sdk
@@ -117,8 +119,8 @@ Your wide event now includes:
117119

118120
| Method | Description |
119121
|--------|-------------|
120-
| `wrap(model)` | Wraps a language model with middleware. Accepts a model string (e.g. `'anthropic/claude-sonnet-4.6'`) or a `LanguageModelV3` object. Works with `generateText`, `streamText`, `generateObject`, `streamObject`, and `ToolLoopAgent`. Also works with pre-wrapped models (e.g. from supermemory). |
121-
| `captureEmbed(result)` | Manually captures token usage from `embed()` or `embedMany()` results (embedding models use a different type). |
122+
| `wrap(model)` | Wraps a language model with middleware. Accepts a model string (e.g. `'anthropic/claude-sonnet-4.6'`) or a `LanguageModelV3` object. Works with `generateText`, `streamText`, and `ToolLoopAgent`. Also works with pre-wrapped models (e.g. from supermemory). |
123+
| `captureEmbed(result)` | Manually captures token usage, model info, and dimensions from `embed()` or `embedMany()` results (embedding models use a different type). |
122124

123125
The middleware intercepts calls at the provider level. It does not touch your callbacks, prompts, or responses. Captured data flows through the normal evlog pipeline (sampling, enrichers, drains) and ends up in Axiom, Better Stack, or wherever you drain to.
124126

@@ -127,6 +129,7 @@ The middleware intercepts calls at the provider level. It does not touch your ca
127129
| Option | Type | Default | Description |
128130
|--------|------|---------|-------------|
129131
| `toolInputs` | `boolean \| ToolInputsOptions` | `false` | When enabled, `toolCalls` contains `{ name, input }` objects instead of plain strings. Opt-in because inputs can be large and may contain sensitive data. |
132+
| `cost` | `Record<string, ModelCost>` | `undefined` | Pricing map for cost estimation. Keys are model IDs, values are `{ input, output }` in dollars per 1M tokens. |
130133

131134
Pass `true` to capture all inputs as-is, or an options object for fine-grained control:
132135

@@ -152,6 +155,14 @@ const ai = createAILogger(log, {
152155
},
153156
},
154157
})
158+
159+
// Cost estimation
160+
const ai = createAILogger(log, {
161+
cost: {
162+
'claude-sonnet-4.6': { input: 3, output: 15 },
163+
'gpt-4o': { input: 2.5, output: 10 },
164+
},
165+
})
155166
```
156167

157168
## Usage Patterns
@@ -282,7 +293,11 @@ export default defineEventHandler(async (event) => {
282293
model: openai.embedding('text-embedding-3-small'),
283294
value: query,
284295
})
285-
ai.captureEmbed({ usage })
296+
ai.captureEmbed({
297+
usage,
298+
model: 'text-embedding-3-small',
299+
dimensions: 1536,
300+
})
286301

287302
const docs = await findSimilar(embedding)
288303

@@ -295,6 +310,16 @@ export default defineEventHandler(async (event) => {
295310
})
296311
```
297312

313+
For `embedMany`, pass the batch count:
314+
315+
```typescript
316+
const { embeddings, usage } = await embedMany({
317+
model: openai.embedding('text-embedding-3-small'),
318+
values: documents,
319+
})
320+
ai.captureEmbed({ usage, model: 'text-embedding-3-small', count: documents.length })
321+
```
322+
298323
### Multiple models
299324

300325
Wrap each model separately, they share the same accumulator. When multiple models are used, the wide event includes both `model` (last model) and `models` (all unique models):
@@ -335,6 +360,87 @@ import { anthropic } from '@ai-sdk/anthropic'
335360
const model = ai.wrap(anthropic('claude-sonnet-4.6'))
336361
```
337362

363+
## Telemetry Integration
364+
365+
For deeper observability — tool execution timing, success/failure tracking, and total generation wall time — use `createEvlogIntegration()`. It implements the AI SDK's `TelemetryIntegration` interface and captures data that middleware alone cannot see.
366+
367+
### Combined with middleware (recommended)
368+
369+
When passed an `AILogger`, the integration shares its accumulator. Both paths write to the same `ai.*` field:
370+
371+
```typescript [server/api/agent.post.ts]
372+
import { generateText } from 'ai'
373+
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
374+
375+
export default defineEventHandler(async (event) => {
376+
const log = useLogger(event)
377+
const ai = createAILogger(log)
378+
379+
const result = await generateText({
380+
model: ai.wrap('anthropic/claude-sonnet-4.6'),
381+
tools: { getWeather, searchDB },
382+
experimental_telemetry: {
383+
isEnabled: true,
384+
integrations: [createEvlogIntegration(ai)],
385+
},
386+
})
387+
388+
return { text: result.text }
389+
})
390+
```
391+
392+
Your wide event now includes tool execution details:
393+
394+
```json [Wide Event]
395+
{
396+
"ai": {
397+
"calls": 2,
398+
"steps": 2,
399+
"model": "claude-sonnet-4.6",
400+
"provider": "anthropic",
401+
"inputTokens": 3500,
402+
"outputTokens": 800,
403+
"totalTokens": 4300,
404+
"toolCalls": ["getWeather", "searchDB"],
405+
"tools": [
406+
{ "name": "getWeather", "durationMs": 150, "success": true },
407+
{ "name": "searchDB", "durationMs": 45, "success": true }
408+
],
409+
"totalDurationMs": 2340,
410+
"msToFirstChunk": 180,
411+
"msToFinish": 2100,
412+
"tokensPerSecond": 380
413+
}
414+
}
415+
```
416+
417+
### Standalone (without middleware)
418+
419+
If your model is already wrapped (e.g. by another middleware), pass the request logger directly:
420+
421+
```typescript [server/api/chat.post.ts]
422+
import { createEvlogIntegration } from 'evlog/ai'
423+
424+
const integration = createEvlogIntegration(log)
425+
426+
const result = await generateText({
427+
model: somePreWrappedModel,
428+
experimental_telemetry: {
429+
isEnabled: true,
430+
integrations: [integration],
431+
},
432+
})
433+
```
434+
435+
### What the integration captures
436+
437+
| Data | Source | Description |
438+
|------|--------|-------------|
439+
| `ai.tools[]` | `onToolCallFinish` | Per-tool `name`, `durationMs`, `success`, and `error` (if failed) |
440+
| `ai.totalDurationMs` | `onStart``onFinish` | Total wall time from generation start to completion |
441+
442+
The middleware captures tokens, model info, and streaming metrics. The integration captures tool execution timing. Together, they give you complete AI observability.
443+
338444
## Captured Data
339445

340446
| Wide event field | Source | Description |
@@ -358,6 +464,10 @@ const model = ai.wrap(anthropic('claude-sonnet-4.6'))
358464
| `ai.msToFinish` | Stream timing | Total stream duration (streaming only) |
359465
| `ai.tokensPerSecond` | Computed | Output tokens per second (streaming only) |
360466
| `ai.error` | Error capture | Error message if a model call fails |
467+
| `ai.tools` | `TelemetryIntegration` | Per-tool `{ name, durationMs, success, error? }` (requires `createEvlogIntegration`) |
468+
| `ai.totalDurationMs` | `TelemetryIntegration` | Total generation wall time (requires `createEvlogIntegration`) |
469+
| `ai.embedding` | `captureEmbed` | `{ model?, tokens, dimensions?, count? }` — embedding metadata |
470+
| `ai.estimatedCost` | Computed | Estimated cost in dollars (requires `cost` option) |
361471

362472
## Composability
363473

apps/docs/skills/review-logging-patterns/SKILL.md

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: review-logging-patterns
3-
description: Review code for logging patterns and suggest evlog adoption. Guides setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript. Detects console.log spam, unstructured errors, and missing context. Covers wide events, structured errors, drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack, Datadog), sampling, enrichers, and AI SDK integration (token usage, tool calls, streaming metrics).
3+
description: Review code for logging patterns and suggest evlog adoption. Guides setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript. Detects console.log spam, unstructured errors, and missing context. Covers wide events, structured errors, drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack, Datadog), sampling, enrichers, and AI SDK integration (token usage, tool calls, streaming metrics, telemetry integration, cost estimation, embedding metadata).
44
license: MIT
55
metadata:
66
author: HugoRCD
@@ -866,7 +866,9 @@ Works in all frameworks: Nuxt (`evlog` config), Nitro (`evlog()` module options)
866866

867867
## AI SDK Integration
868868

869-
Capture token usage, tool calls, model info, and streaming metrics from the Vercel AI SDK into wide events. Import from `evlog/ai`. Requires `ai >= 6.0.0` as a peer dependency.
869+
Capture token usage, tool calls, model info, streaming metrics, tool execution timing, cost estimation, and embedding metadata from the Vercel AI SDK into wide events. Import from `evlog/ai`. Requires `ai >= 6.0.0` as a peer dependency.
870+
871+
### Basic setup (middleware)
870872

871873
```typescript
872874
import { createAILogger } from 'evlog/ai'
@@ -877,22 +879,62 @@ const ai = createAILogger(log)
877879
const result = streamText({
878880
model: ai.wrap('anthropic/claude-sonnet-4.6'), // accepts string or model object
879881
messages,
880-
onFinish: ({ text }) => {
881-
// User callbacks remain free — no conflict
882+
})
883+
```
884+
885+
`ai.wrap()` uses model middleware to transparently capture all LLM calls. Works with `generateText`, `streamText`, and `ToolLoopAgent`.
886+
887+
### Telemetry integration (deeper observability)
888+
889+
For tool execution timing, success/failure tracking, and total generation wall time, add `createEvlogIntegration()`:
890+
891+
```typescript
892+
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
893+
894+
const ai = createAILogger(log)
895+
896+
const agent = new ToolLoopAgent({
897+
model: ai.wrap('anthropic/claude-sonnet-4.6'),
898+
tools: { searchWeb, queryDatabase },
899+
stopWhen: stepCountIs(5),
900+
experimental_telemetry: {
901+
isEnabled: true,
902+
integrations: [createEvlogIntegration(ai)],
882903
},
883904
})
884905
```
885906

886-
`ai.wrap()` uses model middleware to transparently capture all LLM calls. Works with `generateText`, `streamText`, `generateObject`, `streamObject`, and `ToolLoopAgent`.
907+
This adds `ai.tools` (per-tool `{ name, durationMs, success, error? }`) and `ai.totalDurationMs` to the wide event.
887908

888-
For embeddings (different model type):
909+
### Embeddings
889910

890911
```typescript
891912
const { embedding, usage } = await embed({ model: embeddingModel, value: query })
892-
ai.captureEmbed({ usage })
913+
ai.captureEmbed({ usage, model: 'text-embedding-3-small', dimensions: 1536 })
893914
```
894915

895-
Wide event `ai` field includes: `calls`, `model`, `provider`, `inputTokens`, `outputTokens`, `totalTokens`, `cacheReadTokens`, `reasoningTokens`, `finishReason`, `toolCalls`, `steps`, `msToFirstChunk`, `msToFinish`, `tokensPerSecond`, `error`.
916+
For `embedMany`, pass the batch count:
917+
918+
```typescript
919+
ai.captureEmbed({ usage, model: 'text-embedding-3-small', count: documents.length })
920+
```
921+
922+
### Cost estimation
923+
924+
Pass a pricing map to get `ai.estimatedCost` in the wide event:
925+
926+
```typescript
927+
const ai = createAILogger(log, {
928+
cost: {
929+
'claude-sonnet-4.6': { input: 3, output: 15 },
930+
'gpt-4o': { input: 2.5, output: 10 },
931+
},
932+
})
933+
```
934+
935+
### Wide event `ai` field
936+
937+
Includes: `calls`, `model`, `provider`, `inputTokens`, `outputTokens`, `totalTokens`, `cacheReadTokens`, `reasoningTokens`, `finishReason`, `toolCalls`, `steps`, `msToFirstChunk`, `msToFinish`, `tokensPerSecond`, `error`, `tools` (via telemetry integration), `totalDurationMs` (via telemetry integration), `embedding` (via `captureEmbed`), `estimatedCost` (via `cost` option).
896938

897939
Anti-patterns to detect:
898940

@@ -901,6 +943,8 @@ Anti-patterns to detect:
901943
| Manual token tracking in `onFinish` | `ai.wrap()` — middleware captures automatically |
902944
| `console.log('tokens:', result.usage)` | `ai.wrap()` — structured `ai.*` fields in wide event |
903945
| No AI observability | Add `createAILogger(log)` + `ai.wrap()` |
946+
| No tool execution timing | Add `createEvlogIntegration(ai)` to `experimental_telemetry.integrations` |
947+
| Manual cost calculation | Use `cost` option in `createAILogger()` |
904948

905949
---
906950

apps/nuxthub-playground/server/api/chat.post.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
2-
import { createAILogger } from 'evlog/ai'
2+
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
33
import { queryEvents } from '../tools/query-events'
44

55
const systemPrompt = `You are a helpful assistant that analyzes application logs stored in a SQLite database.
@@ -63,14 +63,23 @@ export default defineEventHandler(async (event) => {
6363

6464
logger.set({ action: 'chat', messagesCount: messages.length })
6565

66-
const ai = createAILogger(logger, { toolInputs: true })
66+
const ai = createAILogger(logger, {
67+
toolInputs: true,
68+
cost: {
69+
'gemini-3-flash': { input: 0.1, output: 0.4 },
70+
},
71+
})
6772

6873
try {
6974
const agent = new ToolLoopAgent({
7075
model: ai.wrap('google/gemini-3-flash'),
7176
instructions: systemPrompt,
7277
tools: { queryEvents },
7378
stopWhen: stepCountIs(5),
79+
experimental_telemetry: {
80+
isEnabled: true,
81+
integrations: [createEvlogIntegration(ai)],
82+
},
7483
})
7584
return createAgentUIStreamResponse({
7685
agent,

apps/nuxthub-playground/server/api/test/ai-wrap.get.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { gateway, generateText, wrapLanguageModel } from 'ai'
22
import type { LanguageModelV3Middleware } from '@ai-sdk/provider'
3-
import { createAILogger } from 'evlog/ai'
3+
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
44

55
/**
66
* Simulates an external middleware (supermemory, guardrails, etc.)
@@ -23,7 +23,12 @@ export default defineEventHandler(async (event) => {
2323
const logger = useLogger(event)
2424
logger.set({ action: 'test-ai-wrap-composition' })
2525

26-
const ai = createAILogger(logger, { toolInputs: true })
26+
const ai = createAILogger(logger, {
27+
toolInputs: true,
28+
cost: {
29+
'gemini-3-flash': { input: 0.1, output: 0.4 },
30+
},
31+
})
2732

2833
const base = gateway('google/gemini-3-flash')
2934
const preWrapped = wrapLanguageModel({ model: base, middleware: externalMiddleware })
@@ -33,6 +38,10 @@ export default defineEventHandler(async (event) => {
3338
model,
3439
prompt: 'Say hello.',
3540
maxOutputTokens: 200,
41+
experimental_telemetry: {
42+
isEnabled: true,
43+
integrations: [createEvlogIntegration(ai)],
44+
},
3645
})
3746

3847
const middlewareRan = result.text.startsWith('MIDDLEWARE_OK:')

0 commit comments

Comments
 (0)