Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nitro-ai-streams.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'evlog': minor
---

Add `createNitroAIStreamLogger()` from `evlog/ai/nitro` for Nuxt/Nitro AI SDK streaming responses. The helper records stream metadata on a correlated child event and sends it through the normal Nitro enrich/drain hooks, avoiding post-emit warnings when the parent request event has already completed.
2 changes: 1 addition & 1 deletion apps/docs/content/2.learn/2.wide-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ For intentional background work that should produce **its own** wide event, use

The parent wide event may be emitted **before** the child event; they are two separate events ordered by time.

**Not available yet:** Hono (no `useLogger` without `c.get('log')` + ALS) and Nitro/Nuxt `useLogger(event)`use the post-emit warnings to catch mistakes; a different API may arrive later for event-scoped forks.
**Not available yet:** Hono (no `useLogger` without `c.get('log')` + ALS) and Nitro/Nuxt `useLogger(event)`. For Nuxt/Nitro AI streams, use `createNitroAIStreamLogger()` from `evlog/ai/nitro` to emit a correlated child event after the response body finishes.

```typescript [server/routes/checkout.post.ts]
import { evlog, useLogger } from 'evlog/express'
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/3.integrate/frameworks/04.nitro.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ One request, one log line with all context:
└─ requestId: a1b2c3d4-...
```

