Goal: every conversation with the app shows up in Maple Agent Sessions as exactly one session, one turn per Runner.run, with transcript, model calls, tool calls (failures marked), agent lanes for sub-agents and handoffs, and tokens (streamed turns included).
Mechanism: the SDK has its own tracing pipeline (not OpenTelemetry) whose default processor uploads to the OpenAI dashboard. openinference-instrumentation-openai-agents registers a processor on that pipeline that converts each SDK span into an OTel span; an OTel SDK TracerProvider + OTLP/HTTP exporter sends them to Maple.
Python is the primary path. TypeScript (@openai/agents in package.json): Step 1 applies; then follow Step 2e in place of Steps 0 and 2a-6, and verify with Step 7. The TypeScript bridge exports less (see the end of 2e).
Step 0: Detect versions and existing setup
- Find the project file (
pyproject.toml,requirements*.txt,uv.lock,poetry.lock) and the installedopenai-agentsversion. Targetopenai-agents>=0.22(verified 0.22.3) andopeninference-instrumentation-openai-agents>=2.5(verified 2.5.0; 1.x does not record agent names). Python 3.10 to 3.14. - Grep for existing tracing:
TracerProvider(,set_tracer_provider,OpenAIAgentsInstrumentor,set_trace_processors,add_trace_processor,set_tracing_disabled,OPENAI_AGENTS_DISABLE_TRACING,tracing_disabled=,logfire.configure,instrument_openai_agents,OpenAIInstrumentor,phoenix.otel.register,langfuse,openlit.init,Traceloop.init.- Existing
TracerProviderof the app's own: reuse it. Add aBatchSpanProcessor(OTLPSpanExporter(...))for Maple to it. Do not create a second provider. - Existing
OpenAIAgentsInstrumentor().instrument(...): edit that call; never callinstrument()twice. - Tracing disabled anywhere (
set_tracing_disabled(True),OPENAI_AGENTS_DISABLE_TRACING=1,RunConfig(tracing_disabled=True)): remove it. It kills the pipeline the bridge reads; zero spans. - Any other instrumentation on the same calls (OpenInference
OpenAIInstrumentor, Logfireinstrument_openai_agents/instrument_openai, Langfuse/Traceloop/OpenLIT Agents or OpenAI instrumentors,opentelemetry-instrumentation-genai-openai-agents): remove it or every model call is recorded twice.
- Existing
- Find every
Runner.run(,Runner.run_sync(,Runner.run_streamed(and where the conversation/thread id lives in the request. NoteRunConfig(group_id=...)andSQLiteSession(...)/otherSessionids: reuse that id in Step 3. - Find the model setup: default OpenAI (Responses API,
OPENAI_API_KEY) vs a custom base URL (AsyncOpenAI(base_url=...),set_default_openai_client,OpenAIChatCompletionsModel,set_default_openai_api("chat_completions"), LiteLLM extension).
Step 1: Key and region
- US:
https://ingest.maple.dev. EU:https://ingest.eu.maple.dev. - Header:
Authorization=Bearer <key>. Protocolhttp/protobuf. - 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. This runs server-side; amaple_pk_ingest key is write-only. - Follow the repo's existing secret/env convention (
.env, settings module, secret manager). If there is none, inline is acceptable because ingest keys are write-only: set theOTEL_*values from 2b as defaults at the top of the tracing module, before the provider /NodeSDKis built (os.environ.setdefault(...)/process.env.X ??= ...). - Building the header in code from an env var: 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 raise, throw or exit over the key, and never sendBearer None/Bearer undefined(opaque 401) or hit a bareKeyErroron import. Or inline the key when the repo has no env convention. - 401
ingest_unauthorized/ "Invalid ingest key" with a key you trust: keys are region-bound, so it usually belongs to the other region. Try the other endpoint.
Step 2: Install and initialize
2a. Packages
Add with the project's package manager:
openai-agents>=0.22
openinference-instrumentation-openai-agents>=2.5
opentelemetry-sdk>=1.45
opentelemetry-exporter-otlp-proto-http>=1.45
2b. Environment
OTEL_SERVICE_NAME=<service name, e.g. support-agent>
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=<env>
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev # EU: https://ingest.eu.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
These are read when the exporter / NodeSDK is constructed. If the app loads .env (load_dotenv(), dotenv/config, --env-file), load it at the top of the tracing module, before the provider is built; otherwise the exporter silently targets localhost:4318 with no key.
OTLPSpanExporter() appends /v1/traces to OTEL_EXPORTER_OTLP_ENDPOINT. OTLPSpanExporter(endpoint=...) in code does NOT append; give the full .../v1/traces URL there.
2c. Tracing module
Create tracing.py (or add to the app's existing observability module), imported at the top of the entry point before any Runner.run:
import re
from agents import set_trace_processors
from agents.tracing import TracingProcessor
from agents.tracing.span_data import GenerationSpanData, HandoffSpanData
from openinference.instrumentation import TraceConfig
from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def _chat_message(response: dict) -> dict:
"""The assistant message inside a Responses-shaped dict, as a Chat Completions message."""
text, calls = "", []
for item in response.get("output") or []:
if item.get("type") == "message":
text += "".join(c.get("text", "") for c in item.get("content") or [] if c.get("type") == "output_text")
elif item.get("type") == "function_call":
calls.append({"id": item["call_id"], "type": "function",
"function": {"name": item["name"], "arguments": item["arguments"]}})
return {"role": "assistant", "content": text or None, "tool_calls": calls or None}
class MapleSpanFixes(TracingProcessor):
"""Fills two gaps in what OpenInference exports. Must run before the OpenInference processor."""
def on_span_end(self, span):
data = span.span_data
current = trace.get_current_span() # the matching OpenTelemetry span, still open here
if isinstance(data, HandoffSpanData) and data.to_agent:
# Handoff spans carry no tool name; rebuild the SDK's default one.
current.set_attribute("gen_ai.tool.name", re.sub(r"[^a-zA-Z0-9_]", "_", f"transfer_to_{data.to_agent}").lower())
elif isinstance(data, GenerationSpanData) and data.output and data.output[0].get("object") == "response":
# Streamed Chat Completions calls record a Responses object OpenInference can't read.
data.output = [_chat_message(data.output[0])]
def on_trace_start(self, t): pass
def on_trace_end(self, t): pass
def on_span_start(self, span): pass
def shutdown(self): pass
def force_flush(self): pass
provider = TracerProvider() # resource from OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
set_trace_processors([MapleSpanFixes()]) # drops the SDK's upload to OpenAI
OpenAIAgentsInstrumentor().instrument(
tracer_provider=provider,
config=TraceConfig(enable_genai_semconv=True),
exclusive_processor=False, # append after MapleSpanFixes
)
enable_genai_semconv=Trueis REQUIRED for finish reasons: without it model spans carry none. Env equivalentOPENINFERENCE_ENABLE_GENAI_SEMCONV=trueonly works if set beforeTraceConfig()is built.MapleSpanFixesis required. It runs on each SDK span before the OpenInference processor and fixes two gaps: (1) handoff spans get no tool name, so it sets the SDK's defaulttransfer_to_<agent>name, snake-cased (a name set withtool_name_overridecan't be recovered); (2) streamed Chat Completions calls (run_streamedonOpenAIChatCompletionsModel, LiteLLM, any-llm) record their output as a Responses object that OpenInference can't parse, so the streamed reply is missing from the transcript; it rewrites that output into a Chat Completions message. It must be FIRST in the SDK's processor list, henceset_trace_processors([...])+exclusive_processor=False. Keep the OpenAI dashboard upload too only if the user asks:set_trace_processors([MapleSpanFixes(), default_processor()])(from agents.tracing.processors import default_processor; needs a valid OpenAI key).
2d. Non-OpenAI models (OpenRouter, LiteLLM proxy, vLLM, Ollama, Azure-compatible)
If any agent uses a Chat Completions model on a base URL other than api.openai.com, set ModelSettings(include_usage=True) on those agents (or in RunConfig(model_settings=...)):
agent = Agent(
name="assistant",
instructions="...",
model=OpenAIChatCompletionsModel(model="openai/gpt-4o-mini", openai_client=client),
model_settings=ModelSettings(include_usage=True),
)
Without it the SDK sends no stream_options for non-OpenAI clients and every run_streamed turn has zero tokens. Non-streamed calls are unaffected. Responses API models on OpenAI need nothing.
2e. TypeScript (@openai/agents) only
Steps 0, 2a-2d and 3-6 are Python. For TypeScript do this section instead, then Step 7. Find every run( / runner.run( / Runner call and where the conversation id lives first.
Verified 2026-09-29: @openai/agents 0.18.0, @arizeai/openinference-instrumentation-openai-agents 0.2.15, @arizeai/openinference-core 2.7.1, @opentelemetry/sdk-node 0.222.0, @opentelemetry/api 1.9.1, Node 26.
Install (the project's package manager):
@openai/agents @arizeai/openinference-instrumentation-openai-agents @arizeai/openinference-core @opentelemetry/api @opentelemetry/sdk-node
Env: same OTEL_* variables as 2b. new NodeSDK() reads all of them (service name, resource attributes, endpoint + /v1/traces, headers, protocol; default http/protobuf).
instrumentation.ts, imported as the FIRST line of the entry point (before anything imports @openai/agents and runs an agent): import "./instrumentation.js", or "./instrumentation.ts" when the project runs .ts directly with allowImportingTsExtensions; match the entry file's other relative imports.
import * as agents from "@openai/agents"
import { OpenAIAgentsInstrumentation } from "@arizeai/openinference-instrumentation-openai-agents"
import { NodeSDK } from "@opentelemetry/sdk-node"
// Reads OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES and OTEL_EXPORTER_OTLP_*
export const sdk = new NodeSDK()
sdk.start()
// Replaces the SDK's default processor, which uploads traces to OpenAI
new OpenAIAgentsInstrumentation().manuallyInstrument(agents)
manuallyInstrument(agents)works in ESM and CJS;NodeSDK({ instrumentations: [...] })alone only hooksrequire(), so don't rely on it.- Without
tracerProvider, the bridge uses the GLOBAL tracer provider. App already starts OpenTelemetry (auto-instrumentationsregister, Sentry, its ownNodeTracerProvider().register(),@vercel/otel): don't add aNodeSDK; add aBatchSpanProcessor(new OTLPTraceExporter())for Maple to that provider if it doesn't export to Maple already, and keep themanuallyInstrumentline. A hand-built 2.xNodeTracerProviderdoes NOT readOTEL_SERVICE_NAMEunless givenresource: detectResources({ detectors: [envDetector] })(@opentelemetry/resources); you getunknown_service:node. - The bridge's default
exclusiveProcessor: truecallssetTraceProcessors([bridge]), which drops the OpenAI dashboard upload. Only passmanuallyInstrument(agents, { exclusiveProcessor: false })if the user wants to keep that upload (needs a valid OpenAI key). - Tracing switches:
setTracingDisabled(true),OPENAI_AGENTS_DISABLE_TRACING=1|trueandtracingDisabled: truein the run config give zero spans; remove them.NODE_ENV=testALSO disables the SDK's tracing by default (verified: no export); in tests that should export, callsetTracingDisabled(false)from@openai/agents(verified to re-enable). - Remove other instrumentations of the same calls (
@arizeai/openinference-instrumentation-openai,@opentelemetry/instrumentation-openai, Langfuse/Traceloop/OpenLIT OpenAI instrumentors) or every model call is recorded twice.
Session id: put it in the OpenTelemetry context as gen_ai.conversation.id with setAttributes from @arizeai/openinference-core; the bridge copies context attributes onto every span it starts:
import { setAttributes } from "@arizeai/openinference-core"
import { run } from "@openai/agents"
import { context } from "@opentelemetry/api"
export async function handleMessage(conversationId: string, text: string) {
const ctx = setAttributes(context.active(), { "gen_ai.conversation.id": conversationId })
const result = await context.with(ctx, () => run(agent, text))
return result.finalOutput
}
- App wraps runs in
withTrace(...): putcontext.with(ctx, ...)around thewithTracecall, not the innerrun(the bridge starts the root span from the context active when the trace starts). The root span is then named after thewithTracename, andworkflowNamenames the inner span. - Wrap EVERY
run(...)/runner.run(...)call. Use the id the app already stores the chat under; never a per-request UUID or a constant. If the app passes asession(MemorySession,OpenAIConversationsSession, its own), key it by the same id.groupIdand SDK session ids don't reach Maple. - Streaming: call
run(agent, text, { stream: true })inside the callback; reading the stream (toTextStream(),for await,await stream.completed) may happen aftercontext.withreturns (verified: every span of the streamed turn carried the id). workflowName(run config): same rule as Python, keeptoolout of it. The defaultAgent workflowis fine.
Flush:
- Long-running server: flush on
SIGTERMonly, nothing per request. If the app has noSIGTERMhandler:process.on("SIGTERM", () => sdk.shutdown().catch((err) => console.error("telemetry flush failed", err)).finally(() => process.exit(0))); otherwise add theshutdown()to its handler. - Script / CLI:
await sdk.shutdown().catch((err) => console.error("telemetry flush failed", err))infinally:shutdown()rejects when an export failed, and a Maple outage must not crash the app. - Serverless: add
@opentelemetry/sdk-trace-base+@opentelemetry/exporter-trace-otlp-proto, buildexport const spanProcessor = new BatchSpanProcessor(new OTLPTraceExporter()), passnew NodeSDK({ spanProcessors: [spanProcessor] }), andawait spanProcessor.forceFlush()infinallyof EVERY invocation (verified: spans arrive with the process exiting right afterforceFlush()). Nevershutdown()per invocation.NodeSDKhas noforceFlush()of its own.
What Maple shows for TypeScript (tell the user):
- Works: one session per conversation id, one turn per
run(rootAgent workflowAGENT span, turn label = last user message), operation per span fromopeninference.span.kind(LLM -> chat, TOOL -> execute_tool, AGENT -> invoke_agent), model (llm.model_name: the configured id on Chat Completions, the dated snapshot OpenAI returns on Responses), provideropenai, input/output tokens on every model call INCLUDING streamed Chat Completions on non-OpenAI base URLs (the JS SDK always sendsstream_options.include_usagewhen streaming; noinclude_usagestep needed), cached tokens on Responses, reasoning tokens on Responses and on Chat Completions (when the provider reports them), tool name, tool failures (a throwing tool's span is status ERRORError running tool (non-fatal): ...; a tool that RETURNS an error string counts as success), prompts in the transcript (frominput.value). Handoffs are TOOL spanshandoff to <agent>with tool namehandoff_to_<agent>. - Agent lanes: one per agent name, read from the agent spans'
graph.node.id. - Missing: cost.
Step 3: Session id (one conversation = one session)
Maple reads session.id on this framework's spans. Only OpenInference's using_session sets it. RunConfig(group_id=...), trace_metadata and SDK Session ids are NOT exported.
from openinference.instrumentation import using_session
async def handle_message(conversation_id: str, text: str) -> str:
with using_session(conversation_id):
result = await Runner.run(
agent, text,
session=SQLiteSession(conversation_id, "chats.db"), # or the app's existing Session
run_config=RunConfig(workflow_name="<app name> workflow"),
)
return result.final_output
-
Wrap EVERY
Runner.run/run_sync/run_streamedcall inusing_session(<conversation id>). Use the id the app already stores the chat under (the same one it passes asgroup_idor to itsSession). Never mint a new id per request; never a constant. -
run_streamed: call it inside thewithblock (its background task inherits the context there); consumingstream_events()may continue inside or after. -
Human-in-the-loop resumes (
Runner.run(agent, state)afterstate.approve(...)): wrap in the sameusing_sessionid. The resume is its own trace (a second turn with the same user message as label) whose root is the workflow-named span, not aninvoke_agentroot. -
Set
RunConfig(workflow_name=...)to name the trace root; the defaultAgent workflowis the same for every run. If the app already wraps runs in its owntrace(), the root is named after that andworkflow_namenames the inner CHAIN span. The name must NOT containtool(any case): the per-run CHAIN span carries it with no operation, and Maple's name fallback then counts every run as an extra tool call. End it inworkfloworagent. -
using_sessionstores the id in a contextvar; asyncio tasks inherit it, so concurrent tool calls and agents-as-tools inside the block get it too. -
Multi-agent fan-out with several
Runner.runcalls: wrap them in onewith trace("<name>")(fromagents) insideusing_session, or each run becomes its own trace/turn:import asyncio from agents import trace with using_session(conversation_id), trace("amsterdam briefing"): weather, budget = await asyncio.gather( Runner.run(weather_worker, "Weather in Amsterdam?"), Runner.run(budget_worker, "3-day budget for Amsterdam?"), ) -
Do not set
gen_ai.conversation.idormaple_ai.session.idby hand; the dual-write copiessession.idtogen_ai.conversation.idalready.
Step 4: Content
- Content is ON by default (SDK
trace_include_sensitive_data=True, OpenInference copies it). With Step 2c, messages land ingen_ai.input.messages/gen_ai.output.messages. Nothing to enable. - Content is sent three times per model span (flattened
llm.input_messages.*,input.value,gen_ai.input.messages). Expected; don't strip the OpenInference keys, the GenAI ones are derived from them. - User wants no content / PII-sensitive:
OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=false(orRunConfig(trace_include_sensitive_data=False)). Tell them: empty transcript, tool error details redacted (Tool execution failed. Error details are redacted.), tokens/tools/failures remain. Alternative at the bridge:TraceConfig(enable_genai_semconv=True, hide_inputs=True, hide_outputs=True)(orOPENINFERENCE_HIDE_INPUTS/OPENINFERENCE_HIDE_OUTPUTS); also empties the transcript. - Partial redaction (e.g. mask emails): drop or rewrite attributes in an OpenTelemetry Collector with the
transformorredactionprocessor. - Every model span repeats the conversation so far. Maple has no per-attribute limit and accepts requests up to 20 MiB, so this costs bandwidth, not data.
Step 5: Tools, errors, sub-agents
- Function tools: span named after the tool,
execute_tool,gen_ai.tool.name, description, arguments, result. A tool that RAISES: the SDK catches it and the span gets status ERROR with messageError running tool (non-fatal): {...}; Maple counts it failed. A tool that RETURNS an error string counts as success; point it out, don't change behavior unasked. - Give every
Agenta distinctname=. Agent spans carrygen_ai.agent.name; Maple draws one lane per name. - Agents as tools (
agent.as_tool(...)): nested run appears inside the calling tool span. Nothing to add. - Handoffs: a
handoff to <agent>span (counted as a tool call namedtransfer_to_<agent>, snake-cased, viaMapleSpanFixes) and the target agent span as a sibling of the source agent's. Nothing to add. needs_approval=Truetools: the paused run records a tool span without a result, and the resumed run records the executed call again, so Maple shows the tool twice for one approved call. Expected; tell the user.- Known gaps, don't try to fix: no
gen_ai.tool.call.idon tool spans; nogen_ai.response.idon Chat Completions model spans; no cost. - Model spans: named
generation(Chat Completions) orresponse(Responses API). Model on Chat Completions = the configured id (openai/gpt-4o-mini); on Responses = the name OpenAI returns, usually a dated snapshot (gpt-4o-mini-2024-07-18). Provider is alwaysopenai(even an Anthropic model behind OpenRouter); cached tokens are inside the input total. Responses API also records cached input tokens and reasoning tokens (llm.token_count.completion_details.reasoning). - Cost via OpenRouter: its Broadcast traces (https://maple.dev/docs/agent-tracing/openrouter) carry per-call cost. Because Chat Completions model spans have no
gen_ai.response.id, nest the Broadcast spans under them (see "Join Broadcast to your own traces" in that guide) or each call is counted twice.
Step 6: Flush
-
Long-running server: nothing to add; the provider flushes at normal exit.
-
Script / CLI / worker:
from tracing import provider try: asyncio.run(main()) finally: provider.force_flush() provider.shutdown() -
Serverless handler:
provider.force_flush()before returning from EVERY invocation; nevershutdown(). -
Notebook:
provider.force_flush()after the cell that runs the agent. -
agents.flush_traces()is not enough: the bridge'sforce_flushis a no-op; spans sit in the OTel batch processor.
Step 7: Verify
Run one real conversation: 2-3 turns with the same conversation id including one tool call and one streamed turn, plus a second conversation with a different id. Flush. No scriptable entry point (server, REPL, UI only) → write a small driver: one conversation id, 2+ turns, at least one tool call, flush before exit. Wait ~1 minute. In Maple Agent Sessions, filtered by the service name (or via the Maple MCP list_agent_sessions + get_agent_session), check:
- Exactly one session per conversation id; none named
trace:<id>(that means a run was outsideusing_session). - The two conversations are two different sessions.
- Framework shows OpenAI Agents SDK. Unidentified = wrong/old bridge.
- TypeScript: every span of a run carries
gen_ai.conversation.id. - One turn per
Runner.run; turn labels are the user messages; root span named afterworkflow_name(or the app's owntrace()/withTrace()name). - Transcript shows instructions, user and assistant messages, tool calls, including the streamed turn's reply (missing there in Python =
MapleSpanFixesabsent or not first). - Model calls (
generationfor Chat Completions,responsefor Responses API) show the model and non-zero input/output tokens, INCLUDING the streamed turn (zero there = Step 2d missing). LLM call count equals real model calls. - Each tool call appears once, with its real name and real arguments (not a JSON schema); a raised tool error is marked failed (verdict: Tool availability failed for it) and nothing else is. Only
needs_approvaltools appear twice (pause + resume). - Multi-agent: one lane per agent name; all sub-agent spans in one trace with the same session.
- Each model call appears once (no second
ChatCompletion-style span from another instrumentor). - Cost shows "unpriced" (expected; nothing emits cost).
- No span attribute contains the model provider API key,
Bearerorsk-.
Without Maple access, both must hold: the run exits with no export errors on stderr (Failed to export, OTLPExporterError, 401 lines), AND a local console/in-memory exporter shows the expected span names with gen_ai.conversation.id (TS) / session.id (Python) on every span. Silence alone proves nothing (no spans is silent too). With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row.
Raw span check (optional, e.g. an InMemorySpanExporter in a scratch run): model spans have gen_ai.operation.name=chat, gen_ai.input.messages, gen_ai.usage.input_tokens, session.id; tool spans have gen_ai.operation.name=execute_tool, gen_ai.tool.call.arguments equal to the call's arguments; agent spans have gen_ai.agent.name.