Goal
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 -> chat
Beta 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).
Step 0: Detect
genkitversion inpackage.json/ lockfile: need>= 1.22(disableGenkitOTelInitializationwas added in 1.22). Older: upgrade Genkit first. Node.js >= 20.- Go or Python Genkit: stop; this skill covers TypeScript/JavaScript only. Tell the user.
- Existing OpenTelemetry: search for
NodeSDK,NodeTracerProvider,registerOTel,@vercel/otel,Sentry.init,enableTelemetry(,enableFirebaseTelemetry(,enableGoogleCloudTelemetry(,ENABLE_FIREBASE_MONITORING.- An SDK/provider already exists: reuse it. Add
GenkitForMapleand one Maple exporting processor to it. Never start a second SDK. enableTelemetry({...})fromgenkit/tracingwith custom processors: remove it. Genkit'senableTelemetrybuilds its own bundled@opentelemetry/sdk-node0.52 /sdk-trace-base1.25, and current (2.x) exporters crash inside it (Cannot read properties of undefined (reading 'name')oninstrumentationScope). Don't pass Maple's exporter there.enableFirebaseTelemetry()/enableGoogleCloudTelemetry()/ENABLE_FIREBASE_MONITORING=true:disableGenkitOTelInitialization()makes these no-ops (they go throughenableTelemetry). Ask the user whether Google Cloud trace export may stop. If they need both, stop and tell them; don't wire two SDKs.
- An SDK/provider already exists: reuse it. Add
- Find every flow (
ai.defineFlow(), directai.generate(/ai.generateStream(/prompt(call site, beta agents (defineAgent(,.chat(), and how the app deploys (plain Node server, ExpressstartFlowServer/expressHandler, Next.js@genkit-ai/next, Cloud Functions for FirebaseonCallGenkit, Cloud Run). Find each conversation's id (chat id, thread id, session row).
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 (
.env, FirebasedefineSecret, Secret Manager). If there is none, inlining the ingest key is acceptable. - 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 2: Install
npm install genkit @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto
Use 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).
- 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. The exporter reads theOTEL_*vars when it is constructed; otherwise it silently targetslocalhost:4318with no key. - Building the header from a variable (
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 becomeBearer undefined(opaque 401).
Step 3: The span processor
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:
- It mutates
span.attributesinonEnd. That works because the exporting processor reads the same object; listGenkitForMapleBEFORE the exporting processor (required withSimpleSpanProcessor, which exports insideonEnd). - Optional, only if the user asks for cache/reasoning token detail:
output.usage.cachedContentTokens→gen_ai.usage.cache_read.input_tokens,output.usage.thoughtsTokens→gen_ai.usage.reasoning.output_tokens. Gemini models (googleai/,vertexai/) reportoutputTokenswithout thoughts: for them setoutput_tokenstooutputTokens + thoughtsTokens. Keepinput_tokensas Genkit reports it. - Optional provider label mapping (
googleai→gcp.gemini,vertexai→gcp.vertex_ai): cosmetic, skip unless asked. - Leave
genkit:*attributes in place; they are what the Genkit Developer UI and Google Cloud views read. - Typecheck the file with the repo's
tsc(it passesstrict).
Step 4: Start OpenTelemetry
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.jssuffix,tsx, bundler).- Why
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=devis set bygenkit 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'sTraceServerExporterreads 1.x span fields). So in dev: Developer UI traces, nothing to Maple. If the user wants Maple in dev too, drop theifand tell them the Developer UI trace view goes empty.- Existing provider/SDK: add
new GenkitForMaple()and the MapleBatchSpanProcessorto its span processors (before any exporting processor for GenkitForMaple), keepdisableGenkitOTelInitialization(), don't createNodeSDK. - Next.js with
@vercel/otel: passspanProcessors: [new GenkitForMaple(), new BatchSpanProcessor(new OTLPTraceExporter({ url, headers }))]toregisterOTeland calldisableGenkitOTelInitialization()inregister(). Untested; verify spans arrive. NodeSDK()withspanProcessorsdoesn't build exporters fromOTEL_TRACES_EXPORTER; the explicitOTLPTraceExporterstill readsOTEL_EXPORTER_OTLP_ENDPOINT/HEADERS. It also starts OTLP metric and log exporters from env (defaultotlp); setOTEL_METRICS_EXPORTER=none/OTEL_LOGS_EXPORTER=noneif 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.
Step 5: Conversation id (session)
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)
- It writes to the CURRENT Genkit action, so call it directly in the flow body, not inside a tool or a nested
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 noinvoke_agentspan and no session id; each call becomes its owntrace:<id>session.- Id: stable per conversation, unique across conversations. Never a module constant,
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. - Flows exposed via
startFlowServer/expressHandler/onCallGenkit/appRoute: the client must send the chat id in the flow input; add it toinputSchemaif missing. - Beta agents (
defineAgentetc.): nothing to add;genkit:metadata:agent:sessionIdis used. - The flow name is the agent name in Maple (
gen_ai.agent.name). Give each distinct agent its own flow name. - Sub-agents: a flow called from inside another flow or a tool also becomes an
invoke_agentspan 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. - Don't set
gen_ai.conversation.idormaple_ai.session.idon spans yourself; the processor does it.
Step 6: Content, errors, flush
- Content: Genkit always records
genkit:input/genkit:output(full prompts, replies, tool args/results). There is no Genkit switch. If the user needs content kept out of Maple, deletegenkit:input/genkit:outputin the processor after mapping and skip the message/argument/result attributes; tell them transcripts will be empty. - Never set
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT/OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: a truncatedgenkit:inputmakesJSON.parsein the processor throw insidespan.end(). - Tool failures: a tool that throws ends with status
ERROR(Maple counts it failed) and aborts the wholegenerate/ 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. - Streaming (
ai.generateStream,flow.stream()): model spans end with the full output and usage; read the stream to the end before flushing. - Flush:
- Scripts/CLIs:
await sdk.shutdown().catch((err) => console.error("telemetry flush failed", err))in afinally.shutdown()rejects when an export failed; the.catchkeeps a Maple outage from crashing the app. - Long-running servers (Cloud Run, plain Node):
process.on("SIGTERM", () => sdk.shutdown().catch((err) => console.error("telemetry flush failed", err))); nothing per request. - Serverless handlers you control:
await spanProcessor.forceFlush()after the flow returns (the flow span ends when the flow returns, so flushing inside the flow misses it). - Cloud Functions for Firebase
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 withonCalland call the flow thenforceFlush()before returning. Untested.
- Scripts/CLIs:
Step 7: Verify
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):
- One session per conversation (id = your conversation id), not
trace:<id>sessions; the second conversation is separate. - Framework shows Genkit.
- Turns = number of flow runs; each trace has
invoke_agent(flow name) →generate→ model (chat) and tool (execute_tool) spans. - Transcript shows user messages, assistant replies, tool calls with arguments and results; turn labels are the user's messages.
- Every model span has input and output tokens (if the model plugin reports usage).
- A throwing tool is failed; successful tools are not.
- No attribute contains the provider API key or
Bearer. - Under
genkit start, the Developer UI still shows traces (and nothing reaches Maple). - 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 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.
Do not
- Do not add a second GenAI tracer that also exports to Maple (OpenLLMetry/OpenInference Google GenAI or OpenAI instrumentations for the same calls): model calls get recorded twice.