Nitro uses **`useLogger(event)`** (event-bound scope), not `AsyncLocalStorage`, so **`log.fork()` is not available** here yet. Post-emit warnings still apply if code calls `set()` after the wide event was emitted. See [Wide events — After emit](/learn/wide-events#after-emit-sealing-and-background-work).
Nitro uses **`useLogger(event)`** (event-bound scope), not `AsyncLocalStorage`, so **`log.fork()` is not available** here yet. For AI SDK streaming responses, use `createNitroAIStreamLogger(event)` from `evlog/ai/nitro`; it emits a correlated child event after the response body finishes and routes it through the same Nitro enrich/drain hooks. Post-emit warnings still apply if code calls `set()` after the wide event was emitted. See [Wide events — After emit](/learn/wide-events#after-emit-sealing-and-background-work).

## Error Handling

Expand Down
34 changes: 21 additions & 13 deletions apps/docs/content/4.use-cases/2.ai-sdk/01.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ actions:
Add AI observability to my app with evlog.

- Install the AI SDK: pnpm add ai
- Import createAILogger from 'evlog/ai'
- Create an AI logger with createAILogger(log) where log is your request logger
- Import createAILogger from 'evlog/ai' for awaited calls, or createNitroAIStreamLogger from 'evlog/ai/nitro' for Nuxt streaming responses
- Create an AI logger with createAILogger(log) or use the ai returned by createNitroAIStreamLogger(event)
- Wrap your model with ai.wrap('anthropic/claude-sonnet-4.6') and pass it to generateText, streamText, etc.
- Token usage, tool calls, streaming metrics, and errors are captured automatically into the wide event
- Token usage, tool calls, streaming metrics, and errors are captured automatically into the request event or a correlated child stream event
- For deeper observability (tool execution timing, total generation wall time), add createEvlogIntegration(ai) to experimental_telemetry.integrations
- For embedding calls, use ai.captureEmbed({ usage, model, dimensions, count }) after embed() or embedMany()
- For cost estimation, pass a cost map: createAILogger(log, { cost: { 'claude-sonnet-4.6': { input: 3, output: 15 } } })
Expand Down Expand Up @@ -94,30 +94,38 @@ export default defineEventHandler(async (event) => {
```

```typescript [After]
import { useLogger } from 'evlog'
import { createAILogger } from 'evlog/ai'
import { consumeStream } from 'ai'
import { createEvlogIntegration } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'

export default defineEventHandler(async (event) => {
const log = useLogger(event)
const ai = createAILogger(log)
const { ai, wrapResponse } = createNitroAIStreamLogger(event)

const result = streamText({
model: ai.wrap('anthropic/claude-sonnet-4.6'),
messages,
experimental_telemetry: {
isEnabled: true,
integrations: [createEvlogIntegration(ai)],
},
})
return result.toTextStreamResponse()
return wrapResponse(result.toUIMessageStreamResponse({
consumeSseStream: consumeStream,
}))
})
```
::

Your wide event now includes:
Your request wide event gets a correlated child event with:

```json [Wide Event]
{
"method": "POST",
"path": "/api/chat",
"status": 200,
"duration": "4.5s",
"operation": "ai-stream",
"_parentRequestId": "req_...",
"ai": {
"calls": 1,
"model": "claude-sonnet-4.6",
Expand Down Expand Up @@ -148,6 +156,8 @@ Your wide event now includes:

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 lands in Axiom, Better Stack, or wherever you drain to.

For Nuxt/Nitro streaming responses, `createNitroAIStreamLogger(event)` creates the same `AILogger` on a child stream event so late stream metadata is not written to an already-emitted request event.

## Where to next

::card-group
Expand Down Expand Up @@ -194,11 +204,9 @@ The middleware intercepts calls at the provider level. It does not touch your ca

::code-group
```typescript [Nuxt]
import { useLogger } from 'evlog'
import { createAILogger } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'

const log = useLogger(event)
const ai = createAILogger(log)
const { ai } = createNitroAIStreamLogger(event)
```

```typescript [Next.js]
Expand Down
37 changes: 23 additions & 14 deletions apps/docs/content/4.use-cases/2.ai-sdk/02.usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,42 @@ links:
variant: subtle
---

Every pattern below uses the same `createAILogger(log)` setup. Wrap the model with `ai.wrap()` and the middleware accumulates tokens, tools, and timing on the wide event automatically.
Awaited calls use `createAILogger(log)` directly. Nuxt/Nitro streaming responses use `createNitroAIStreamLogger(event)` so AI metadata lands on a correlated child event after the response body finishes.

## streamText

The most common pattern — streaming chat with full observability:

```typescript [server/api/chat.post.ts]
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'
import { consumeStream, streamText } from 'ai'
import { createEvlogIntegration } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'

export default defineEventHandler(async (event) => {
const log = useLogger(event)
const ai = createAILogger(log)
const { ai, log, wrapResponse } = createNitroAIStreamLogger(event)
const { messages } = await readBody(event)

log.set({ action: 'chat', messagesCount: messages.length })

const result = streamText({
model: ai.wrap('anthropic/claude-sonnet-4.6'),
messages,
experimental_telemetry: {
isEnabled: true,
integrations: [createEvlogIntegration(ai)],
},
onFinish: ({ text }) => {
saveConversation(text)
},
})

return result.toTextStreamResponse()
return wrapResponse(result.toUIMessageStreamResponse({
consumeSseStream: consumeStream,
}))
})
```

The middleware never touches your `onFinish` callback — your code runs as usual.
The middleware never touches your `onFinish` callback — your code runs as usual. The child event uses `operation: 'ai-stream'` and `_parentRequestId` to link back to the request event.

## generateText

Expand Down Expand Up @@ -75,14 +81,15 @@ The middleware fires for each step automatically. Steps, tool calls, and tokens

```typescript [server/api/agent.post.ts]
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { useLogger } from 'evlog'
import { createAILogger } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'

export default defineEventHandler(async (event) => {
const log = useLogger(event)
const { messages } = await readBody(event)
const ai = createAILogger(log, {
toolInputs: { maxLength: 500 },
const { ai, wrapResponse } = createNitroAIStreamLogger(event, {
fields: { messagesCount: messages.length },
ai: {
toolInputs: { maxLength: 500 },
},
})

const agent = new ToolLoopAgent({
Expand All @@ -91,13 +98,15 @@ export default defineEventHandler(async (event) => {
stopWhen: stepCountIs(5),
})

return createAgentUIStreamResponse({
return wrapResponse(createAgentUIStreamResponse({
agent,
uiMessages: messages,
})
}))
})
```

For non-streaming agent calls, bind `createAILogger` to `useLogger(event)` directly.

Wide event after a 3-step agent run:

```json [Wide Event]
Expand Down
8 changes: 3 additions & 5 deletions apps/docs/content/4.use-cases/2.ai-sdk/04.metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,11 @@ Each invocation receives a fresh snapshot. Returns an unsubscribe function. Subs

```typescript [server/api/agent.post.ts]
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { useLogger } from 'evlog'
import { createAILogger } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'

export default defineEventHandler(async (event) => {
const log = useLogger(event)
const { messages } = await readBody(event)
const ai = createAILogger(log)
const { ai, wrapResponse } = createNitroAIStreamLogger(event)

ai.onUpdate((metadata) => {
pushToClient(event, {
Expand All @@ -110,7 +108,7 @@ export default defineEventHandler(async (event) => {
stopWhen: stepCountIs(5),
})

return createAgentUIStreamResponse({ agent, uiMessages: messages })
return wrapResponse(createAgentUIStreamResponse({ agent, uiMessages: messages }))
})
```

Expand Down
26 changes: 13 additions & 13 deletions apps/nuxthub-playground/server/api/chat.post.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
import { createEvlogIntegration } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'
import { queryEvents } from '../tools/query-events'

const systemPrompt = `You are a helpful assistant that analyzes application logs stored in a SQLite database.
Expand Down Expand Up @@ -58,20 +59,19 @@ Use json_extract() for querying JSON columns. Examples:
- Only use SELECT queries — never modify data.`

export default defineEventHandler(async (event) => {
const logger = useLogger(event)
const { messages } = await readBody(event)

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

const ai = createAILogger(logger, {
toolInputs: true,
cost: {
'gemini-3-flash': { input: 0.1, output: 0.4 },
const { ai, log, wrapResponse } = createNitroAIStreamLogger(event, {
fields: { action: 'chat', messagesCount: messages.length },
ai: {
toolInputs: true,
cost: {
'gemini-3-flash': { input: 0.1, output: 0.4 },
},
},
})

ai.onUpdate((metadata) => {
logger.set({
log.set({
aiLive: {
step: metadata.calls,
totalTokens: metadata.totalTokens,
Expand All @@ -92,7 +92,7 @@ export default defineEventHandler(async (event) => {
integrations: [createEvlogIntegration(ai)],
},
})
return createAgentUIStreamResponse({
return wrapResponse(createAgentUIStreamResponse({
agent,
uiMessages: messages,
messageMetadata: ({ part }) => {
Expand All @@ -107,12 +107,12 @@ export default defineEventHandler(async (event) => {
}
},
onFinish: () => {
logger.set({
log.set({
aiFinalMetadata: ai.getMetadata(),
aiFinalCost: ai.getEstimatedCost(),
})
},
})
}))
} catch (error) {
throw createError({
statusCode: 500,
Expand Down
20 changes: 14 additions & 6 deletions packages/evlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,23 +797,31 @@ See [the Audit Logs guide](https://evlog.dev/use-cases/audit/overview) for compl
Capture token usage, tool calls, model info, and streaming metrics from the [Vercel AI SDK](https://ai-sdk.dev) into wide events. Requires `ai >= 6.0.0`.

```typescript
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'
import { consumeStream, streamText } from 'ai'
import { createEvlogIntegration } from 'evlog/ai'
import { createNitroAIStreamLogger } from 'evlog/ai/nitro'

export default defineEventHandler(async (event) => {
const log = useLogger(event)
const ai = createAILogger(log)
const { ai, wrapResponse } = createNitroAIStreamLogger(event)

const result = streamText({
model: ai.wrap('anthropic/claude-sonnet-4.6'), // string or model object
messages,
experimental_telemetry: {
isEnabled: true,
integrations: [createEvlogIntegration(ai)],
},
onFinish: ({ text }) => saveConversation(text), // no conflict
})

return result.toTextStreamResponse()
return wrapResponse(result.toUIMessageStreamResponse({
consumeSseStream: consumeStream,
}))
})
```

For Nuxt/Nitro streaming responses, `createNitroAIStreamLogger(event)` emits a correlated child event with `operation: 'ai-stream'` and `_parentRequestId`. For awaited calls such as `generateText`, bind `createAILogger` to the request logger directly.

The middleware captures: `inputTokens`, `outputTokens`, `cacheReadTokens`, `reasoningTokens`, `model`, `provider`, `finishReason`, `toolCalls`, `steps`, `msToFirstChunk`, `msToFinish`, `tokensPerSecond`.

For embeddings: `ai.captureEmbed({ usage })`.
Expand Down Expand Up @@ -1255,7 +1263,7 @@ The framework emits **one wide event per HTTP request** when the response finish
| Express, Fastify, NestJS, SvelteKit, React Router, Elysia | Yes |
| Next.js `withEvlog` | Yes |
| Hono (`c.get('log')` only) | Not yet |
| Nitro / Nuxt `useLogger(event)` | Not yet — use post-emit warnings; see [Wide events](https://evlog.dev/learn/wide-events) |
| Nitro / Nuxt `useLogger(event)` | Not yet — use `createNitroAIStreamLogger()` for AI streams |

```typescript
import { evlog, useLogger } from 'evlog/express'
Expand Down
8 changes: 8 additions & 0 deletions packages/evlog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@
"import": "./dist/ai/index.mjs",
"default": "./dist/ai/index.mjs"
},
"./ai/nitro": {
"types": "./dist/ai/nitro.d.mts",
"import": "./dist/ai/nitro.mjs",
"default": "./dist/ai/nitro.mjs"
},
"./better-auth": {
"types": "./dist/better-auth/index.d.mts",
"import": "./dist/better-auth/index.mjs",
Expand Down Expand Up @@ -326,6 +331,9 @@
"ai": [
"./dist/ai/index.d.mts"
],
"ai/nitro": [
"./dist/ai/nitro.d.mts"
],
"better-auth": [
"./dist/better-auth/index.d.mts"
]
Expand Down
Loading