fix(otel): label retrieval and agent metrics correctly and emit gen_ai.provider.name - #35151
Conversation
|
|
Greptile SummaryThis PR corrects OpenTelemetry GenAI metric attribution.
Confidence Score: 5/5The PR appears safe to merge. The previously reported vector-store CRUD mislabeling is resolved because the current production lifecycle and file-management call-type spellings are covered by the new operation map, and no blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/integrations/otel/model/semconv.py | Adds complete operation mappings for the currently routed vector-store lifecycle and file-management call types, resolving the previously reported chat-series contamination. |
| litellm/integrations/otel/plumbing/metrics.py | Records resolved operation and provider attributes while preserving the deprecated raw provider label for compatibility. |
| litellm/a2a_protocol/main.py | Propagates the streaming A2A call type into callback metadata so agent metrics resolve correctly. |
| litellm/integrations/opentelemetry.py | Adds the new provider-name attribute to the supported metric attribute allowlist. |
| tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py | Covers vector-store, retrieval, agent, vendor-namespace, and fallback operation resolution. |
| tests/test_litellm/integrations/otel/test_otel_v2_metrics.py | Verifies emitted provider and operation attributes through the metric recorder. |
| tests/test_litellm/a2a_protocol/test_main.py | Verifies that streaming A2A logging metadata carries the required call type. |
Reviews (5): Last reviewed commit: "fix(otel): give the streaming A2A path a..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Fixed in Mapping only the search left the store lifecycle ( Worth flagging for anyone auditing the family: these live as router The convention names no operation for vector-store management, so those two are vendor values under the
Tests: one per call-type group pinning its operation name, one asserting every vendor value stays |
|
The fix is complete and correct. The mapping is exhaustive — all 10 lifecycle verbs × 2 prefixes for management, 6 file verbs × 2 prefixes for file management, plus search/RAG/agent — and the live capture confirms zero A few observations on the follow-up commit:
The P1 concern is resolved. The mapping is complete, tested, and live-verified. |
|
The The sole failure is Evidence it is base:
Tracked as LIT-4975. Leaving it red rather than pushing a no-op to re-roll, since a re-run will fail the same way until the base is fixed. |
|
Root cause found for the
|
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bb885fa. Configure here.
| "query": GenAIOperation.RETRIEVAL, | ||
| "aquery": GenAIOperation.RETRIEVAL, | ||
| "send_message": GenAIOperation.INVOKE_AGENT, | ||
| "asend_message": GenAIOperation.INVOKE_AGENT, |
There was a problem hiding this comment.
Streaming agent still labeled chat
Medium Severity
send_message / asend_message map to invoke_agent, but the streaming A2A path records call_type as asend_message_streaming and still falls through to chat. That path dispatches the same success handlers, so streaming agent latency and cost keep polluting chat series.
Reviewed by Cursor Bugbot for commit bb885fa. Configure here.
There was a problem hiding this comment.
Legit, and worse than it reads. Fixed in 61c6742e3c.
The symptom is exactly right: a streamed A2A turn records as chat. The mechanism is only half right, and the half that is missing is why the one-line map entry Bugbot implies is not enough. I added "asend_message_streaming" to the map first, reran the live rig, and every streaming datapoint still came out chat.
_build_streaming_logging_obj (litellm/a2a_protocol/main.py) builds its Logging by hand and never calls update_environment_variables, which is the only place call_type is written into model_call_details (litellm/litellm_core_utils/litellm_logging.py:553); Logging.__init__ seeds that dict at line 398 without it. So the recorder was reading kwargs.get("call_type") is None and falling back to chat on the not call_type branch. The call type never reached the map at all, which also means it never reached any other callback either. The fix stamps it, and maps the streaming spelling.
Live proof
One proxy on the real streaming path, no mocks: a real A2A agent (built on the a2a-sdk's own server routes, streaming a task plus three status updates plus a completed status), registered in agent_list, driven over HTTP through POST /a2a/{agent} with method: message/stream. OTel v2 with the console metric exporter, no database. Each run sends 3 streamed turns and 1 non-streamed turn, so the non-streamed one is the control that was already correct.
python agent_server.py 8959 # real A2A agent, /.well-known/agent-card.json + JSON-RPC
export OTEL_ENDPOINT="" OTEL_EXPORTER=console LITELLM_OTEL_V2=1 \
LITELLM_OTEL_INTEGRATION_ENABLE_METRICS=1
python litellm/proxy/proxy_cli.py --config config.yaml --port 4959 > run.log 2>&1 &
for i in 1 2 3; do
curl -sS -N -X POST http://127.0.0.1:4959/a2a/streaming-proof-agent \
-H 'Content-Type: application/json' -H 'Authorization: Bearer sk-1234' \
-d "{\"jsonrpc\":\"2.0\",\"id\":\"$i\",\"method\":\"message/stream\",\"params\":{\"message\":{\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"streamed turn $i\"}],\"messageId\":\"m$i\"}}}"
done
curl -sS -X POST http://127.0.0.1:4959/a2a/streaming-proof-agent \
-H 'Content-Type: application/json' -H 'Authorization: Bearer sk-1234' \
-d '{"jsonrpc":"2.0","id":"4","method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"non-streamed turn"}],"messageId":"m4"}}}'All four calls HTTP 200 at every SHA, and the agent streamed its chunks back through the proxy each time. Datapoint attribute sets are counted by brace-matching each "attributes": {...} object out of the exporter output and json.loads-ing it, rather than by regex, since a regex stops at the first } inside a JSON-string label value and silently merges distinct series.
Before, at bb885fa0f7:
x18 operation='chat' provider=None model='a2a_agent/otel-streaming-proof-agent'
x6 operation='invoke_agent' provider='a2a_agent' model='a2a_agent/otel-streaming-proof-agent'
With only the map entry added, which is the fix the finding implies:
x12 operation='chat' provider=None model='a2a_agent/otel-streaming-proof-agent'
x4 operation='invoke_agent' provider='a2a_agent' model='a2a_agent/otel-streaming-proof-agent'
Unchanged, because the call type was never arriving.
After, at 61c6742e3c:
x12 operation='invoke_agent' provider=None model='a2a_agent/otel-streaming-proof-agent'
x4 operation='invoke_agent' provider='a2a_agent' model='a2a_agent/otel-streaming-proof-agent'
Not one datapoint reads chat. The 3:1 split is the 3 streamed turns against the 1 non-streamed control.
Tests
Three, each mutation-checked by reverting the production line and rerunning:
test_main.py::test_streaming_logging_obj_carries_call_type_into_model_call_detailspins the root cause, that the hand-built logging object carries its call type intomodel_call_details. This is the one that fails on the pre-fix tree with aKeyError, i.e. it catches the real defect rather than the map.test_otel_v2_sources_of_truth.py::test_agent_message_is_an_invoke_agent_operationgains the streaming spelling, and a newtest_every_call_type_the_a2a_package_stamps_is_an_agent_operationscanslitellm/a2a_protocolforcall_type="..."literals and asserts each one resolves toinvoke_agent, so a future spelling added there fails here instead of quietly landing in the chat series.test_otel_v2_metrics.py::test_agent_message_is_not_labelled_as_chatis parametrized over both spellings, driving the real success hook into an in-memory reader.
Reverting the map entry alone fails 3, reverting the stamping alone fails 1; both restored, tests/test_litellm/a2a_protocol plus tests/test_litellm/integrations/otel are green at 394 passed.
One thing deliberately left alone
The streamed datapoints carry no gen_ai.provider.name, while the non-streamed ones carry a2a_agent. That is this PR's own rule working as intended rather than a second bug in this fix: the recorder reads the provider from litellm_params["custom_llm_provider"], and on the non-bridge streaming path those params legitimately carry no provider, so the label is omitted instead of invented. Making the two paths agree means changing what litellm_params holds for A2A streaming, which the cost calculator also reads, so it belongs in its own change rather than riding along here.
|
@greptileai please review the current head New commit addresses the Cursor Bugbot finding on the A2A streaming path. The finding was real but its stated mechanism was incomplete: adding |
…i.provider.name The GenAI metric attribute builder mapped only chat, text completion, embedding, responses and MCP tool calls to an operation name, so vector-store searches and A2A agent sends fell through to the "chat" default. Their duration and cost then landed in the same series a Grafana GenAI dashboard reads chat latency off, with no way to tell them apart. Both now map to the operation names the convention defines for them, retrieval and invoke_agent, and an unmapped call type says so at debug instead of silently becoming chat. The provider label used gen_ai.system, which the convention deprecated in favor of gen_ai.provider.name; the dashboards built on that vocabulary find nothing under the old key. Metrics now carry gen_ai.provider.name with the semconv provider value (bedrock -> aws.bedrock) via the resolve_provider helper the span path already uses, and keep dual-emitting gen_ai.system with its raw value so a dashboard already querying it keeps matching. A request litellm cannot attribute to a provider gets no provider label at all rather than a placeholder "Unknown" that minted a permanent series nobody can act on. Resolves LIT-4954 Resolves LIT-4959
…efault Mapping only the search left the store lifecycle (create, retrieve, list, update, delete) and the file operations (create, list, retrieve, content, update, delete) falling through to chat, so vector-store admin traffic kept polluting the same series a dashboard reads chat latency off. A live run confirmed it: all 20 metric datapoints from a create, retrieve, list, file-list and delete came out labelled chat. The convention names no operation for vector-store management, so these take vendor values under the litellm. prefix, litellm.vector_store_management and litellm.vector_store_file_management, one per REST resource. Its note on gen_ai.operation.name directs instrumentation to use a system-specific name when no predefined value applies, which is the same allowance resolve_provider already relies on for unmapped providers. Excluding them from the GenAI metrics altogether was the alternative; it deletes series an operator may be watching today and is far harder to reverse than a rename, so it stays available as a follow-up rather than being decided here. Mapping them onto the semconv memory store family was rejected: litellm vector stores hold documents, not agent memory records, and borrowing those names would put document admin calls into whatever charts agent-memory operations, which is the bug this fixes. /rag/query reaches the same recorder and is the same operation as a vector-store search, so query and aquery map to retrieval too; leaving them would have left the defect alive on a second retrieval surface. /rag/ingest is a write with no semconv equivalent and no retrieval or agent confusion, so it is left for the RAG owners to name. Resolves LIT-4954
61c6742 to
b262547
Compare
|
@greptileai please review the current head Rebased onto current staging, which had moved 48 commits and turned the PR One of them is a real integration change worth a look rather than a mechanical resolution: #34828 added Commit SHAs all changed. The proof captures in the body were taken pre-rebase and are quoted at their original SHAs with a mapping note, rather than relabelled with commits the numbers did not come from. |
…voke_agent The streaming logging object is built by hand and never runs through update_environment_variables, the only place call_type reaches model_call_details, so every streamed agent turn arrived at the recorder with no call type and fell back to chat. Stamp it, and map the streaming spelling alongside the non-streaming ones.
b262547 to
72a176d
Compare
|
@greptileai please review the current head Correcting my previous comment: it named No content changed between those two SHAs. The review targets are still the two commits described above: the A2A streaming call-type fix, and the |


