Trace OpenRouter calls with Maple: route OpenRouter Broadcast (OTLP) to Maple and edit the app's OpenRouter requests to send session_id and trace ids, so each conversation is one Maple Agent Session with model calls, tokens and real cost. Triggers on 'trace my openrouter calls', 'add Maple to openrouter', 'agent sessions for openrouter', 'OpenTelemetry for openrouter', 'openrouter broadcast to maple'.
77
97%
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
Goal: every conversation = one Maple Agent Session with each OpenRouter model call, its tokens, cost (gen_ai.usage.total_cost, OpenRouter's real charge) and prompt/completion. If the app already exports its own traces to Maple, the Broadcast spans nest inside them and each call is counted once.
Mechanism: OpenRouter Broadcast, configured in the OpenRouter dashboard, exports one OTLP/HTTP JSON trace per request (scope and service.name = openrouter, root span LLM Generation, children provider attempt N: <provider>). Maple reads session.id as the session key. OpenRouter sets session.id from the request's session_id body field or x-session-id header, and uses trace.trace_id / trace.parent_span_id from the body verbatim as the OTLP trace id / parent span id.
You cannot change the OpenRouter dashboard. Your job: (1) edit the app's OpenRouter calls, (2) hand the user the exact destination settings.
What Broadcast cannot give (tell the user, don't try to fix it here): tool spans, tool failures, agent names / sub-agent lanes. Those need the app's framework instrumentation (router skill maple-agent-tracing).
openrouter.ai base URLs (https://openrouter.ai/api/v1, https://eu.openrouter.ai/api/v1), OPENROUTER_API_KEY, @openrouter/ai-sdk-provider, @openrouter/sdk, the openrouter PyPI package, openai clients with an OpenRouter baseURL / base_url, LiteLLM openrouter/ models, framework model classes pointed at OpenRouter.TracerProvider, NodeSDK, registerOTel, registerTelemetry, OTEL_EXPORTER_OTLP_ENDPOINT, ingest.maple.dev, logfire.configure, OpenInference / OpenLLMetry instrumentors.
openrouter.ai vs eu.openrouter.ai), for the destination's data regions.https://ingest.maple.dev. EU: https://ingest.eu.maple.dev.Authorization=Bearer <key>.MAPLE_TEST (ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion. Test Connection passes with it, but nothing lands.maple_sk_ key in browser code. The key here goes into OpenRouter's dashboard, not the repo.session_id on every OpenRouter requestSame id for every request of one conversation, new id per conversation, max 256 characters. If the app also has framework instrumentation, use the SAME value as the framework's conversation/session id (a trace with two different ids is assigned to the lexically larger one, silently).
openai npm (>= 7; field is untyped, sent as-is):
const completion = await client.chat.completions.create({
model,
messages,
// @ts-expect-error OpenRouter-only field
session_id: conversationId,
})Or per-request header, no type workaround:
await client.chat.completions.create({ model, messages }, { headers: { "x-session-id": conversationId } })openai PyPI (>= 3): extra_body={"session_id": conversation_id} on chat.completions.create(...) (and on responses.create(...) if used).
Vercel AI SDK + @openrouter/ai-sdk-provider (>= 3.1): providerOptions: { openrouter: { session_id: conversationId } } on generateText / streamText / agent calls. Everything under providerOptions.openrouter is merged into the body.
@openrouter/sdk: openRouter.chat.send({ model, messages, sessionId: conversationId }). openrouter PyPI: session_id=conversation_id.
Other clients: find the framework's extra-body or per-request-headers option and set session_id / x-session-id. Check the outgoing request (log the body once, or a test that captures fetch) to confirm the field is actually sent; some wrappers drop unknown fields.
Optional: user (<= 128 chars) is forwarded as user.id. Maple does not use it for sessions. Never put emails/names in user, session_id or trace metadata: Privacy Mode does not strip them.
Without this, every model call is recorded twice (app span + Broadcast trace) and Broadcast-only turns split one per model call. Put the active span's W3C ids in trace.trace_id (32 hex) and trace.parent_span_id (16 hex).
TypeScript: wrap fetch and pass it to the client (new OpenAI({ baseURL, apiKey, fetch: openRouterFetch }) or createOpenRouter({ apiKey, fetch: openRouterFetch })). Reads the span active when the SDK sends the request, which is the framework's model-call span if it has one:
import { trace } from "@opentelemetry/api"
// Nests each OpenRouter Broadcast trace under the span that made the request.
export const openRouterFetch: typeof fetch = (input, init) => {
const span = trace.getActiveSpan()?.spanContext()
if (span && typeof init?.body === "string") {
const body = JSON.parse(init.body)
body.trace = { ...body.trace, trace_id: span.traceId, parent_span_id: span.spanId }
init = { ...init, body: JSON.stringify(body) }
}
return fetch(input, init)
}@opentelemetry/api only if it isn't already a dependency (it is, wherever OTel is set up).fetch already, compose with it instead of replacing it.Python (openai or any client with extra_body): build the body at the call site:
from opentelemetry import trace
def openrouter_extra_body(conversation_id: str) -> dict:
body = {"session_id": conversation_id}
ctx = trace.get_current_span().get_span_context()
if ctx.is_valid:
body["trace"] = {
"trace_id": format(ctx.trace_id, "032x"),
"parent_span_id": format(ctx.span_id, "016x"),
}
return bodyThis parents to the span current at the call site (turn/agent span), so the app's model span and Broadcast's LLM Generation are siblings. Maple dedupes siblings only by gen_ai.response.id (OpenRouter's gen-… id). Check that the app's instrumentation records the response id (Vercel AI SDK ai.response.id, OTel openai-v2 instrumentor gen_ai.response.id). If it doesn't, tell the user tokens will double-count for that service, and offer to exclude that service's OpenRouter API key from the destination instead.
Don't set trace_name / span_name / generation_name unless the user asks: span_name creates an extra intermediate span.
Broadcast includes prompts and completions by default (gen_ai.prompt, gen_ai.completion on LLM Generation). The completion object has also been seen echoing the request body (tool definitions, user, session_id, trace). Nothing to change in code. If the user must keep content out of Maple: tell them to enable Privacy Mode on the destination (tokens, cost, timing and metadata still arrive).
provider attempt N: <provider> children; a failed attempt followed by a successful one is a retry and not counted as a failure.LLM Generation status Error, message Provider returned error, counted as provider_error. On this path OpenRouter drops the trace object, so it's its own trace (still in the right session via session.id).Broadcast needs none: OpenRouter exports from its servers after each request. Delivery lag is about a minute. If the app exports its own spans, keep/ensure its normal shutdown flush (sdk.shutdown(), provider.force_flush()), otherwise nested Broadcast spans point at a missing parent.
Print these, filled in (you cannot apply them):
https://openrouter.ai/settings/observability) → Enable Broadcast (org accounts: org admin only).https://ingest.maple.dev/v1/traces (EU: https://ingest.eu.maple.dev/v1/traces). Full path; OpenRouter does not append /v1/traces.{ "Authorization": "Bearer <key>" }1 (sampling is per session; lower values drop whole conversations).eu.openrouter.ai.gen_ai.usage.total_cost, which is sent anyway.ingest_unauthorized / "Invalid ingest key") with a key you trust usually means the key belongs to the other region (keys are region-bound): switch the endpoint to the other region.Run one conversation (3+ turns, one with a tool call) with a fixed session_id, then a second conversation. If the app has no scriptable entry point (server or UI only), write a small driver: one conversation id, 3 turns, one tool call. Wait about a minute. In Maple → Agent Sessions (https://app.maple.dev/agent-sessions, EU app.eu.maple.dev), filter service openrouter (plus the app's service if nested). Check:
session_id, vendor OpenRouter. No trace:<id> sessions. With the Maple MCP: list_agent_sessions with search=<session_id> returns one row.LLM Generation span with gen_ai.operation.name=chat, gen_ai.request.model (OpenRouter slug), gen_ai.response.id (gen-…), input/output tokens, gen_ai.usage.total_cost.LLM Generation has a parent in the app's trace; turns = the app's turns, not one per model call.sk-or-, Bearer or maple_sk_.Tell the user: cost is shown (OpenRouter's charge); TTFT, environment, tool calls and agent lanes are not available from Broadcast.
/v1/traces, no collector needed.session_id also makes OpenRouter route a session's requests to the same provider (better prompt-cache hits). Body session_id wins over the x-session-id header if both are sent.session_id → each call is its own session named trace:<trace id>, one turn, one model call.LLM Generation is a descendant of the app's model-call span, usage is counted at the deepest span that reports it; siblings or separate traces are matched by gen_ai.response.id. When both spans carry cost, Maple keeps the larger (OpenRouter's).LLM Generation root, provider attempt N: <provider> children, sometimes generation / moderation children. Only LLM Generation counts as a model call.gen_ai.prompt = {"messages": [...]}, gen_ai.completion = {"completion": "...", "reasoning": "..."}, both JSON strings. Values over 10,000,000 chars are shortened with a <key>.truncated attribute; ingest body limit 20 MiB.LLM Generation: gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.input_tokens.cached (included in input), gen_ai.usage.output_tokens.reasoning (included in output), gen_ai.usage.total_cost (USD, actual charge), gen_ai.request.model / gen_ai.response.model (OpenRouter slug), gen_ai.response.finish_reasons.trace.metadata.openrouter.first_token_ms (Maple reads TTFT only from gen_ai.response.time_to_first_chunk), the Cost generation-metadata option's span.metadata.openrouter_generation.*.gen_ai.provider.name = the model's author (openai, anthropic); the serving provider is trace.metadata.openrouter.provider_name (e.g. Amazon Bedrock).stream_options.include_usage (server-side accounting).service.name on Broadcast spans is always openrouter, no environment attribute. Custom keys in the trace object arrive as trace.metadata.<key> (searchable in Traces, don't set service/environment).Test Trace - OpenRouter Observability, an openai/gpt-4-turbo call with sample tokens/cost) can appear as a session; ignore it.MAPLE_TEST.type: "otel-collector") with a management key (only if the user explicitly asks and provides a management key).MapleTechLabs/maple
f1305bd
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.