Trace Genkit (TypeScript/Node.js) agents with Maple: export Genkit's OpenTelemetry spans to Maple, map its genkit:* attributes to the GenAI conventions with a span processor, and stamp a conversation id so each chat is one Agent Session with transcript, tool calls and tokens. Covers flows, ai.generate, generateStream and beta defineAgent chats. Triggers on 'trace my genkit agent', 'add Maple to genkit', 'agent sessions for genkit', 'OpenTelemetry for genkit', 'firebase genkit tracing'.
76
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
One conversation = one Maple Agent Session, one turn per flow run, with the transcript, every model call (model, tokens) and every tool call (name, args, result, failures).
Span tree per flow run:
supportChat genkit:metadata:subtype=flow, genkit:isRoot=true -> invoke_agent
└── generate genkit:type=util (one nested generate per tool-loop step, left unmapped)
├── googleai/gemini-2.5-flash subtype=model -> chat
├── getWeather subtype=tool -> execute_tool
└── generate
└── googleai/gemini-2.5-flash subtype=model -> chatBeta agents (ai.defineAgent() / defineCustomAgent / definePromptAgent from genkit/beta): root span has subtype agent and genkit:metadata:agent:sessionId (Genkit's session id), which the processor uses as the conversation id. Under it: runTurn-<n> (flowStep), render (promptTemplate), generate, model, tool spans. One trace per chat.send().
Known gaps (tell the user, don't try to fix): cost shows as unpriced; gen_ai.provider.name is the Genkit plugin prefix (googleai, vertexai, openai, anthropic...) rather than the semconv value (gcp.gemini...), which is only a label in Maple; media parts are left out of transcripts; execute_tool spans have no gen_ai.tool.call.id (Genkit doesn't put the call ref on the tool span; the transcript still pairs calls and results through the ids in the model messages when the model plugin sets ref).
genkit version in package.json / lockfile: need >= 1.22 (disableGenkitOTelInitialization was added in 1.22). Older: upgrade Genkit first. Node.js >= 20.NodeSDK, NodeTracerProvider, registerOTel, @vercel/otel, Sentry.init, enableTelemetry(, enableFirebaseTelemetry(, enableGoogleCloudTelemetry(, ENABLE_FIREBASE_MONITORING.
GenkitForMaple and one Maple exporting processor to it. Never start a second SDK.enableTelemetry({...}) from genkit/tracing with custom processors: remove it. Genkit's enableTelemetry builds its own bundled @opentelemetry/sdk-node 0.52 / sdk-trace-base 1.25, and current (2.x) exporters crash inside it (Cannot read properties of undefined (reading 'name') on instrumentationScope). Don't pass Maple's exporter there.enableFirebaseTelemetry() / enableGoogleCloudTelemetry() / ENABLE_FIREBASE_MONITORING=true: disableGenkitOTelInitialization() makes these no-ops (they go through enableTelemetry). Ask the user whether Google Cloud trace export may stop. If they need both, stop and tell them; don't wire two SDKs.ai.defineFlow(), direct ai.generate( / ai.generateStream( / prompt( call site, beta agents (defineAgent(, .chat(), and how the app deploys (plain Node server, Express startFlowServer/expressHandler, Next.js @genkit-ai/next, Cloud Functions for Firebase onCallGenkit, Cloud Run). Find each conversation's id (chat id, thread id, session row).https://ingest.maple.dev, EU endpoint https://ingest.eu.maple.dev. Header Authorization=Bearer <key>.MAPLE_TEST (ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion.maple_sk_ keys never go in browser code. Ingest keys are write-only..env, Firebase defineSecret, Secret Manager). If there is none, inlining the ingest key is acceptable.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.npm install genkit @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-protoUse the repo's package manager. @opentelemetry/api arrives as a peer; add it explicitly only if the package manager doesn't install peers. 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>"Inlining instead of env: new OTLPTraceExporter({ url: "https://ingest.maple.dev/v1/traces", headers: { authorization: "Bearer <key>" } }) (the full /v1/traces path is needed when passing url).
.env (dotenv, --env-file): load it at the top of instrumentation.ts (import "dotenv/config" as its first line) or run with --env-file. The exporter reads the OTEL_* vars when it is constructed; otherwise it silently targets localhost:4318 with no key.Bearer ${process.env.MAPLE_INGEST_KEY}): when it is unset, log one warning (MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled) and skip the Maple exporter so the app runs normally. Never throw over the key, and never let it become Bearer undefined (opaque 401).Create genkit-for-maple.ts next to the entry point, verbatim:
// genkit-for-maple.ts
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base"
type Part = {
text?: string
reasoning?: string
toolRequest?: { name: string; ref?: string; input?: unknown }
toolResponse?: { name: string; ref?: string; output?: unknown }
}
type Message = { role: string; content: Part[] }
// Genkit message parts to OpenTelemetry GenAI parts. Media parts are left out.
function toPart({ text, reasoning, toolRequest, toolResponse }: Part) {
if (text !== undefined) return { type: "text", content: text }
if (reasoning !== undefined) return { type: "reasoning", content: reasoning }
if (toolRequest) {
return { type: "tool_call", id: toolRequest.ref, name: toolRequest.name, arguments: toolRequest.input }
}
if (toolResponse) return { type: "tool_call_response", id: toolResponse.ref, response: toolResponse.output }
return undefined
}
const toParts = (content: Part[]) => content.map(toPart).filter((part) => part !== undefined)
function toMessages(messages: Message[]) {
return messages.map((m) => ({ role: m.role === "model" ? "assistant" : m.role, parts: toParts(m.content) }))
}
/** Adds the gen_ai.* attributes Maple reads to Genkit's flow, model and tool spans. */
export class GenkitForMaple implements SpanProcessor {
onStart() {}
onEnd(span: ReadableSpan) {
const attrs = span.attributes
const json = (key: string) => {
const value = attrs[key]
return typeof value === "string" ? JSON.parse(value) : undefined
}
const name = String(attrs["genkit:name"])
switch (attrs["genkit:metadata:subtype"]) {
case "flow":
case "agent": {
Object.assign(attrs, {
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": name,
})
// Set in your flow, or by Genkit for defineAgent() chats
const conversationId = attrs["genkit:metadata:conversationId"] ?? attrs["genkit:metadata:agent:sessionId"]
if (conversationId !== undefined) attrs["gen_ai.conversation.id"] = conversationId
break
}
case "model": {
const input = json("genkit:input")
const output = json("genkit:output")
const [provider, ...model] = name.split("/")
const messages: Message[] = input?.messages ?? []
const system = messages.filter((m) => m.role === "system").flatMap((m) => toParts(m.content))
Object.assign(attrs, {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": provider,
"gen_ai.request.model": model.join("/") || name,
"gen_ai.input.messages": JSON.stringify(toMessages(messages.filter((m) => m.role !== "system"))),
})
if (system.length > 0) attrs["gen_ai.system_instructions"] = JSON.stringify(system)
if (output?.message) {
attrs["gen_ai.output.messages"] = JSON.stringify(
toMessages([output.message]).map((m) => ({ ...m, finish_reason: output.finishReason })),
)
}
if (output?.finishReason) attrs["gen_ai.response.finish_reasons"] = [output.finishReason]
if (output?.usage?.inputTokens !== undefined) attrs["gen_ai.usage.input_tokens"] = output.usage.inputTokens
if (output?.usage?.outputTokens !== undefined) attrs["gen_ai.usage.output_tokens"] = output.usage.outputTokens
break
}
case "tool": {
Object.assign(attrs, {
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": name,
"gen_ai.tool.call.arguments": attrs["genkit:input"] ?? "{}",
})
const result = json("genkit:output")
if (result !== undefined) {
attrs["gen_ai.tool.call.result"] = typeof result === "string" ? result : JSON.stringify(result)
}
break
}
}
}
forceFlush() {
return Promise.resolve()
}
shutdown() {
return Promise.resolve()
}
}It maps:
Genkit span (genkit:metadata:subtype) | Adds |
|---|---|
flow, agent | gen_ai.operation.name=invoke_agent, gen_ai.agent.name=<genkit:name>, gen_ai.conversation.id from genkit:metadata:conversationId or genkit:metadata:agent:sessionId |
model | chat, gen_ai.provider.name (prefix before /), gen_ai.request.model (rest), gen_ai.input.messages / gen_ai.system_instructions from genkit:input.messages, gen_ai.output.messages + gen_ai.response.finish_reasons from genkit:output, gen_ai.usage.input_tokens / output_tokens from genkit:output.usage |
tool | execute_tool, gen_ai.tool.name, gen_ai.tool.call.arguments (= genkit:input), gen_ai.tool.call.result (= genkit:output, string results as plain text) |
Message conversion: role model → assistant; parts {text} → text, {reasoning} → reasoning, {toolRequest:{name,ref,input}} → tool_call, {toolResponse:{name,ref,output}} → tool_call_response; media, data, custom parts are dropped.
Notes:
span.attributes in onEnd. That works because the exporting processor reads the same object; list GenkitForMaple BEFORE the exporting processor (required with SimpleSpanProcessor, which exports inside onEnd).output.usage.cachedContentTokens → gen_ai.usage.cache_read.input_tokens, output.usage.thoughtsTokens → gen_ai.usage.reasoning.output_tokens. Gemini models (googleai/, vertexai/) report outputTokens without thoughts: for them set output_tokens to outputTokens + thoughtsTokens. Keep input_tokens as Genkit reports it.googleai → gcp.gemini, vertexai → gcp.vertex_ai): cosmetic, skip unless asked.genkit:* attributes in place; they are what the Genkit Developer UI and Google Cloud views read.tsc (it passes strict).Create instrumentation.ts:
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"
import { NodeSDK } from "@opentelemetry/sdk-node"
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { disableGenkitOTelInitialization } from "genkit/tracing"
import { GenkitForMaple } from "./genkit-for-maple"
export const spanProcessor = new BatchSpanProcessor(new OTLPTraceExporter())
export const sdk = new NodeSDK({ spanProcessors: [new GenkitForMaple(), spanProcessor] })
if (process.env.GENKIT_ENV !== "dev") {
disableGenkitOTelInitialization()
sdk.start()
}import "./instrumentation" as the FIRST line of every entry point (server, worker, CLI, Cloud Function index). It must run before Genkit's first span: otherwise Genkit's own SDK registers the global tracer provider first and Maple's never receives spans. Adjust the import extension/style to the repo (ESM .js suffix, tsx, bundler).disableGenkitOTelInitialization(): without it Genkit lazily starts a second, bundled NodeSDK on its first span. With Maple's SDK started first that second one mostly loses the global registration race, but it's two SDKs; disable it.GENKIT_ENV=dev is set by genkit start (Developer UI). Genkit's own SDK then ships traces to the Developer UI's telemetry server; Maple's SDK can't feed it (Genkit's TraceServerExporter reads 1.x span fields). So in dev: Developer UI traces, nothing to Maple. If the user wants Maple in dev too, drop the if and tell them the Developer UI trace view goes empty.new GenkitForMaple() and the Maple BatchSpanProcessor to its span processors (before any exporting processor for GenkitForMaple), keep disableGenkitOTelInitialization(), don't create NodeSDK.@vercel/otel: pass spanProcessors: [new GenkitForMaple(), new BatchSpanProcessor(new OTLPTraceExporter({ url, headers }))] to registerOTel and call disableGenkitOTelInitialization() in register(). Untested; verify spans arrive.NodeSDK() with spanProcessors doesn't build exporters from OTEL_TRACES_EXPORTER; the explicit OTLPTraceExporter still reads OTEL_EXPORTER_OTLP_ENDPOINT / HEADERS. It also starts OTLP metric and log exporters from env (default otlp); set OTEL_METRICS_EXPORTER=none / OTEL_LOGS_EXPORTER=none if the repo doesn't want them.Genkit starts a NEW TRACE for every root Genkit action (it passes root: true), even inside an HTTP server span. That's fine for Maple (one trace per turn). If the user wants flows nested under their HTTP spans, disableOTelRootSpanDetection() from genkit/tracing changes it; not needed for Agent Sessions.
Every turn must run inside a flow (or a beta agent). In each flow that handles a conversation, call at the start:
import { setCustomMetadataAttribute } from "genkit/tracing"
setCustomMetadataAttribute("conversationId", chatId)ai.run() step. It throws outside any Genkit action.ai.generate() called outside a flow (e.g. straight from an Express route): wrap the call in a flow (ai.defineFlow) and call the flow from the route. Without a flow there is no invoke_agent span and no session id; each call becomes its own trace:<id> session.Date.now() per call or a per-process id. If the app has no id, create one when the conversation is created and persist it with it.startFlowServer / expressHandler / onCallGenkit / appRoute: the client must send the chat id in the flow input; add it to inputSchema if missing.defineAgent etc.): nothing to add; genkit:metadata:agent:sessionId is used.gen_ai.agent.name). Give each distinct agent its own flow name.invoke_agent span named after that flow (expected to show as a sub-agent lane; not verified in Maple). Don't set the conversation id on inner flows.gen_ai.conversation.id or maple_ai.session.id on spans yourself; the processor does it.genkit:input / genkit:output (full prompts, replies, tool args/results). There is no Genkit switch. If the user needs content kept out of Maple, delete genkit:input/genkit:output in the processor after mapping and skip the message/argument/result attributes; tell them transcripts will be empty.OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT / OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: a truncated genkit:input makes JSON.parse in the processor throw inside span.end().ERROR (Maple counts it failed) and aborts the whole generate / flow (Genkit doesn't feed the error back to the model). Don't change tool error behavior for tracing; tools that return { error } payloads show as successful, which is accurate to what the model saw.ai.generateStream, flow.stream()): model spans end with the full output and usage; read the stream to the end before flushing.await sdk.shutdown().catch((err) => console.error("telemetry flush failed", err)) in a finally. shutdown() rejects when an export failed; the .catch keeps a Maple outage from crashing the app.process.on("SIGTERM", () => sdk.shutdown().catch((err) => console.error("telemetry flush failed", err))); nothing per request.await spanProcessor.forceFlush() after the flow returns (the flow span ends when the flow returns, so flushing inside the flow misses it).onCallGenkit(flow): you can't hook after the flow; the instance may be throttled after the response, so the last batch can be delayed or lost. Tell the user; if it matters, wrap with onCall and call the flow then forceFlush() before returning. Untested.Run one conversation with 2+ turns under the same id, one tool call, plus a second conversation. If the app has no scriptable entry point (server, Developer UI only), write a small driver for this that calls the flow directly: one conversation id, 2+ turns, at least one tool call, flush before exit. Then check (Maple MCP list_agent_sessions / get_agent_session, or the Agent Sessions page, ~30 s after the run):
trace:<id> sessions; the second conversation is separate.invoke_agent (flow name) → generate → model (chat) and tool (execute_tool) spans.Bearer .genkit start, the Developer UI still shows traces (and nothing reaches Maple).Without Maple access: the run exits with no export errors on stderr (OTLPExporterError, Failed to export, 401 lines) AND a local check shows the spans. Silence alone proves nothing (no spans also looks silent). Local check: point OTEL_EXPORTER_OTLP_ENDPOINT at a small HTTP server and swap in @opentelemetry/exporter-trace-otlp-http with OTEL_EXPORTER_OTLP_PROTOCOL=http/json to read the spans as JSON; confirm gen_ai.operation.name on flow/model/tool spans and gen_ai.conversation.id equal across turns. With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row.
MapleTechLabs/maple
27a645c
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.