Parity matrix¶
What this SDK does, against the Python reference genai_otel_instrument v1.46.0 (contract re-synced from v1.30.0; the rows below that name v1.30.0 describe what was found then).
Every instrumentor id the reference accepts in GENAI_ENABLED_INSTRUMENTORS has a row.
Status values: shipped (tests, an example, a changelog line), planned with the phase, or not applicable with the reason. Nothing is marked shipped that is not.
Last updated: phase 1 (foundation). Phases are those of the implementation plan: 2 providers, 3 frameworks and MCP / datastores, 4 GPU / energy / CO2, server metrics, evaluation, media.
Foundation¶
| Area | Status | Notes |
|---|---|---|
| Configuration from environment | shipped | Every variable, default and raw-value case in contract/config.json |
| Resource attributes and identity | shipped | Same attribute set as the reference; identity differs by design (telemetry.sdk.language=nodejs, telemetry.distro.name=@traceverse/genai-otel-instrument-node) |
| Cost calculator | shipped | All 2,545 vectors in contract/cost_vectors.json, bit for bit |
| Cost enrichment of spans from other instrumentation | shipped | One span processor (onEnding); the reference needs a processor plus an exporter wrapper |
Semantic-convention names, OTEL_SEMCONV_STABILITY_OPT_IN |
shipped | Generated from the contract; all 13 opt-in cases |
| GenAI client metrics (20 instruments) and histogram views | shipped | Name, unit, kind, description, scope and bucket boundaries from contract/metrics.json |
| OTLP export: http/protobuf, gRPC, console | shipped | gRPC selected by OTEL_EXPORTER_OTLP_PROTOCOL or port 4317; not exercised against a gRPC collector yet |
| Zero-code entry point | shipped | node --require .../register (CommonJS), node --import .../register (ES modules) |
| Sampling, W3C trace-context propagation, flush on exit / SIGTERM | shipped | |
| Session / user id extractors | shipped | Programmatic only, as in the reference |
withSession() / setSession() |
shipped | Not present in Python. Puts session.id, gen_ai.conversation.id and optionally user.id on every span in scope; an extractor still wins |
| Agent budget governance | shipped | gen_ai.agent.iteration_budget, gen_ai.agent.token_budget, the two .consumed attributes and the gen_ai.invoke_agent.token_budget.utilization histogram, accumulated through the context so that sampled-out child calls still count |
Server concurrency gauge (GENAI_ENABLE_CONCURRENCY_METRICS) |
shipped | gen_ai.server.requests.running{server_metrics_source="client"}: this SDK's own model calls in flight, on while the setting is (default on; the reference parses it and never reads it). Values an application sets through the server-metrics API carry no label; engine readings carry "engine" (SDK_ADDITIONS.md section 12) |
| GPU, energy, CO2 metrics | shipped for NVIDIA on Node | 16 per-device gauges and 4 aggregates from nvidia-smi, polled every GENAI_GPU_COLLECTION_INTERVAL seconds; energy, electricity cost and CO2 from measured GPU power. Parsed against the real output of an RTX 5090 Laptop GPU (driver 592.02). Not shipped: AMD (amd-smi), CPU and RAM energy, PCIe throughput. Not run on a multi-GPU or data-centre host. See gpu-energy.md |
| Evaluation (PII, toxicity, bias, prompt injection, restricted topics, hallucination) and pre-call blocking | shipped | The five rule-based detectors match all 170 of their vectors in contract/eval_vectors.json exactly, floats included, and the span attributes match the 10 non-toxicity span_attribute_vectors. Rules are generated from contract/eval_rules.json; Python re semantics (Unicode \w \d \s \b, code-point offsets) are reproduced. Toxicity: see the differences table. Pre-call blocking throws PolicyViolationError. See evaluation.md |
| Multimodal content capture and offload | shipped | Detection, offload and the canonical form match vectors made by running the reference; filesystem, HTTP and S3 / MinIO stores. Text needs content capture too; redactors are registered functions. See media.md |
Anonymous usage reporting (TRACEVERDE_TELEMETRY) |
not applicable | This SDK makes no call home |
Providers¶
| Reference id | Node library | Status |
|---|---|---|
openai |
openai |
shipped - Chat Completions (buffered, streamed, tool calls), Embeddings, Responses API |
anthropic |
@anthropic-ai/sdk |
shipped - messages.create (buffered, streamed), messages.stream() |
azure.ai.openai |
openai (AzureOpenAI) |
covered by openai - the AzureOpenAI client is traced by the OpenAI instrumentor (gen_ai.system = openai, endpoint in server.address), which is also how the reference traces openai.AzureOpenAI. The reference's separate instrumentor targets the legacy azure.ai.openai client, which has no JavaScript counterpart (@azure/openai 2.x is a types companion) |
azure.ai.inference |
@azure-rest/ai-inference |
shipped - azure.ai.inference.embeddings as in the reference, plus azure.ai.inference.chat (SDK_ADDITIONS.md section 7), gen_ai.system = azure_ai_inference. The client is a REST client with no class per operation, so requests are observed in the Azure pipeline (@azure/core-rest-pipeline), for /chat/completions and /embeddings only. A streamed request gets its span and no tokens. Tested against the mock provider; not run against Azure |
google.generativeai |
@google/genai |
shipped - generateContent, generateContentStream, embedContent (one span per request to the model). The legacy @google/generative-ai package is end-of-life and not instrumented |
vertexai |
@google-cloud/vertexai |
shipped - GenerativeModel.generateContent, as the reference. Vertex AI through @google/genai (vertexai: true) is traced by the google.generativeai instrumentor, as in the reference |
boto3 |
@aws-sdk/client-bedrock-runtime |
shipped - Converse, ConverseStream, InvokeModel, InvokeModelWithResponseStream, through client.send() and the aggregated client |
cohere |
cohere-ai |
shipped - generate, embed (as the reference), plus chat / chatStream on the v1 and v2 clients (not present in Python, which traces only the legacy generate) |
mistralai |
@mistralai/mistralai |
shipped - chat.complete, chat.stream, embeddings.create. Span names carry the model, as in the reference |
groq |
groq-sdk |
shipped - chat completions (buffered, streamed) |
together |
together-ai |
shipped - chat completions, embeddings |
ollama |
ollama |
shipped - chat, generate (buffered, streamed), embed, embeddings; token estimate when the server reports no counts. Server-metrics poller (/api/ps): shipped - per-model VRAM, and its share only when the GPU's VRAM is known |
replicate |
replicate |
shipped - run(). No tokens and no cost: Replicate bills by hardware time, as the reference notes |
elevenlabs |
@elevenlabs/elevenlabs-js |
shipped - textToSpeech.convert / stream (cost by character), speechToText.convert (cost by second) |
sarvamai |
sarvamai |
shipped - chat completions, translate, transliterate, identify-language, speech-to-text (transcribe, translate), text-to-speech (convert, stream); cost per thousand characters |
openrouter |
openai at openrouter.ai |
shipped - chat completions (buffered, streamed, tool calls), embeddings |
cometapi |
openai / @anthropic-ai/sdk at cometapi.com |
shipped - chat completions, embeddings, messages.create |
hyperbolic |
openai at api.hyperbolic.xyz |
shipped - chat completions. Not in the default list, as in the reference: enable it by name. The reference traces raw requests.post; JavaScript applications use the OpenAI client |
sambanova |
openai at api.sambanova.ai |
shipped - chat completions. The reference patches the sambanova Python SDK; there is no maintained JavaScript one |
anyscale |
openai |
shipped as the no-op it is in the reference; calls are traced by the openai instrumentor |
vllm, llamacpp (server) |
openai with a base URL |
Traced by the openai instrumentor with the endpoint in server.address; llama.cpp timings become gen_ai.latency.*. shipped (not in the reference): GENAI_SERVER_METRICS_ENDPOINTS polls each server's /metrics - KV cache per model (vllm:kv_cache_usage_perc / gpu_cache_usage_perc, llamacpp:kv_cache_usage_ratio), waiting (vllm:num_requests_waiting, llamacpp:requests_deferred), and the vLLM prefix cache as gen_ai.server.prefix_cache.queries / .hits (section 13: observable gauges of the cumulative counters vllm:prefix_cache_*_total, older vllm:gpu_prefix_cache_*, summed per model over the engines; unit {token}; absent when not exported; no hit rate). All server_metrics_source="engine". Tested against a captured llama.cpp /metrics (build b9151) and vLLM text written from vLLM's metric definitions - no vLLM server was run |
transformers (inference client half) |
@huggingface/inference |
shipped, by wrapping - chatCompletion, chatCompletionStream, textGeneration on a client passed through traceHuggingFaceClient(). The client cannot be patched from outside (per-instance non-configurable methods, read-only exports) |
transformers (local pipeline half) |
@huggingface/transformers (transformers.js) |
shipped - huggingface.pipeline, huggingface.model.generate, and transformers.model.load. On Node found by name; in a browser handed over with instrumentTransformers() |
litellm_latency, litellm |
- | not applicable: LiteLLM is a Python library. A LiteLLM gateway is reached through the openai client and is traced by the openai instrumentor |
llamacpp (in-process), vllm (in-process) |
- | not applicable: Python in-process bindings |
sentence_transformers |
- | not applicable: Python-only library |
liquid_audio |
- | not applicable: Python-only library |
Frameworks¶
| Reference id | Node library | Status |
|---|---|---|
langchain |
@langchain/core |
shipped - langchain.chat_model.invoke / .batch / .stream on BaseChatModel (provider from the class name, model, message count, tokens, cost) and langchain.chain.<Class> on runnable sequences, which are the chains of the JavaScript library. Not ported: langchain.agent.execute (AgentExecutor lives in the separate langchain package and is superseded by LangGraph there) |
langgraph |
@langchain/langgraph |
shipped - langgraph.graph.invoke / langgraph.graph.stream on a compiled graph: node, edge and channel structure, thread and checkpoint ids, recursion limit as the iteration budget, and what the model calls inside consumed. One span per run (invoke runs through stream). The sequences LangGraph builds around its nodes are not reported as LangChain chains |
llama_index |
llamaindex |
shipped - llamaindex.query_engine on BaseQueryEngine.query, as far as the reference goes |
openai_agents |
@openai/agents |
shipped - openai_agents.runner.run on Runner.run (which the package's run() also goes through): agent, tools, handoffs, guardrails, turn limit as the iteration budget, output length, guardrail results. No run_sync in the JavaScript library |
google_adk |
@google/adk |
shipped - google_adk.runner.run on Runner.runAsync, open until the event stream ends: app, agent, sub-agents, tools, user and session. No run_debug in the JavaScript library |
bedrock_agents |
@aws-sdk/client-bedrock-agent-runtime |
shipped - bedrock.agents.invoke_agent, bedrock.agents.retrieve, bedrock.agents.retrieve_and_generate on the client's send, by command; every other command passes through untraced. The InvokeAgent span ends when the call returns, before the completion stream is read, as in the reference |
mcp_client, mcp |
@modelcontextprotocol/sdk |
shipped, opt-in by name as in the reference - one client span per callTool with the mcp.* / commerce.* attribution (server, stage, behaviour, idempotency, tool-selection correctness, hallucinated identifiers, cart hash, duplicate placement); the tool registry, session tracker and identifier hashing of mcp_semconv. Checked against vectors generated by running the reference. The terminal-state classifier (classifySession, TerminalStateClassifier): a finished session's spans reduced to one of the seven outcomes with its evidence, checked against 26 sessions classified by the reference (state, rule, signals, evidence, audit text) |
autogen, autogen_agentchat |
- | not applicable: no maintained JavaScript port of AutoGen |
crewai |
- | not applicable: Python-only |
dspy |
- | not applicable: Python-only |
haystack |
- | not applicable: Python-only |
instructor |
@instructor-ai/instructor |
not ported. It wraps the openai client, whose calls are traced by the OpenAI instrumentor; the reference's own instructor.* spans are not produced |
guardrails |
- | not applicable: Python-only |
pydantic_ai |
- | not applicable: Python-only |
smolagents |
- | not applicable: Python-only |
strands |
@strands-agents/sdk |
not ported |
typesafe |
- | to be confirmed in phase 2: no JavaScript client known |
| - | Vercel AI SDK (ai 7 with @ai-sdk/otel) |
shipped, JavaScript only - the SDK's functions cannot be patched and it emits OpenTelemetry spans itself; a span processor fills in provider, operation and total tokens on them, the cost processor prices the model-call span (not the invoke_agent span that repeats its tokens), the session is applied, and the prompts and responses the SDK records are removed unless content capture is on. Both attribute styles of @ai-sdk/otel (ai.* and gen_ai.*) are covered. Node entry only: the browser and React Native entries do not install the processor |
| - | Mastra (@mastra/core) |
not exercised. Named in the design; nothing was built or run for it. Spans it exports through OpenTelemetry that carry gen_ai.request.model and gen_ai.usage.* would be priced by the cost processor like any other; that is untested |
A name from the reference list that is not shipped here is skipped at start-up. When it
was requested explicitly (GENAI_ENABLED_INSTRUMENTORS or the enabledInstrumentors
option) a warning says so; with the default list it is logged at debug level.
Datastores and tools¶
Started together when GENAI_ENABLE_MCP_INSTRUMENTATION is on (the default), as the
reference's MCPInstrumentorManager does. Each is skipped when its library is not
installed. Every operation is one client span plus the four mcp.* metrics
(mcp.requests, mcp.client.operation.duration, mcp.request.size,
mcp.response.size).
| Store | Reference | Node library | Status |
|---|---|---|---|
| PostgreSQL | psycopg2 (contrib span + metrics) | pg |
shipped - span named for the verb, db.system = postgresql; pool queries counted once |
| TimescaleDB | psycopg2 cursor | pg |
shipped - a statement using a TimescaleDB function is named timescaledb.<operation>, with the reference's operation list |
| MySQL | mysql-connector | mysql2 |
shipped - query, execute, also through the promise wrapper |
| MongoDB | pymongo | mongodb |
not ported: not in the Node target list |
| SQLAlchemy | sqlalchemy | - | not applicable: a Python ORM; the drivers are covered directly |
| Redis | redis-py (contrib span) | redis, ioredis |
shipped - span named for the command |
| FalkorDB | falkordb | falkordb |
shipped - falkordb.query, falkordb.ro_query, falkordb.delete, falkordb.copy |
| Kafka | kafka-python (contrib span) | kafkajs |
shipped - <topic> send (producer), <topic> receive (consumer, around the application's eachMessage). @confluentinc/kafka-javascript is not covered |
| RabbitMQ | pika | amqplib |
shipped - rabbitmq.basic_publish, basic_consume, basic_get, queue_declare, queue_delete, exchange_declare, exchange_delete; patched when the first connection and channel are created |
| OpenSearch | opensearch-py (contrib span) | @opensearch-project/opensearch |
shipped - opensearch.<api> (search, bulk, doc, ...), with the index |
| Elasticsearch | elasticsearch-py (contrib span) | @elastic/elasticsearch |
shipped, metrics only - the client creates its own OpenTelemetry span per request; this adds the mcp.* metrics, which is the reference's own split |
| MinIO | minio | minio |
shipped - minio.put_object, get_object, remove_object, list_objects, make_bucket, remove_bucket, list_buckets, stat_object, fput_object, fget_object, with bucket and object |
| S3 | - | @aws-sdk/client-s3 |
shipped (not present in Python) - s3.<operation> for every command, with bucket and key |
| Qdrant | qdrant-client | @qdrant/js-client-rest |
shipped - qdrant.query_points, qdrant.search, with collection, top-k and the rag.result.score_* summary |
| Pinecone | pinecone | @pinecone-database/pinecone |
shipped - pinecone.index.query, .upsert, .delete, with the score summary |
| Chroma | chromadb | chromadb |
shipped - chroma.query; the collection class is patched when the client first hands one back |
| Milvus | pymilvus | @zilliz/milvus2-sdk-node |
shipped - milvus.search |
| Weaviate | weaviate-client | weaviate-client |
not ported: the v3 client builds its query objects per collection and its classes can only be reached through a live connection |
| FAISS, LanceDB | faiss, lancedb | - | not ported: not in the Node target list |
| HTTP | httpx | global fetch |
shipped, off by default (GENAI_ENABLE_HTTP_INSTRUMENTATION) - api.call.<method>.<host>. url.full is recorded without its query string, which can carry credentials |
Not present in Python¶
Additions fixed across all four SDKs by contract/SDK_ADDITIONS.md, so that they agree
with each other where the reference has nothing to agree with.
| Addition | Definition |
|---|---|
Counter gen_ai.usage.cost.pricing_missing |
unit "", meter genai_otel.instrumentors.base, attributes model and call_type. Incremented once per span whose model has no price, including the 13 pricing-table entries on which the calculator raises |
withSession(id, fn, { userId }), setSession(id, { userId }), currentSession() |
The reference has only the session_id_extractor / user_id_extractor callbacks (ported). These set session.id + gen_ai.conversation.id (+ user.id) on every span started in scope, from any instrumentation. Confirmed as the cross-SDK shape (contract/SDK_ADDITIONS.md section 4) |
Cohere chat / chatStream spans (cohere.chat) |
The reference Cohere instrumentor wraps only generate and embed. generate is Cohere's legacy endpoint; without chat a current Cohere application produces no span. Confirmed, SDK_ADDITIONS.md section 7 |
On-device spans: transformers.model.load, cactus.download, cactus.init, cactus.complete, cactus.embed, cactus.stt.transcribe; attributes transformers.device, transformers.device.requested, transformers.dtype, transformers.load.*, transformers.tokens_per_second, webgpu.*, cactus.*; counter webgpu.device.lost |
JavaScript only: models that run in the page or on the phone. gen_ai.* attributes are used wherever one exists (tokens, time to first token, model, cost) |
MCP server span mcp.server.call_tool <tool> (server kind) |
The reference instruments the MCP client only. Here the point where an McpServer runs a tool handler is wrapped too, so both sides of a call are spans |
withMcpSession() is also a session scope |
In the reference mcp_session() puts the session id on MCP spans only, and model spans get it through the extractor. Here everything started inside carries session.id |
telemetry.distro.name = @traceverse/genai-otel-instrument-node |
The reference emits genai-otel-instrument, with no suffix |
JavaScript only: three runtimes¶
The reference is a server-side library. This package also loads in browsers and in React
Native (docs/runtimes.md), which the reference has no counterpart for.
| Item | Node | Browser | React Native |
|---|---|---|---|
| Core: config, cost, semconv, metric registry, base instrumentor | yes | yes | yes |
instrument() |
from the environment | explicit options, no default endpoint | explicit options, no default endpoint |
| Export | OTLP http/protobuf, gRPC | OTLP/HTTP JSON over fetch |
OTLP/HTTP JSON over fetch |
| Provider instrumentation by module patching | yes | no | no |
Manual span helper traceGenAiCall() |
no (phase 2) | yes | yes |
| Resource host / process / OS attributes | yes | never collected | never collected |
| Flush | on exit, on SIGTERM | on page hide | on app background (appState) |
transformers.js (instrumentTransformers) |
yes, also by name | yes | yes |
WebGPU adapter facts (collectWebGpuFacts, enableWebGpuFacts, watchWebGpuDevice) |
n/a | yes | n/a |
cactus-react-native (instrumentCactus) |
n/a | n/a | yes |
Deliberate differences from the reference¶
These are choices, each with a reason. Everything else is meant to be identical.
| Behaviour | Reference | This SDK | Why |
|---|---|---|---|
| Cost of a model with no price | calculate_cost() returns 0.0; the span gets no cost attribute and pricing_source = unpriced |
calculateCost() returns null; same span attributes; plus the counter gen_ai.usage.cost.pricing_missing |
A zero that means "not measured" must not be summed as "free". The span is the same; the counter makes the gap visible |
A stream the consumer stops reading (break, abort) |
Span closed with status OK and whatever was measured | Span closed with status ERROR (stream cancelled before completion), the tokens seen, no cost; gen_ai.client.errors{error_type=StreamCancelled} |
Review focus 2 of the implementation plan. A partial cost is not a measurement |
| A stream that fails part-way | Span ERROR; tokens seen are dropped | Span ERROR; tokens seen are kept; no cost | Same |
| A result nobody consumes (stream never iterated, promise never awaited) | Span is never ended | Span ended when the object is garbage collected | No leaked open spans |
host.id, process.owner on the resource |
Not emitted | Not emitted (the Node detectors would add them; they are removed) | Same attribute set; no machine identifier or OS user name leaves the host |
null / undefined request parameters |
None is written as the string "None" |
The attribute is left out | An explicit undefined is routine in JavaScript and would put "undefined" on every span |
GENAI_CUSTOM_PRICING_JSON (deliberate difference from Python v1.30.0, to be fixed upstream; contract/SDK_ADDITIONS.md section 3) |
Ignored by 39 of the 42 instrumentor modules, OpenAI and Anthropic included: their instrument() never calls _setup_config(), so a custom-priced model is reported unpriced (openai_instrumentor.py:80, anthropic_instrumentor.py:244, base.py:563). Cost enrichment of other instrumentation's spans always uses the default table (auto_instrument.py:381) |
Applied by every instrumentor and by the cost-enrichment processor, with the calculator's own merge and validation rules. A custom-priced model gets the gen_ai.usage.cost.* attributes and pricing_source = table, as the reference calculator reports for it |
Founder decision, 2026-10-02: a setting that is accepted and then ignored is a wrong bill |
| Instrumentors the reference writes by hand (Groq, Hyperbolic, ...) | A hand-rolled span with a short attribute list, and request counters tagged {model, provider} |
The same span name and attributes through the common wrapper, which also adds what it derives centrally (server.address, server.port, request parameters) and counts requests as every other instrumentor does ({operation}) |
One code path for streams, cancellation, errors and leaks; a superset of the reference attributes |
| Aggregator instrumentors (OpenRouter, CometAPI, ...) | Patch the client constructor and wrap methods per instance | Wrap the client's class prototypes a second time and act only for a client whose base URL is the provider's | Same spans; also covers clients created before instrument() |
| Bedrock InvokeModel response body | Read as a string: boto3 returns a one-shot stream, so in practice the body, its usage and its text are not available to the instrumentor | The JavaScript client returns the body as bytes, which are parsed without disturbing the caller. Token counts are read under the spellings the model families use (inputTokens, input_tokens, inputTextTokenCount, prompt_token_count, invocation metrics) |
A span with tokens and cost where the data is there to be read |
| Bedrock InvokeModelWithResponseStream | The span ends when the call returns, before the stream is read | The span stays open until the stream ends and takes usage from the invocation metrics on the final chunk | Same rule as every other stream |
| Text-to-speech audio streams (ElevenLabs, Sarvam) | The audio iterator is wrapped: the span covers the whole synthesis and records time to first byte | The span ends when the stream is handed to the application | The JavaScript clients return a web ReadableStream, usually read through a reader or piped; that cannot be observed in place without replacing the object the application receives |
huggingface.pipeline token counts |
Estimated from text length and marked as estimated; the generate span nested in it carries counts too |
The pipeline span carries no tokens and no cost; the nested huggingface.model.generate span carries the measured ones |
Both carrying them counts every token twice |
| Model load: cache or download | n/a | Not reported | transformers.js does not say which happened; a guess from timing would be a fabricated fact. Fetch time and session-create time are reported instead |
| Datastore spans | Taken from the OpenTelemetry contrib instrumentation of each driver (psycopg2, redis, kafka, elasticsearch, httpx); this library adds the mcp.* metrics |
Made by this SDK, to the same database conventions (db.system, db.operation, db.name, db.user, net.peer.name, net.peer.port, client kind), plus the same metrics |
The contrib packages for Node hook module loading, which misses modules loaded earlier and needs a second loader flag for ES-module applications. Prototype patching works for both, with no extra dependency |
db.statement |
Recorded always by the contrib instrumentations; FalkorDB records it bounded | Recorded only with content capture on, bounded by GENAI_CONTENT_MAX_LENGTH (2000 characters when that is 0). Bound parameter values and Redis values are never recorded; AUTH arguments never |
A statement can carry literal personal data; telemetry must not collect it by accident |
db.system label on the mcp.* metrics |
The Python driver's name (psycopg2, mysql, pymongo) |
The database (postgresql, mysql, redis, ...), the same value as the span attribute |
A driver name of another language means nothing here; the label matches the span |
| Content on framework spans | Recorded always, bounded to 200 or 500 characters: agent instructions, run input and output (openai.agent.*), graph state (langgraph.input.*, langgraph.output.*), google_adk.input_message, bedrock.agent.input_text, bedrock.retrieval.query, bedrock.rag.*_text, llamaindex.query |
Recorded only with content capture on, bounded by GENAI_CONTENT_MAX_LENGTH (the reference's bound when that is unset). Names, counts, lengths and identifiers are always recorded |
Content capture is off by default in this SDK, and a framework span is not an exception to it. Confirmed, SDK_ADDITIONS.md section 8 |
| Session of the calls inside a framework run | Each span resolves its own session | A span that carries a session id of its own (a graph's thread, an agent session, a sessionId in the request) puts it in force for the calls made inside it, unless a withSession scope is already open |
Otherwise the model calls of a run would be in no session, or in a different generated one each |
| Session fallback for LangChain and LangGraph | call arguments, extractor, a generated UUID | call arguments, extractor, the session in scope (withSession), a generated UUID |
The scope is this SDK's way to name a session; a generated id must not displace it |
Bedrock RetrieveAndGenerate inference parameters |
Read from inferenceConfig.temperature / maxTokens / topP |
Read from inferenceConfig.textInferenceConfig.*, then from the reference's place |
The API nests them; the reference's path never matches a real request |
| Toxicity with nothing to measure it | The detector answers "not toxic" and the span gets evaluation.toxicity.<prompt\|response>.detected = false |
No toxicity attribute and no toxicity metric. The detector's own return value is the reference's (the 24 toxicity vectors pass); nothing downstream reads it as a measurement | A "false" that nothing measured is a fabricated measurement. Decided by the lead |
| Toxicity backends | Detoxify (local model), Perspective API | By precedence: a scorer function the application supplies, synchronous or asynchronous (setToxicityScorer); an on-prem HTTP judge at GENAI_TOXICITY_JUDGE_URL (SDK_ADDITIONS.md section 12: posts {"text", "categories"}, reads {"scores"} or Detoxify's flat object with obscene as profanity; never traced; allowed when air-gapped); a Detoxify-style ONNX classifier at GENAI_TOXICITY_ONNX_MODEL, run in-process by the optional add-on genai-otel-instrument-node-onnx (onnx/, ONNX Runtime 1.30.0, BERT WordPiece checked against Hugging Face's BertTokenizer, sigmoid or softmax per config.json, labels mapped to the reference's categories; the core never depends on ONNX Runtime). Tested on a toy model, and live through packed packages; not run with a real Detoxify export. No Perspective client ships |
No call out of the organisation the operator did not configure |
| Where spans are evaluated | Inside each instrumentor before the span ends, and again in a span processor for other spans | One span processor, in onEnding, for every span; an instrumented call hands it the full prompt and the response |
The JS SDK lets a processor write a span that is ending |
evaluation.completed |
Set on every span the processor sees | Set only on a span that had a prompt or a response to evaluate | A database or HTTP span is not "evaluated" |
| Evaluation metric instruments | All 22 created at start-up | Created on first use | A detector that never fires leaves no empty series |
| Response text on streamed calls | Not evaluated (the result is a stream) | The same: prompt evaluated, response not | |
| Server gauges before anything was measured | gen_ai.server.requests.running / .waiting / .max report 0 from start-up |
Each reports nothing until its value was set once | A zero nobody measured is not reported |
| Ollama engine figures | Kept: a model that unloads keeps its last KV-cache value | Replaced on every poll; a model that unloads disappears, an Ollama that cannot be read reports nothing | Section 12: engine metrics describe the engine now |
| Gauges nobody observes any more | (Python's SDK drops them) | Not exported: gauges are read with delta temporality, which the wire does not carry; under the JS SDK's default cumulative temporality they would keep their last value | Section 12; also a stopped GPU collector shows as a gap |
| Ollama model share when the host's VRAM is unknown | Computed against an assumed 8 GB | Not reported; one log line says to set GENAI_OLLAMA_MAX_VRAM_GB |
A percentage of an assumed denominator is not a measurement |
gen_ai.latency.* from llama.cpp timings |
Set by the in-process llama_cpp instrumentor only |
Set on any instrumented call whose response carries a timings object (a llama.cpp server behind an OpenAI-compatible client) |
There is no in-process llama.cpp in Node; the server reports the same timings over HTTP |
| How the GPU is read | NVML in-process (nvidia-ml-py), each gauge reading the device when collected |
nvidia-smi --query-gpu run on an interval; the gauges report the latest sample |
There is no NVML binding for Node to depend on. Named in the design |
| GPU energy, electricity cost and manual CO2 | energy = (power_w / 1000) * seconds, then used as watt-hours: 3.6 times too high |
energy_kWh = watts x seconds / 3 600 000, with the mean power of the interval's two ends |
The reference's line multiplies kilowatts by seconds and calls it watt-hours. Confirmed, SDK_ADDITIONS.md section 9 |
| Energy and CO2 source | codecarbon when installed (CPU + GPU + RAM, regional grid data); otherwise the manual formula | Measured GPU power only; CO2 is that energy times GENAI_CARBON_INTENSITY (default 475 gCO2e/kWh). The GENAI_CO2_* location and mode variables are read and have no effect |
No codecarbon outside Python; the staged contract has no carbon-intensity table to vendor. CPU and RAM energy are not measured, so not reported |
gen_ai.codecarbon.task.duration |
Recorded per codecarbon task | Never recorded | There is no codecarbon task |
gen_ai.gpu.pcie.tx / .rx |
From NVML | Never reported | nvidia-smi --query-gpu has no such field |
| A GPU field the device does not report | The NVML call raises and the observation is skipped | [N/A] in the output: no point for that gauge and device |
The same outcome by a different road |
| GPU source failing | Each gauge callback logs an error on every collection, forever | After 5 consecutive failed samples collection stops, with one warning | "Not zeros and not a crash loop" (plan, phase 4) |
| GPU source failing at start | NVML initialisation failing: no GPU metrics for the process | One warning, and the first sample is tried again every interval, up to 5 times | A driver busy at start-up answers a moment later; one lost sample should not cost a whole run's GPU telemetry (platform run 2026-10-04) |
| Response text without content capture | gen_ai.response written regardless by Bedrock, Groq, Mistral, SambaNova and Azure OpenAI (and the prompt by the AsyncOpenAI wrapper) |
Never: content-bearing attributes are removed centrally unless content capture is on | SDK_ADDITIONS.md section 5 |
| Content with capture on | Some providers capture nothing (Google, Together, Hyperbolic); streamed calls never carry the completion | Every chat span carries its prompt events and its completion (gen_ai.completion.0, gen_ai.response), streamed calls included, assembled from the stream and bounded by GENAI_CONTENT_MAX_LENGTH. Off: nothing is assembled |
SDK_ADDITIONS.md sections 6 and 7. Checked across every provider example by tests/e2e/uniform-spans.test.ts |
gen_ai.operation.name |
Missing on Anthropic, Groq, Mistral and hand-built spans | On every model-call span; derived from the request type or span name where an instrumentor sets none | The platform selects single model requests by it. Section 6 |
| Anthropic response identity | No response id, model or finish reason | gen_ai.response.id, .model, .finish_reasons |
Section 6, shared-core superset. Tool-call attributes are still not recorded |
| Tokens the provider reports | Not read: Gemini thinking and cached tokens, Bedrock Converse cache tokens, Bedrock's x-amzn-bedrock-*-token-count headers, the streamed Responses API's final event |
Read and priced. Gemini thinking tokens are output and reasoning tokens; cached tokens are cache reads. The Bedrock headers are read by a middleware on the command and used when the body has no counts | Section 6. A span with real usage and no tokens is a defect |
| OpenAI-compatible vendor matching | The vendor's domain anywhere in the base URL | The base URL's host is the domain or a subdomain of it | Section 5 |
| Vendor embeddings and Responses | Only OpenRouter and CometAPI embeddings are traced; a Responses call through any vendor gets no span (the generic instrumentor steps aside, the vendor one does not trace it) | <vendor>.embeddings for every OpenAI-compatible vendor and groq.embeddings; Responses through a vendor is openai.responses with the vendor's gen_ai.system |
Section 7 |
| One model call, one usage record | A framework span (LangChain chat model) and the provider span beneath it both carry the tokens and cost and both record the token and cost metrics: the call is counted twice | Every SDK span owns a usage holder visible beneath it. A span that records usage marks the holders above it; a call in flight marks them too. An enclosing span so marked records no gen_ai.usage.* attributes and no token or cost metrics, and says gen_ai.usage.recorded_by = descendant. With nothing recorded beneath it (an uninstrumented provider) it records its own. The cost-enrichment processor follows the same rule. A stream is read in the context of the call that returned it, so a framework's streaming generator makes its model calls under the framework's span. Browser and React Native: there is no asynchronous context propagation there, so the holder is not seen across an await: a framework span over an instrumented provider call in a browser still reports the call twice. transformers.js does not, by construction (its pipeline span carries no tokens) |
Founder decision, SDK_ADDITIONS.md section 8 |
| Terminal-state classifier: a failed span | Status read as 'ERROR', True, or a status object whose code is named ERROR |
The same, plus an OpenTelemetry JS span status (status.code === SpanStatusCode.ERROR, a number) |
JS span statuses carry a numeric code, not a named enum |
| Media text parts and canonical messages | Written whenever media capture is on | Also need content capture on; media metadata needs media capture only | Section 5: content only with content capture |
| Media redactor | A Python dotted path, with built-in EXIF / face / PDF redactors | A function registered with setMediaRedactor; a configured redactor with none registered drops the bytes (redactor_not_allowed). The built-in redactors are not ported |
Nothing to resolve a Python path to; never upload unredacted when redaction was asked for |
| Media upload timing | Synchronous, on the request path | Asynchronous; the span waits for it and ends with the call's end time | Node I/O is asynchronous |
| HTTP media store credentials | In the URL | Sent as Basic authorization; stripped from the URI on the span, as in the reference | fetch refuses credentials in a URL |
| Instrumentation scope / tracer name | genai_otel.instrumentors.base |
the same | A backend keys on it |
OTEL_METRICS_EXEMPLAR_FILTER defaulted to always_off |
yes | not needed: the JS SDK records no exemplars |
Reference behaviours ported as they are¶
Found while reading the reference at v1.30.0. Each looks unintended; each was ported as it behaves, not as it reads, so that the two SDKs report the same thing. They are listed here so that a fix upstream is a deliberate, synchronised change (bump the pinned tag, regenerate the contract, adjust the port).
| # | Behaviour | Where |
|---|---|---|
| 1 | (moved to "Deliberate differences": custom pricing is now honoured by every instrumentor) | |
| 2 | (moved to "Deliberate differences": Anthropic spans carry gen_ai.operation.name, the response identity and finish reason. Tool-call attributes are still not recorded, as in the reference) |
anthropic_instrumentor.py:395 |
| 3 | (moved to "Deliberate differences": anthropic.messages.stream is counted in gen_ai.requests and carries server.address and the request parameters, as hand-built spans must - SDK_ADDITIONS.md section 6) |
anthropic_instrumentor.py:353 |
| 4 | A list-valued request attribute is written as the Python str() of the list: gen_ai.request.encoding_formats = "['float']" (a string), not an array |
base.py:813-816; confirmed by contract/semconv.json span_emission |
| 5 | Spans are created with the default kind (INTERNAL), not CLIENT. (The reference's separate AsyncOpenAI wrapper does use CLIENT - see 8) |
base.py:840 |
| 6 | The streaming path records the token histograms and the granular cost counters unconditionally; the buffered path only under GENAI_METRICS_PROFILE=full. A streamed span also gets no response id / model, finish reason, cache / reasoning token attributes or deprecation flag |
base.py:2413-2580 vs 1376-1740 |
| 7 | Only chat spans get gen_ai.usage.cost.pricing_source; an embedding, image or audio span whose model has no price is indistinguishable from a free one on the span. Separately, pricing_source() uses the call type as a pricing-table section name, so for embedding and image it consults the chat table |
base.py:1592-1679, cost_calculator.py:330 |
| 8 | The reference's AsyncOpenAI path is a second, divergent wrapper: span kind CLIENT, an extra gen_ai.latency attribute, no gen_ai.provider.name, no server.* or request-parameter attributes, no request or error counters - and it puts the prompt and the response text on the span even when content capture is off (verified by running the reference with enable_content_capture=False). This SDK follows the common wrapper (create_span_wrapper) for every call and does not reproduce that path; in particular it never captures content with capture off |
openai_instrumentor.py:213-297 |
| 9 | 13 pricing-table entries cannot be priced: 9 embedding models whose price is an object rather than a number, 4 image models whose quality value is a number rather than an object. The calculator raises TypeError; the instrumentor logs it and records no cost |
contract/cost_vectors.json, the vectors with error |
| 10 | A usage detail that is present but null (completion_tokens_details.reasoning_tokens: null) makes the cost calculation raise; the span then has tokens and no cost |
cost_calculator.py:495, openai_instrumentor.py:565 |
| 11 | OTEL_EXPORTER_OTLP_TIMEOUT is read as seconds; the OpenTelemetry specification defines milliseconds |
config.py:141 |
| 12 | Floating-point seconds are recorded into gen_ai.server.time_to_first_token and gen_ai.server.time_per_output_token histograms that keep the SDK default bucket boundaries (0, 5, 10, ... 10000), so almost every value lands in the first bucket. gen_ai.server.ttft / .tbt have proper boundaries |
auto_instrument.py:263, contract/metrics.json |
| 13 | (moved to "Deliberate differences": cost enrichment now uses custom pricing) | |
| 15 | (moved to "Deliberate differences": the streamed Responses API's usage is read from its final event) | |
| 14 | Under a hardened profile, an explicit enable_content_capture=False passed in code is overridden to true unless one of the two environment variables is also set |
config.py:534 |
| 16 | (moved to "Deliberate differences": one model call, one usage record - founder decision, SDK_ADDITIONS.md section 8) | langchain_instrumentor.py (_extract_usage) |
| 17 | With no session to be found, LangChain and LangGraph spans get a generated UUID as session.id, so unrelated calls each look like a session of their own |
langchain_instrumentor.py, langgraph_instrumentor.py (_resolve_session_id) |
| 18 | gen_ai.request.model on an OpenAI Agents run is the agent's name, not a model |
openai_agents_instrumentor.py (_extract_runner_attributes) |
| 19 | Agent-framework spans (OpenAI Agents, Google ADK, Bedrock Agents) carry no tokens of their own; the Bedrock InvokeAgent span ends before its completion stream is read |
openai_agents_instrumentor.py, google_adk_instrumentor.py, bedrock_agents_instrumentor.py (_extract_usage) |
| 20 | Evaluation false positives are the reference's: "She is a doctor." is gender bias (0.4, at the threshold); restricted-topic keywords match as substrings (harm in "pharmacy"); the hallucination sentence extractor stops at a decimal point ("...earned $45." is reported as a claim); a spaced 16-digit card is also reported as IN_AADHAAR |
evaluation/*_detector.py; pinned by contract/eval_vectors.json |
| 21 | Two thresholds differ between the detector's own default and what the environment gives: restricted topics 0.5 / 0.7, hallucination 0.7 / 0.6. instrument() uses the environment's, as the reference does |
evaluation/config.py, config.py |
| 22 | PII threshold and allow_list are ignored by the pattern path; 12 of 22 entity types (names, locations, IBAN, ...) have no pattern, in GDPR / HIPAA / PCI mode too |
pii_detector.py (_fallback_detection) |
| 23 | Prompt-injection patterns, check_patterns, use_ml_model and log_attempts are accepted and ignored |
prompt_injection_detector.py |
| 24 | On a response, a blocking detector only annotates: the response has already been returned to the application | evaluation/span_processor.py |
| 25 | What the Ollama poller reports as gen_ai.server.kv_cache.usage is each loaded model's VRAM as a share of the GPU's, not KV-cache usage; gen_ai.server.requests.max is the number of loaded models, not a request capacity |
ollama_server_metrics_poller.py (_collect_metrics) |
| 26 | gen_ai.server.requests.running counts client calls in flight in this process, not requests running on a server |
instrumentors/base.py (increment_requests_running) |