maple-agent-tracing-vercel-ai-sdk
GitHub为 Vercel AI SDK Agent 集成 Maple 追踪,实现会话级监控、工具调用记录及 Token 统计。通过配置 OpenTelemetry 将 traces 导出至 Maple,支持 Next.js 和 Node.js 环境,涵盖多版本适配与冲突检测。
Trigger Scenarios
Install
npx skills add MapleTechLabs/maple --skill maple-agent-tracing-vercel-ai-sdk -g -y
SKILL.md
Frontmatter
{
"name": "maple-agent-tracing-vercel-ai-sdk",
"description": "Trace Vercel AI SDK agents with Maple: register the AI SDK's OpenTelemetry integration, export to Maple, and pass a conversation id so each chat is one Agent Session with transcript, tool calls, sub-agents and tokens. Covers generateText, streamText, ToolLoopAgent, Node.js and Next.js. Triggers on 'trace my vercel ai sdk agent', 'add Maple to vercel ai sdk', 'agent sessions for vercel ai sdk', 'OpenTelemetry for vercel ai sdk', 'trace my ai sdk app'."
}
Maple agent tracing: Vercel AI SDK
Goal
One conversation = one Maple Agent Session, one turn per generate()/stream() call, with the transcript, every model call (model, tokens, TTFT), every tool call (name, args, result, failures), and a lane per sub-agent.
Known gaps (tell the user, don't try to fix): cost shows as "unpriced" (AI SDK emits no cost; Maple never prices tokens); on AI SDK 5/6 the final assistant reply is missing from transcripts. If model calls go through OpenRouter, its Broadcast traces carry per-call cost and Maple matches them to the AI SDK chat spans by response id (see the OpenRouter guide).
Step 0: Detect
aiversion inpackage.json/ lockfile:>= 7: this skill. Upgrade to^7.0.106or newer if older (earlier 7.x leave spans open when a stream errors mid-read). Node.js >= 22 required.5.x/6.x: ask the user whether to upgrade to 7 (recommended;npx @ai-sdk/codemod v7). The codemod only renamesexperimental_telemetrytotelemetry; you still install@ai-sdk/otel, callregisterTelemetry(), and move the id frommetadata(removed in 7) toruntimeContext, then drop anyConversationIdProcessor. If not, go to "AI SDK 5/6" at the end.
- Mastra (
@mastra/core), eve (eve) or another framework built onai: stop, use that framework's skill instead. - Existing OpenTelemetry: search for
NodeSDK,NodeTracerProvider,registerOTel,@vercel/otel,Sentry.init,@langfuse/otel,LangfuseSpanProcessor,braintrust,registerTelemetry(,experimental_telemetry,telemetry:.- An SDK/provider already exists: reuse it. Add one Maple exporting span processor to it. Never start a second SDK.
registerTelemetry(...)already exists: extend that call'sOpenTelemetryoptions; never call it twice (it appends, so every span is emitted twice).LegacyOpenTelemetryregistered: replace it withOpenTelemetryunless the user says another backend depends on the legacy format. Never register both.
- Next.js app (
nextdependency,instrumentation.ts): use Step 2b. - Find every AI SDK call site:
generateText(,streamText(,new ToolLoopAgent(,createAgentUIStreamResponse(,pipeAgentUIStreamToResponse(,createAgentUIStream(,agent.generate(,agent.stream(. Find each one's conversation id (chat id, thread id,useChatrequest bodyid).
Step 1: Key and region
- US endpoint
https://ingest.maple.dev, EU endpointhttps://ingest.eu.maple.dev. HeaderAuthorization=Bearer <key>. - Key in the user's prompt: use it. No key: use the literal
MAPLE_TEST(ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion. - Private
maple_sk_keys never go in browser code. Ingest keys are write-only. - Follow the repo's existing secret/env convention (e.g.
MAPLE_INGEST_KEYorOTEL_EXPORTER_OTLP_*in.env). If there is none, inlining the ingest key is acceptable. - The app loads
.env(dotenv,--env-file): load it at the top ofinstrumentation.ts(import "dotenv/config"as its first line) or run with--env-file.NodeSDK()reads theOTEL_*vars when it is constructed; otherwise the exporter silently targetslocalhost:4318with no key. - Never let an unset variable become
Bearer undefined(opaque 401). When the key variable is missing, log one warning (MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled) and leave the Maple exporter out so the app runs normally; never throw over the key. Or inline the key when the repo has no env convention. - A 401
ingest_unauthorized/ "Invalid ingest key" with a key you trust usually means the key belongs to the other region (keys are region-bound): try the other endpoint.
Step 2a: Install and init (Node.js)
npm install ai@^7.0.106 @ai-sdk/otel @opentelemetry/sdk-node
Use the repo's package manager (@opentelemetry/api arrives as a peer of sdk-node; add it explicitly only if the package manager doesn't install peers). Works on Node.js 22+ and Bun. Env (or the repo's equivalent):
OTEL_SERVICE_NAME=support-agent
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
NodeSDK() with no span processors builds a batched OTLP http/protobuf exporter from these variables. Create instrumentation.ts:
import { OpenTelemetry } from "@ai-sdk/otel"
import { NodeSDK } from "@opentelemetry/sdk-node"
import { registerTelemetry } from "ai"
export const sdk = new NodeSDK()
sdk.start()
registerTelemetry(
new OpenTelemetry({
usage: true,
runtimeContext: true,
}),
)
Use the explicit span processor variant instead when either applies:
- Serverless handler (Lambda, Cloud Run jobs, queue consumers, cron, Vercel Workflow steps): needs
spanProcessor.forceFlush()per invocation;NodeSDKonly hasshutdown(). - The repo already starts an OpenTelemetry SDK/provider: add the processor to it, don't create a second
NodeSDK. - Inlining the key instead of env: pass
{ url: "https://ingest.maple.dev/v1/traces", headers: { authorization: "Bearer <key>" } }toOTLPTraceExporter.
npm install @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
export const spanProcessor = new BatchSpanProcessor(new OTLPTraceExporter())
export const sdk = new NodeSDK({ spanProcessors: [spanProcessor] })
import "./instrumentation"as the FIRST line of every entry point (server, worker, CLI).registerTelemetrymust run before the first AI SDK call.- Keep
usage: true(adds the reasoning-token breakdown,ai.usage.*) andruntimeContext: true(records the included runtime context keys; Maple reads the conversation id from them). - Do not pass
tracer:toOpenTelemetryunless reusing a provider requires it; if you must, useprovider.getTracer("gen_ai"). Other scope names break detection.
Step 2b: Install and init (Next.js)
npm install ai@^7.0.106 @ai-sdk/otel @vercel/otel @opentelemetry/api. In instrumentation.ts (root, or src/ if the app uses src/), inside register():
import { OpenTelemetry } from "@ai-sdk/otel"
import { OTLPHttpProtoTraceExporter, registerOTel } from "@vercel/otel"
import { registerTelemetry } from "ai"
export function register() {
const mapleKey = process.env.MAPLE_INGEST_KEY
if (!mapleKey) {
// A missing key disables export; it never stops the app.
console.warn("MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled")
return
}
registerOTel({
serviceName: "support-chat",
traceExporter: new OTLPHttpProtoTraceExporter({
url: "https://ingest.maple.dev/v1/traces",
headers: { authorization: `Bearer ${mapleKey}` },
}),
})
registerTelemetry(
new OpenTelemetry({
usage: true,
runtimeContext: true,
}),
)
}
- An existing
registerOTelcall: keep it, add the Maple exporter (orOTEL_EXPORTER_OTLP_*env vars if it has notraceExporter), addregisterTelemetrynext to it. - Sentry in the same app: it must use
skipOpenTelemetrySetup: true, or spans duplicate. @vercel/otelforce-ends every still-open span of a trace when its root span ends. Read AI SDK streams inside the request (return them as the response); never consume them inafter()or other post-response work, or thechat/invoke_agentspans lose output and tokens.- Next.js 13.4/14:
experimental: { instrumentationHook: true }innext.config.
Step 3: Conversation id (session)
Every AI SDK call site that belongs to a conversation must pass runtimeContext: { conversationId } AND telemetry.includeRuntimeContext: { conversationId: true }. Without the include, the id is not recorded. sessionId works as the key too.
generateText / streamText:
streamText({
model,
messages,
runtimeContext: { conversationId: chatId },
telemetry: { functionId: "support_agent", includeRuntimeContext: { conversationId: true } },
})
If runtimeContext already exists, add conversationId to it and to includeRuntimeContext; keep other keys excluded (they may hold secrets).
ToolLoopAgent (generate/stream take no per-call runtimeContext): add a call option.
import { z } from "zod"
new ToolLoopAgent({
// ...existing settings
callOptionsSchema: z.object({ conversationId: z.string() }),
prepareCall: ({ options, ...rest }) => ({
...rest,
runtimeContext: { conversationId: options.conversationId },
}),
telemetry: { functionId: "support_agent", includeRuntimeContext: { conversationId: true } },
})
await assistant.generate({ messages, options: { conversationId: chatId } })
// streaming: stream() returns a Promise in ai 7
const r = await assistant.stream({ messages, options: { conversationId: chatId } })
for await (const c of r.textStream) process.stdout.write(c)
Install zod if it is not a direct dependency (it is only a peer of ai).
- Agent already has
callOptionsSchema/prepareCall: extend both; keep their existing return values and mergeruntimeContext. useChatroutes: the request body hasid(the chat id). Use it:createAgentUIStreamResponse({ agent, uiMessages: messages, options: { conversationId: id } })(sameoptionskey onpipeAgentUIStreamToResponse), orruntimeContext: { conversationId: id }onstreamText.- Id must be stable per conversation and unique across conversations. Never a module constant, never
Date.now()per call, never a per-process id. If the app has no id for a conversation, create one where the conversation is created and persist it with it. - Tool approvals (
toolApproval: { <tool>: "user-approval" }on the call or agent;needsApprovalontool()is deprecated in 7): the resume after thetool-approval-responseis a secondgenerate/streamcall and a second trace. Pass the same id. Maple shows it as a second turn. - Background jobs with no conversation: one id per job run.
Step 4: Content
- On by default. Do not set
recordInputs/recordOutputsunless the user asks for privacy; if they do, set them per call/agent intelemetryand tell them the transcript will be empty for those calls. - Content is not redacted. For pattern-based redaction, suggest an OpenTelemetry Collector between the app and Maple, or
recordInputs/recordOutputs: falseon the sensitive calls.telemetry: { isEnabled: false }drops a call's spans entirely. - Never set
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT/OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: they cut the message JSON. - Calls that send images/PDFs: warn the user they are recorded as base64 on every step; suggest
recordInputs: falseon those calls if payloads are large (exports failing with 413 are the symptom).
Step 5: Tools, errors, sub-agents
- Set a distinct
telemetry.functionIdon EVERY agent/call (snake_case agent name). It becomesgen_ai.agent.name;ToolLoopAgent.idis not exported. - Tool failures must throw from
execute. If a tool returns{ error }/{ ok: false }for real failures, change it to throw (ask the user first if the model relies on the payload). An always-throwingexecuteneeds an explicit return type (Promise<string>) or it infersnever. - Sub-agents as tools:
execute: async ({ task }) => (await worker.generate({ prompt: task })).text. The worker needs its ownfunctionId; it does not need the conversation id (one span per trace carries it). Separate top-level calls in a pipeline (e.g. a summary agent after the orchestrator) are separate traces: each needs the conversation id. - Don't pass
telemetry.integrationson a call unless it includes theOpenTelemetryinstance: it replaces the global integrations for that call.
Step 6: Flush
- Scripts/CLIs:
await sdk.shutdown().catch((err) => console.error("telemetry flush failed", err))in afinallyat the end.shutdown()rejects when an export failed; the.catchkeeps a Maple outage from crashing the app. - Long-running servers:
process.on("SIGTERM", () => sdk.shutdown().catch((err) => console.error("telemetry flush failed", err))); nothing per request. - Serverless handlers (Lambda, Cloud Run jobs, queue consumers, cron, Vercel Workflow steps):
await spanProcessor.forceFlush()in afinallyper invocation. - Next.js on Vercel:
@vercel/otelflushes per request viawaitUntil; nothing to add in route handlers. Work outside a request needs an explicit flush. - Streams: read every stream to the end (
for await (const c of result.textStream)/await result.consumeStream()/ return it as the response) before flushing. An unread stream never ends its spans.
Step 7: Verify
Run one real conversation: 2+ turns with the same id, one streamed, one tool call; plus a second conversation. If the app has no scriptable entry point (server, UI only), write a small driver for this: one conversation id, 2+ turns, at least one tool call, flush before exit. Then check (via Maple MCP list_agent_sessions / get_agent_session, or the Agent Sessions page, ~30 s after the run):
- One session per conversation (id = your
conversationId), nottrace:<id>sessions; the second conversation is a separate session. - Framework shows Vercel AI SDK, not Unidentified.
- Turns = number of
generate/streamcalls (an approval pause + resume = 2 turns); each hasinvoke_agent <model>,step <n>,chat <model>,execute_tool <tool>spans in one trace. - Transcript shows user messages, assistant replies and tool calls; turn labels are the user's messages.
- Every
chatspan has input and output tokens, including the streamed turn; the streamedchatspan has TTFT (gen_ai.client.operation.time_to_first_chunk). - Tool calls have name, arguments, result; a throwing tool is counted as failed with its message; successful tools are not failed.
- Sub-agents show as their own lanes named after their
functionId. Span names carry the model id, not the agent name (invoke_agent <model id>); two agents on one model have identically named spans, and the name is ongen_ai.agent.name. - No attribute contains the provider API key or
Bearer. - Cost shows as unpriced (expected).
- The process exited cleanly and no turn is missing (flush ran).
Without Maple access: the run exits with no export errors on stderr (OTLPExporterError, Failed to export, 401 lines) AND a local run with OTEL_TRACES_EXPORTER=console,otlp (the NodeSDK() variant without spanProcessors) prints the invoke_agent/chat/execute_tool spans, with ai.settings.context.conversationId on the invoke_agent spans. Silence alone proves nothing (no spans also looks silent). With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row.
AI SDK 5/6 (only if the user won't upgrade)
- Tracing is per call:
experimental_telemetry: { isEnabled: true, functionId: "<agent>", metadata: { conversationId } }on every call. Spans:ai.generateText/ai.streamText,.doGenerate/.doStream,ai.toolCall, tracerai. Provider setup as in Step 2a withoutregisterTelemetry/@ai-sdk/otel. - Copy the id to
gen_ai.conversation.idwith a span processor placed before the exporting one:
import type { Context } from "@opentelemetry/api"
import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"
export class ConversationIdProcessor implements SpanProcessor {
onStart(span: Span, _parentContext: Context) {
const id = span.attributes["ai.telemetry.metadata.conversationId"]
if (typeof id === "string") span.setAttribute("gen_ai.conversation.id", id)
}
onEnd(_span: ReadableSpan) {}
forceFlush() {
return Promise.resolve()
}
shutdown() {
return Promise.resolve()
}
}
// spanProcessors: [new ConversationIdProcessor(), spanProcessor]
- Tell the user: final assistant replies won't show in the transcript (recorded only as plain
ai.response.text); upgrading to 7 fixes it.
Do not
- Do not use
experimental_telemetry: { isEnabled: true }as the v7 setup; withoutregisterTelemetrythere are zero spans. - Do not add a second AI SDK tracer that also exports to Maple (Langfuse/Braintrust/Sentry AI integrations, OpenLLMetry/OpenInference AI SDK processors): every model call gets recorded twice.
Version History
- 27a645c Current 2026-10-18 23:12