TLDR
Problem this solves:
operation.name=chatgen_ai.systemkey, invisible to GenAI dashboardsUnknownprovider seriesHow it solves it:
retrieval, agent sends toinvoke_agentgen_ai.provider.namevia the existingresolve_providerhelperRelevant issues
Linear ticket
Resolves LIT-4954
Resolves LIT-4959
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
The branch was rebased onto current staging after these captures, so the SHAs quoted below are the pre-rebase ones the measurements were actually taken at. They map to the current commits in order:
8818ac2056->b8266b2810,bb885fa0f7->0b26c2c595,61c6742e3c->72a176debe, on base551e5d097c->4eecf7a050. Nothing was re-measured, so the old SHAs are quoted as-is rather than relabelled with commits the numbers did not come fromOne live proxy, OTel v2 with the console metric exporter, a real OpenAI chat completion and a real OpenAI vector-store search (a vector store built from an uploaded file; both calls hit api.openai.com and cost real money). No database, no mocks
Both calls returned 200 at both SHAs; the chat completion answered
otel attribute prooffor 14 prompt / 6 completion tokens and the search returned its indexed chunk at score 0.93Before, at
551e5d097c:Every datapoint says
chat, the vector-store ones included, andgen_ai.provider.nameappears nowhere. The vector-store datapoint in full:After, at
8818ac2056:The chat datapoint carries both spellings and the search is now its own operation:
The retrieval datapoint carries no provider label at all, where before it carried
gen_ai.system: null. The counts differ between runs only because the console exporter re-exports the cumulative datapoints every 5 seconds and the before run stayed up longer; the distinct attribute sets are what mattersVector-store management, from the Greptile follow-up
Same rig, driving the management surface instead: create, retrieve, list, file-list and delete, all real OpenAI calls through the proxy, all HTTP 200
Before, at
8818ac2056(the first commit on this branch, which mapped only the search):After, at
bb885fa0f7:Not one datapoint reads
chatany more. This also settles that the concern was live rather than theoretical: these management calls are@client-decorated and routed, so they reach the same success hook a completion doesStreaming A2A, from the Bugbot follow-up
Cursor Bugbot flagged that a streamed A2A turn still records as
chat. It does, but not for the reason given, and the difference matters:_build_streaming_logging_objinlitellm/a2a_protocol/main.pybuilds itsLoggingby hand and never callsupdate_environment_variables, which is the only placecall_typeis written intomodel_call_details(litellm/litellm_core_utils/litellm_logging.py:553;Logging.__init__seeds that dict at line 398 without it). The recorder was therefore seeingcall_typeasNoneand taking thenot call_typebranch. Adding the map entry alone changes nothing, which the middle capture below showsA different rig from the vector-store one above: a real A2A agent, built on the a2a-sdk's own server routes, streaming a task plus three status updates plus a completed status, registered in
agent_listand driven over HTTP through the proxy. Three streamed turns and one non-streamed turn per run, the non-streamed one being the control that was already labelled correctly. No database, no mocksAll four calls HTTP 200 at every SHA, and the agent streamed its chunks back through the proxy each time. Attribute sets counted by brace-matching each
"attributes": {...}object out of the exporter output andjson.loads-ing it, since a regex stops at the first}inside a JSON-string label value and silently merges distinct seriesBefore, at
bb885fa0f7:With only the map entry added, which is the fix the finding implies:
After, at
61c6742e3c:Not one datapoint reads
chat. The 3:1 split is the three streamed turns against the one non-streamed controlThe streamed datapoints carry no
gen_ai.provider.namewhile the non-streamed ones carrya2a_agent, which is this PR's own omit-rather-than-invent rule working as intended: the recorder reads the provider fromlitellm_params["custom_llm_provider"], and on the non-bridge streaming path those params legitimately carry none. Making the two paths agree means changing whatlitellm_paramsholds for A2A streaming, which the cost calculator also reads, so it belongs in its own changeType
🐛 Bug Fix
Changes
resolve_operationinlitellm/integrations/otel/model/semconv.pymapped completion, text completion, embedding, responses andcall_mcp_tool, and defaulted everything else tochat. Vector-store searches (vector_store_search/avector_store_search) and A2A agent sends (send_message/asend_message) therefore recorded their duration and cost into the chat series, so chat latency read off a GenAI dashboard silently included retrieval and agent time. Both now map to the values the GenAI convention defines for them:retrieval, whose registry entry names the OpenAI vector-store search API specifically, andinvoke_agent. An unmapped call type still falls back tochatso a series is never unlabelled, but it now logs the call type at debug, because a silent default is how this landed in the first placeThe rest of the vector-store family goes with it, which is the Greptile follow-up. The store lifecycle (
vector_store_create/_retrieve/_list/_update/_delete) and the six file operations (_file_create/_file_list/_file_retrieve/_file_content/_file_update/_file_delete), each in both the sync anda-prefixed spelling, were all still landing onchat. Note these live as routerfactory_functioncall types rather thanCallTypesmembers, so the enum understates the family; the router'scall_type=strings are the authoritative list./rag/query(query/aquery) goes toretrievaltoo, since it reaches the same recorder and is the same operation as a vector-store search, and mapping one retrieval surface but not the other would leave the defect half-fixed./rag/ingestis a write with no semconv equivalent and no retrieval or agent confusion, so naming it belongs with the RAG owners rather than hereThe convention names no operation for vector-store management, so those take vendor values,
litellm.vector_store_managementandlitellm.vector_store_file_management, one per REST resource, under thelitellm.prefix so a value the convention adds later can never collide. The convention's own note ongen_ai.operation.namedirects instrumentation to use a system-specific name when no predefined value applies, which is the same allowanceresolve_provideralready relies on for unmapped providers, so this is the sanctioned move rather than a workaround. Two alternatives were considered and rejected. Excluding management calls from the GenAI metrics altogether is arguably cleaner semantically, since creating a vector store is not a GenAI client operation, but it deletes series an operator may be watching today and re-adding deleted telemetry is much harder than renaming a label, so it stays available as a follow-up instead of being decided here. Mapping them onto the semconv memory-store family (create_memory_store,delete_memory_store, and friends) was rejected outright: litellm vector stores hold documents, not an agent's memory records, and borrowing those names would push document-admin calls into whatever charts agent-memory operations, which is the same defect this PR exists to fixFor scope, the wider unmapped set (video, containers, files, skills, interactions, image edit, moderation, OCR) is out of this PR. Those are not retrieval or agent operations, so they are not what the ticket describes, and several need a product decision about whether they belong in
gen_ai.client.*at all. The debug log added here is the mechanism that surfaces them rather than leaving the next person to discover it from a chartThe provider label was
gen_ai.system, which the convention has moved to its deprecated registry in favor ofgen_ai.provider.name. The v2 span path already resolves the semconv value throughresolve_provider(bedrock becomesaws.bedrock); metrics never called it. They do now, andgen_ai.provider.namejoins the metric-attribute allowlist so an operator can include or exclude it like any other labelThe sharpest call here:
gen_ai.systemis dual-emitted with the raw litellm provider string it has always carried, not the mapped value. A query forgen_ai.system="bedrock"is exactly what the dual emission exists to keep working, so writingaws.bedrockinto the deprecated key would break those queries while looking like compatibility. The new key gets the resolved semconv value; the old key's series stays byte-identical to what it emits todayKeeping the old key at all follows the precedent
litellm/integrations/prometheus.pysets for renamed metrics. The label has shipped long enough that someone's dashboard is filtering on it, and a rename with no overlap breaks that dashboard silently at upgrade time rather than loudly. It should come out a release from nowThe
"Unknown"default is gone. A request litellm cannot attribute to a provider now carries no provider label, rather than a placeholder that mints a real, permanently-cardinal series aggregating every unattributable request into one bucket no operator can act on. The live capture shows this was not hypothetical: the vector-store search reached the recorder withcustom_llm_providerset toNone, so it was already emittinggen_ai.system: nullI left the v1
litellm/integrations/opentelemetry.pymetric path ongen_ai.system(the only edit there is the one-line allowlist addition). Itsgen_ai.operation.nameis itself gated behind thegen_ai_latest_experimentalopt-in and hardcodeschatotherwise, so renaming just its provider key would produce a half-migrated attribute set on an integration slated for deletion, while leaving it untouched keeps v1's output byte-identical and compatible with the v2 dual emissionThe streamed A2A path needed a second change, not just a map entry. Its logging object is built by hand in
_build_streaming_logging_objrather than throughupdate_environment_variables, socall_typenever reachedmodel_call_detailsand every callback, not only the metric recorder, saw a streamed agent turn with no call type at all. It is stamped there now alongside themodelandcustom_llm_providerthe same function already sets, andasend_message_streamingjoins the map. There is no sync spelling to add: A2A streaming is async-only, and this is the onlycall_typeliteral anywhere underlitellm/a2a_protocolThe rebase brought in the metric-attribute ceiling that landed on staging as #34828, and
gen_ai.provider.namehad to joinMETRIC_ATTRIBUTE_CEILINGfor the new label to survive it. That is the correct side of that cap rather than a widening: the value is a fixed enum fromresolve_provider, bounded exactly like thegen_ai.systementry it sits next to, and it is caller-independent. Without the entry the ceiling silently stripped the attribute this PR exists to add, which is what the three provider tests caught during the rebaseThe sibling failure-path PR (LIT-4955) has since merged as #35152, and its
plumbing/metrics.pychanges are folded in by the rebaseTests extend three mapped files:
tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.pyfor the call-type mapping,test_otel_v2_metrics.pyfor the recorded attributes, andtests/test_litellm/a2a_protocol/test_main.pyfor the streaming logging object's call type, which drive the real success hook into an in-memory metric reader. There is a test per call-type group pinning its operation name, one asserting every vendor value stays under thelitellm.prefix, and the unmapped-fallback test uses a synthetic call type that can never accidentally become mappedThe streaming commit adds a test that pins the root cause rather than the map: reverting the stamping alone fails
test_streaming_logging_obj_carries_call_type_into_model_call_detailswith aKeyError, and reverting the map entry alone fails the three mapping and recorder cases. A newtest_every_call_type_the_a2a_package_stamps_is_an_agent_operationscanslitellm/a2a_protocolforcall_type="..."literals and asserts each resolves toinvoke_agent, so a future spelling added there fails there instead of quietly landing in the chat series.tests/test_litellm/a2a_protocolplustests/test_litellm/integrations/otelare green at 394 passedAll three commits were mutation-checked by reverting the production change and keeping the tests. For the first: 11 failed, 48 passed, then 59 passed restored. For the follow-up: 29 failed, 59 passed, then 88 passed restored, so every one of the 29 new cases fails without the mapping. The two full mapped files plus the v1
test_opentelemetry.pyare green at 577 passedQA runbook
Final Attestation
Note
Medium Risk
Changes observability labels and time-series cardinality for OTel v2 metrics; behavior is well-tested but operators’ dashboards and alerts keyed on old operation or provider attributes may need review.
Overview
OTel v2 GenAI metrics now use semconv-aligned labels so dashboards and cost/latency series are not skewed by mis-tagged operations.
Operation names: Vector-store search and RAG
querymap togen_ai.operation.name=retrieval; A2A sends map toinvoke_agent. Vector-store and file admin call types get vendor valueslitellm.vector_store_managementandlitellm.vector_store_file_managementinstead of defaulting tochat. Unmappedcall_typevalues still fall back tochatbut emit a debug log so new surfaces are visible.Provider labels: Metrics emit
gen_ai.provider.nameviaresolve_provider(e.g. bedrock →aws.bedrock), withgen_ai.systemdual-emitted using the raw litellm provider string for backward compatibility. Calls with no provider omit both labels (no more permanent"Unknown"series).gen_ai.provider.nameis on the metric-attribute allowlist.Scope: v1
opentelemetry.pyonly gains the allowlist entry; v2GenAIMetricRecorder._common_attributesimplements the behavior. Tests cover mappings, dual emission, absent provider, and filterability.Reviewed by Cursor Bugbot for commit bb885fa. Bugbot is set up for automated code reviews on this repo. Configure here.