mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
* test: add logging e2e coverage (s3_v2, gcs_bucket, team langfuse callback, datadog failure) Five new live e2e scenarios raising Logging & Guardrails registry coverage: s3_v2 success and failure objects read back from the real S3 bucket, gcs_bucket success record read back through the GCS JSON API (with nextPageToken pagination and per-request bearer minting), team-scoped Langfuse callback delivery with non-team isolation, and DataDog failure event delivery queried by indexed model_group. datadog_reader gains query-based variants of the marker search; the langfuse cell is a new registry row. Bucket readers settle past a full flush interval so a late duplicate cannot hide from the exactly-one assertions * test: cover clock-skew day prefix in gcs read-back and retry team callback propagation * test: key the s3 failure read-back on the provider error, not payload absence * chore: rerun ci * chore: rerun ci after config sync * chore: rerun ci with pr lane env * chore: rerun ci * chore: rerun ci * chore: rerun ci * chore: rerun ci * chore: rerun ci * test: add guardrail e2e coverage (presidio masking, bedrock post and during call, moderation on messages) (#38553) * test: add guardrail e2e coverage (presidio masking, bedrock post/during, moderation on messages) * test: require the phone placeholder positively in the presidio masking predicate * test: count only the 400 verdict body as a bedrock post_call block * test(e2e): exempt the guardrail config echo from the post_call leak assertion * test(e2e): pin the fail-closed contract for an unknown guardrail name (skipped, product gap) * test(e2e): tolerate the readiness 503 from a transient db blip in the callback-config probes
786 lines
37 KiB
Python
786 lines
37 KiB
Python
"""Live e2e: OTEL trace completeness on the admin-owned destination (LIT-3787).
|
||
|
||
Covers logging.otel.success.exports_metric: a successful non-streaming call must
|
||
land at the OTEL destination as ONE connected trace - a single root SERVER span
|
||
with the auth phase, db lookups, and cost write under it, and the gen-AI CLIENT
|
||
span parented into the same tree. The regression this pins: the proxy publishing
|
||
the global TracerProvider before callbacks init made server spans export through
|
||
a different provider than the preset's gen-AI spans, so the destination received
|
||
the gen-AI span alone, dangling (fixed in #30590; verified failing at its parent
|
||
commit 1bd603d1ac).
|
||
|
||
Both halves of the contract are asserted: the recorded state (the proxy reports
|
||
the OTEL v2 logger active via /health/readiness/details) and the enforced
|
||
behavior (the complete span tree at the destination, read back through the
|
||
destination's own query API - never proxy-side "export succeeded" logs).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
import pytest
|
||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||
|
||
from e2e_config import CHEAP_ANTHROPIC_MODEL, CHEAP_OPENAI_MODEL, unique_marker
|
||
from lifecycle import ResourceManager
|
||
from logging_client import INVALID_UPSTREAM_API_KEY, LoggingClient, first_ok, readiness_details_body
|
||
from models import LiteLLMParamsBody
|
||
from otel_client import JaegerSpan, JaegerTrace, OtelReader
|
||
|
||
pytestmark = pytest.mark.e2e
|
||
|
||
MODEL = CHEAP_ANTHROPIC_MODEL
|
||
COST_SPAN = "batch_write_to_db _PROXY_track_cost_callback"
|
||
DB_SPAN_PREFIX = "postgres "
|
||
#: The active OTEL v2 logger's name in /health/readiness/details success_callbacks.
|
||
OTEL_V2_LOGGER_NAME = "OpenTelemetryV2"
|
||
|
||
|
||
class _ReadinessDetails(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
success_callbacks: list[str] = []
|
||
|
||
|
||
def _assert_otel_destination_configured(client: LoggingClient) -> None:
|
||
"""Recorded state: the proxy reports the OTEL v2 logger among its active
|
||
callbacks, so a missing/failed destination config fails here, before any
|
||
traffic-based assertion can time out confusingly."""
|
||
details = _ReadinessDetails.model_validate_json(readiness_details_body(client))
|
||
assert OTEL_V2_LOGGER_NAME in details.success_callbacks, (
|
||
f"the proxy must report the {OTEL_V2_LOGGER_NAME} callback active "
|
||
f"(LITELLM_OTEL_V2 + arize_phoenix preset in the compose config); got: {details.success_callbacks}"
|
||
)
|
||
|
||
|
||
def _parent_ids(span_id: str, trace: JaegerTrace) -> list[str]:
|
||
span = next(s for s in trace.spans if s.span_id == span_id)
|
||
return [ref.span_id for ref in span.references if ref.ref_type == "CHILD_OF"]
|
||
|
||
|
||
def _chain_reaches(span_id: str, root_id: str, trace: JaegerTrace) -> bool:
|
||
"""Walk parent references (within the trace) from span_id up to root_id."""
|
||
seen: set[str] = set()
|
||
in_trace = {s.span_id for s in trace.spans}
|
||
current = span_id
|
||
while current not in seen:
|
||
if current == root_id:
|
||
return True
|
||
seen.add(current)
|
||
parents = [p for p in _parent_ids(current, trace) if p in in_trace]
|
||
if not parents:
|
||
return False
|
||
current = parents[0]
|
||
return False
|
||
|
||
|
||
def _assert_complete_trace(
|
||
hits: list[JaegerTrace], *, route: str, genai_span: str, require_cost_span: bool = True
|
||
) -> None:
|
||
"""The enforced behavior: the destination holds exactly one trace for the
|
||
call, rooted at the SERVER span, with auth/db/cost children and the gen-AI
|
||
span all connected into that one tree - no dangling parent references."""
|
||
assert hits, (
|
||
"no trace for this call arrived at the destination within the deadline "
|
||
"(nothing tagged with its call id was found)"
|
||
)
|
||
assert len(hits) == 1, (
|
||
f"expected exactly ONE trace for the call, got {len(hits)}: "
|
||
f"{[(t.trace_id, t.span_names()) for t in hits]} - more than one trace for "
|
||
"one call is the split-trace bug (gen-AI span exported away from its root)"
|
||
)
|
||
trace = hits[0]
|
||
names = trace.span_names()
|
||
in_trace = {span.span_id for span in trace.spans}
|
||
|
||
dangling = [
|
||
span.operation_name
|
||
for span in trace.spans
|
||
if span.references and not any(ref.span_id in in_trace for ref in span.references)
|
||
]
|
||
assert not dangling, (
|
||
f"span(s) {dangling} reference a parent that never reached the destination "
|
||
f"(orphaned trace); spans present: {names}"
|
||
)
|
||
|
||
roots = [span for span in trace.spans if not span.references]
|
||
assert len(roots) == 1, f"expected exactly one root span, got {[s.operation_name for s in roots]}; spans: {names}"
|
||
root = roots[0]
|
||
assert root.operation_name == f"POST {route}", (
|
||
f"the root must be the SERVER span 'POST {route}', got {root.operation_name!r}"
|
||
)
|
||
assert root.kind == "server", f"the root span must have kind=server, got {root.kind!r}"
|
||
|
||
assert f"auth {route}" in names, f"auth phase span 'auth {route}' missing; spans: {names}"
|
||
assert any(name.startswith(DB_SPAN_PREFIX) for name in names), (
|
||
f"no db ('{DB_SPAN_PREFIX}*') span in the trace; spans: {names}"
|
||
)
|
||
if require_cost_span:
|
||
assert COST_SPAN in names, f"cost write span {COST_SPAN!r} missing; spans: {names}"
|
||
|
||
genai = next((span for span in trace.spans if span.operation_name == genai_span), None)
|
||
assert genai is not None, f"gen-AI span {genai_span!r} missing; spans: {names}"
|
||
assert genai.kind == "client", f"gen-AI span must have kind=client, got {genai.kind!r}"
|
||
assert _chain_reaches(genai.span_id, root.span_id, trace), (
|
||
f"gen-AI span {genai_span!r} is in the trace but its parent chain does not "
|
||
f"reach the root SERVER span; spans: {names}"
|
||
)
|
||
|
||
|
||
def _settled_names(*, route: str, genai_span: str, require_cost_span: bool = True) -> set[str]:
|
||
names = {f"POST {route}", f"auth {route}", genai_span}
|
||
return (names | {COST_SPAN}) if require_cost_span else names
|
||
|
||
|
||
def _tag(span: JaegerSpan, key: str) -> str | int | float | bool | None:
|
||
for tag in span.tags:
|
||
if tag.key == key:
|
||
return tag.value
|
||
return None
|
||
|
||
|
||
#: The v2 gen-AI span attribute recording time-to-first-token for streamed
|
||
#: calls: seconds from the upstream request being issued to the first streamed
|
||
#: chunk (stamped only for streaming; added in #32236).
|
||
TTFT_TAG = "gen_ai.response.time_to_first_chunk"
|
||
|
||
#: Jaeger's rendering of a span whose OTEL status is ERROR.
|
||
ERROR_STATUS_TAG = "otel.status_code"
|
||
|
||
|
||
def served_genai_spans(trace: JaegerTrace, genai_span: str) -> list[JaegerSpan]:
|
||
"""The gen-AI spans for attempts that actually served the request.
|
||
|
||
The proxy opens one gen-AI span per upstream attempt, so a call the router
|
||
retried carries an error span for every failed attempt beside the one that
|
||
answered. Only the served attempt streams chunks, so only it records TTFT
|
||
or a streaming flag; asserting over the raw span list makes every one of
|
||
these tests fail whenever the upstream 429s, 529s, or hands back a stale
|
||
credential on the first try."""
|
||
return [
|
||
span for span in trace.spans if span.operation_name == genai_span and _tag(span, ERROR_STATUS_TAG) != "ERROR"
|
||
]
|
||
|
||
|
||
def one_served_genai_span(trace: JaegerTrace, genai_span: str) -> JaegerSpan:
|
||
served = served_genai_spans(trace, genai_span)
|
||
assert len(served) == 1, (
|
||
f"a streamed call must produce exactly ONE served gen-AI span, got {len(served)}; spans: {trace.span_names()}"
|
||
)
|
||
return served[0]
|
||
|
||
|
||
def _assert_real_ttft(hits: list[JaegerTrace], *, genai_span: str) -> None:
|
||
"""The enforced behavior: the gen-AI span for the attempt that served the
|
||
stream records a TTFT that is a real measurement - present, numeric,
|
||
positive, and strictly less than that span's own total duration. A TTFT of
|
||
zero, or one at/above the span duration, is a clock artifact rather than
|
||
first-token latency."""
|
||
assert hits, (
|
||
"no trace for this call arrived at the destination within the deadline "
|
||
"(nothing tagged with its call id was found)"
|
||
)
|
||
assert len(hits) == 1, (
|
||
f"expected exactly ONE trace for the call, got {len(hits)}: {[(t.trace_id, t.span_names()) for t in hits]}"
|
||
)
|
||
trace = hits[0]
|
||
span = one_served_genai_span(trace, genai_span)
|
||
|
||
value = _tag(span, TTFT_TAG)
|
||
assert value is not None, (
|
||
f"the gen-AI span must record {TTFT_TAG} for a streamed call; "
|
||
f"tags present: {sorted(tag.key for tag in span.tags)}"
|
||
)
|
||
assert isinstance(value, (int, float)) and not isinstance(value, bool), (
|
||
f"{TTFT_TAG} must be numeric seconds, got {value!r}"
|
||
)
|
||
ttft_seconds = float(value)
|
||
duration_seconds = span.duration / 1_000_000
|
||
|
||
assert ttft_seconds > 0, (
|
||
f"TTFT must be a real positive latency, got {ttft_seconds!r} - zero or negative "
|
||
"means it was computed from missing/backfilled timestamps, not the first chunk"
|
||
)
|
||
assert ttft_seconds < duration_seconds, (
|
||
f"TTFT ({ttft_seconds:.6f}s) must be strictly less than the gen-AI span's total "
|
||
f"duration ({duration_seconds:.6f}s) - the first chunk arrives before the stream "
|
||
"finishes, so a TTFT at or above the span duration is not a first-token measurement"
|
||
)
|
||
|
||
|
||
#: The attribute contract a failed call's gen-AI span must carry (LIT-4179), as
|
||
#: one reviewable payload. Exact-match values; error.message is additionally
|
||
#: proven untruncated by _assert_error_span_contract, which parses the provider
|
||
#: error JSON embedded in it - a truncated message stops parsing.
|
||
EXPECTED_ERROR_SPAN_ATTRIBUTES: dict[str, str] = {
|
||
"error": "True",
|
||
"error.type": "AuthenticationError",
|
||
"otel.status_code": "ERROR",
|
||
"litellm.provider.error.code": "401",
|
||
"litellm.provider.error.llm_provider": "anthropic",
|
||
}
|
||
|
||
|
||
class _ProviderErrorDetail(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
type: str
|
||
message: str
|
||
|
||
|
||
class _ProviderError(BaseModel):
|
||
"""The provider error JSON embedded in error.message; validating it proves
|
||
the attribute survived untruncated (a cut-off message stops parsing)."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
error: _ProviderErrorDetail
|
||
|
||
|
||
def _assert_error_span_contract(span: JaegerSpan) -> None:
|
||
"""The failed call's gen-AI span carries the LIT-4179 error contract: the
|
||
exact attributes in EXPECTED_ERROR_SPAN_ATTRIBUTES, plus an untruncated
|
||
error.message whose embedded provider error JSON still parses and whose
|
||
text also rides the span status description."""
|
||
for key, expected in EXPECTED_ERROR_SPAN_ATTRIBUTES.items():
|
||
actual = _tag(span, key)
|
||
assert str(actual) == expected, f"error span attribute {key!r} must be {expected!r}, got {actual!r}"
|
||
|
||
message = _tag(span, "error.message")
|
||
assert isinstance(message, str) and message, "error span must carry a non-empty error.message"
|
||
assert "AnthropicException" in message, (
|
||
f"error.message must carry the upstream provider exception, got: {message[:200]}"
|
||
)
|
||
start, end = message.find("{"), message.rfind("}")
|
||
assert start != -1 and end > start, (
|
||
f"error.message carries no parseable provider error JSON (truncated?): {message[:200]}"
|
||
)
|
||
try:
|
||
provider_error = _ProviderError.model_validate_json(message[start : end + 1])
|
||
except ValidationError:
|
||
pytest.fail(f"the embedded provider error JSON does not parse (truncated?): {message[:300]}")
|
||
assert provider_error.error.type == "authentication_error", (
|
||
f"the span must carry anthropic's own auth error object rather than a litellm "
|
||
f"stand-in; parsed: {provider_error}"
|
||
)
|
||
assert provider_error.error.message.strip(), (
|
||
f"the embedded provider error must carry a non-empty message; parsed: {provider_error}"
|
||
)
|
||
assert _tag(span, "otel.status_description") == message, (
|
||
"the span status description must carry the same untruncated message as error.message"
|
||
)
|
||
stack = _tag(span, "litellm.provider.error.stack_trace")
|
||
assert isinstance(stack, str) and stack, "the error span must carry a non-empty litellm.provider.error.stack_trace"
|
||
|
||
|
||
class TestOtelTraceCompleteness:
|
||
@pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["chat_completions"])
|
||
def test_chat_completions_exports_complete_trace(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""This test verifies that a successful non-streaming
|
||
/chat/completions request produces one complete OTEL trace.
|
||
|
||
The trace should have a single server root span for the incoming request, with
|
||
the authentication, database, and cost-recording work beneath it. The span for
|
||
the actual model call must also belong to that same trace, rather than being
|
||
exported separately with a missing parent.
|
||
|
||
This matters because a split trace is easy to miss: all of the spans may still
|
||
arrive, but the model call appears without the surrounding request context.
|
||
That makes it difficult to understand where time was spent, connect the model
|
||
cost to the original request, or investigate a slow or failed call.
|
||
|
||
/chat/completions is the main OpenAI-compatible route used by most customers,
|
||
so it is important that trace parenting works correctly on this path.
|
||
"""
|
||
route = "/chat/completions"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-trace-chat-{unique_marker()}", models=[MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(client, lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16))
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=f"chat {MODEL}"),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}")
|
||
|
||
@pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["messages"])
|
||
def test_messages_exports_complete_trace(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""This test verifies that one successful non-streaming /v1/messages request
|
||
produces exactly one complete OTEL trace.
|
||
|
||
The trace must have a single root span named "POST /v1/messages". The
|
||
authentication, database, cost-writing, and model-call spans must all belong to
|
||
the same trace and have valid parent relationships leading back to that root.
|
||
|
||
The model-call span is expected to be named "chat <model>". The test fails if
|
||
the request is split across multiple traces, if any span references a missing
|
||
parent, or if the model-call span cannot be connected back to the root."""
|
||
route = "/v1/messages"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-trace-messages-{unique_marker()}", models=[MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client, lambda: client.messages_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16)
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=f"chat {MODEL}"),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}")
|
||
|
||
@pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["responses"])
|
||
def test_responses_exports_complete_trace(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""This test verifies that one successful non-streaming /v1/responses request
|
||
produces exactly one complete OTEL trace.
|
||
|
||
The trace must have a single root span named "POST /v1/responses". The
|
||
authentication, database, cost-writing, and model-call spans must all belong to
|
||
the same trace and have valid parent relationships leading back to that root.
|
||
|
||
The model-call span is expected to be named "chat <model>". The test fails if
|
||
the request is split across multiple traces, if any span references a missing
|
||
parent, or if the model-call span cannot be connected back to the root."""
|
||
route = "/v1/responses"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-trace-responses-{unique_marker()}", models=[CHEAP_OPENAI_MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.responses_raw(key, CHEAP_OPENAI_MODEL, f"reply with one word {marker}"),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
|
||
genai_span = f"chat {CHEAP_OPENAI_MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=genai_span)
|
||
|
||
@pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["chat_completions"])
|
||
def test_chat_completions_stream_exports_complete_trace(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A successful streamed `/chat/completions` request should export one
|
||
complete OTEL trace. The trace must contain a single root `SERVER`
|
||
span, with the auth, database, cost, and gen-AI `CLIENT` spans all
|
||
connected back to that root.
|
||
|
||
Streaming has an additional lifecycle risk because the gen-AI span is
|
||
closed by the stream-consumption path after the final chunk has
|
||
arrived and usage has been aggregated. Historically, this has caused
|
||
duplicate or orphaned spans.
|
||
|
||
The test therefore confirms that:
|
||
|
||
* The response actually streams.
|
||
* Exactly one gen-AI span is created for the request.
|
||
* The gen-AI span contains `litellm.request.streaming=true`.
|
||
"""
|
||
route = "/chat/completions"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-stream-chat-{unique_marker()}", models=[MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", stream=True, max_tokens=16),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}"
|
||
assert outcome.chunks > 0, "the stream must deliver at least one event"
|
||
assert outcome.stream_error is None, (
|
||
f"the stream carried an upstream error event despite the 200: {outcome.stream_error}"
|
||
)
|
||
|
||
genai_span = f"chat {MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=genai_span)
|
||
|
||
served = one_served_genai_span(hits[0], genai_span)
|
||
assert _tag(served, "litellm.request.streaming") is True, (
|
||
"the gen-AI span must record litellm.request.streaming=true; its absence means "
|
||
"the stream flag was dropped before the model call"
|
||
)
|
||
|
||
@pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["messages"])
|
||
def test_messages_stream_exports_complete_trace(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A successful streamed `/v1/messages` request should export one
|
||
complete OTEL trace. The trace must contain a single root `SERVER`
|
||
span, with the auth, database, cost, and gen-AI `CLIENT` spans all
|
||
connected back to that root.
|
||
|
||
This endpoint has the same streaming lifecycle risk as
|
||
`/chat/completions`: the gen-AI span is closed by the
|
||
stream-consumption path after the final chunk has arrived and usage
|
||
has been aggregated.
|
||
|
||
The test therefore confirms that:
|
||
|
||
* The response actually streams.
|
||
* Exactly one gen-AI span is created for the request.
|
||
* The gen-AI span contains `litellm.request.streaming=true`.
|
||
"""
|
||
route = "/v1/messages"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-stream-messages-{unique_marker()}", models=[MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.messages_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16, stream=True),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}"
|
||
assert outcome.chunks > 0, "the stream must deliver at least one event"
|
||
assert outcome.stream_error is None, (
|
||
f"the stream carried an upstream error event despite the 200: {outcome.stream_error}"
|
||
)
|
||
|
||
genai_span = f"chat {MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=genai_span)
|
||
|
||
served = one_served_genai_span(hits[0], genai_span)
|
||
assert _tag(served, "litellm.request.streaming") is True, (
|
||
"the gen-AI span must record litellm.request.streaming=true; its absence means "
|
||
"the stream flag was dropped before the model call"
|
||
)
|
||
|
||
@pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["responses"])
|
||
def test_responses_stream_exports_complete_trace(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A successful streamed /v1/responses request should export one complete
|
||
OTEL trace. The trace must contain a single root SERVER span, with the
|
||
auth, database, and gen-AI CLIENT spans all connected back to that
|
||
root.
|
||
|
||
This endpoint has the same streaming lifecycle risk as the other
|
||
streaming surfaces: the gen-AI span is closed by the
|
||
stream-consumption path after the final event has arrived and usage
|
||
has been aggregated.
|
||
|
||
The test therefore confirms that:
|
||
|
||
* The response actually streams.
|
||
* Exactly one gen-AI span is created for the request.
|
||
* Spend is recorded correctly.
|
||
"""
|
||
route = "/v1/responses"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-stream-responses-{unique_marker()}", models=[CHEAP_OPENAI_MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.responses_raw(key, CHEAP_OPENAI_MODEL, f"reply with one word {marker}", stream=True),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}"
|
||
assert outcome.chunks > 0, "the stream must deliver at least one event"
|
||
assert outcome.stream_error is None, (
|
||
f"the stream carried an upstream error event despite the 200: {outcome.stream_error}"
|
||
)
|
||
|
||
genai_span = f"chat {CHEAP_OPENAI_MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False)
|
||
|
||
one_served_genai_span(hits[0], genai_span)
|
||
|
||
spend_row = client.poll_proxy_spend_for_key(key)
|
||
assert spend_row is not None and spend_row.spend is not None and spend_row.spend > 0, (
|
||
"a successful streamed responses call must record a positive-spend row in /spend/logs "
|
||
"(the cost-write SPAN is knowingly absent on this surface, LIT-4428, but the spend "
|
||
f"itself must land); got {spend_row!r}"
|
||
)
|
||
assert spend_row.call_type == "aresponses", (
|
||
f"the spend row must be attributed to the responses call type, got {spend_row.call_type!r}"
|
||
)
|
||
|
||
@pytest.mark.covers("logging.otel.stream.records_ttft", exercised_on=["chat_completions"])
|
||
def test_chat_completions_stream_records_real_ttft(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A successful streamed `/chat/completions` request should record a
|
||
real time-to-first-token on its gen-AI span: the
|
||
`gen_ai.response.time_to_first_chunk` attribute, in seconds.
|
||
|
||
The test therefore confirms that:
|
||
|
||
* The response actually streams.
|
||
* Exactly one gen-AI span is created for the request.
|
||
* The TTFT attribute is present and numeric.
|
||
* Its value is positive and strictly less than the gen-AI span's own
|
||
total duration.
|
||
"""
|
||
route = "/chat/completions"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-ttft-chat-{unique_marker()}", models=[MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", stream=True, max_tokens=16),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}"
|
||
assert outcome.chunks > 0, "the stream must deliver at least one event"
|
||
assert outcome.stream_error is None, (
|
||
f"the stream carried an upstream error event despite the 200: {outcome.stream_error}"
|
||
)
|
||
|
||
genai_span = f"chat {MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_real_ttft(hits, genai_span=genai_span)
|
||
|
||
@pytest.mark.covers("logging.otel.stream.records_ttft", exercised_on=["messages"])
|
||
def test_messages_stream_records_real_ttft(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A successful streamed `/v1/messages` request should record a real
|
||
time-to-first-token on its gen-AI span: the
|
||
`gen_ai.response.time_to_first_chunk` attribute, in seconds.
|
||
|
||
The test therefore confirms that:
|
||
|
||
* The response actually streams.
|
||
* Exactly one gen-AI span is created for the request.
|
||
* The TTFT attribute is present and numeric.
|
||
* Its value is positive and strictly less than the gen-AI span's own
|
||
total duration.
|
||
"""
|
||
route = "/v1/messages"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-ttft-messages-{unique_marker()}", models=[MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.messages_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16, stream=True),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}"
|
||
assert outcome.chunks > 0, "the stream must deliver at least one event"
|
||
assert outcome.stream_error is None, (
|
||
f"the stream carried an upstream error event despite the 200: {outcome.stream_error}"
|
||
)
|
||
|
||
genai_span = f"chat {MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_real_ttft(hits, genai_span=genai_span)
|
||
|
||
@pytest.mark.covers("logging.otel.stream.records_ttft", exercised_on=["responses"])
|
||
def test_responses_stream_records_real_ttft(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A successful streamed `/v1/responses` request should record a real
|
||
time-to-first-token on its gen-AI span: the
|
||
`gen_ai.response.time_to_first_chunk` attribute, in seconds.
|
||
|
||
The test therefore confirms that:
|
||
|
||
* The response actually streams.
|
||
* Exactly one gen-AI span is created for the request.
|
||
* The TTFT attribute is present and numeric.
|
||
* Its value is positive and strictly less than the gen-AI span's own
|
||
total duration.
|
||
"""
|
||
route = "/v1/responses"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
key = client.key_with_alias(f"otel-ttft-responses-{unique_marker()}", models=[CHEAP_OPENAI_MODEL])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
marker = unique_marker()
|
||
outcome = first_ok(
|
||
client,
|
||
lambda: client.responses_raw(key, CHEAP_OPENAI_MODEL, f"reply with one word {marker}", stream=True),
|
||
)
|
||
assert outcome.call_id is not None, "success response must carry x-litellm-call-id"
|
||
assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}"
|
||
assert outcome.chunks > 0, "the stream must deliver at least one event"
|
||
assert outcome.stream_error is None, (
|
||
f"the stream carried an upstream error event despite the 200: {outcome.stream_error}"
|
||
)
|
||
|
||
genai_span = f"chat {CHEAP_OPENAI_MODEL}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_real_ttft(hits, genai_span=genai_span)
|
||
|
||
@pytest.mark.covers("logging.otel.failure.exports_metric", exercised_on=["chat_completions"])
|
||
def test_failed_chat_completions_error_span_attributes(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""This test checks that a failed `/chat/completions` request produces a
|
||
single, complete OTEL trace. The model-call span should include all
|
||
expected error attributes, a non-empty stack trace, and the full,
|
||
untruncated `error.message`. The provider error embedded in that
|
||
message must also remain valid JSON. The root server span should
|
||
record the same 401 response returned to the client.
|
||
|
||
The test uses a deployment with an invalid upstream API key. This
|
||
allows the request to pass LiteLLM’s proxy authentication and fail at
|
||
the provider, which is necessary to generate a model-call error span.
|
||
There should be no cost-write span because failed requests are not
|
||
billed."""
|
||
route = "/chat/completions"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
model_name = f"otel-err-{unique_marker()}"
|
||
model_id = client.create_model(
|
||
model_name,
|
||
LiteLLMParamsBody(model="anthropic/claude-haiku-4-5", api_key=INVALID_UPSTREAM_API_KEY),
|
||
)
|
||
resources.defer(lambda: client.delete_model(model_id))
|
||
key = client.key_with_alias(f"otel-err-{unique_marker()}", models=[model_name])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
deadline = time.monotonic() + client.proxy.poll_timeout
|
||
while True:
|
||
outcome = client.chat_raw(key, model_name, "trigger an upstream auth failure", max_tokens=16)
|
||
assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid"
|
||
if "AnthropicException" in outcome.body or time.monotonic() >= deadline:
|
||
break
|
||
time.sleep(client.proxy.poll_interval)
|
||
assert "AnthropicException" in outcome.body, (
|
||
"never saw the upstream provider failure before the deadline; the key may still be "
|
||
f"propagating - last outcome {outcome.status_code}: {outcome.body[:200]}"
|
||
)
|
||
assert outcome.status_code == 401, (
|
||
f"an upstream auth failure must map to 401, got {outcome.status_code}: {outcome.body[:200]}"
|
||
)
|
||
assert outcome.call_id is not None, "failed responses must still carry x-litellm-call-id"
|
||
|
||
genai_span = f"chat {model_name}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False)
|
||
|
||
root = next(span for span in hits[0].spans if not span.references)
|
||
assert str(_tag(root, "http.status_code")) == "401", (
|
||
f"the SERVER span must record the 401 the client received, got {_tag(root, 'http.status_code')!r}"
|
||
)
|
||
genai = next(span for span in hits[0].spans if span.operation_name == genai_span)
|
||
_assert_error_span_contract(genai)
|
||
|
||
@pytest.mark.covers("logging.otel.failure.exports_metric", exercised_on=["messages"])
|
||
def test_failed_messages_error_span_attributes(
|
||
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||
) -> None:
|
||
"""A failed `/v1/messages` request must carry the same error-span
|
||
contract as a failed `/chat/completions` request (LIT-6164). The
|
||
async messages entrypoint used to surface the provider handler's raw
|
||
BaseLLMException to the failure logger, so the model-call span came
|
||
out with error.type=BaseLLMException and no
|
||
litellm.provider.error.llm_provider attribute.
|
||
|
||
Same setup as the chat sibling: a deployment with an invalid upstream
|
||
API key passes proxy auth and fails at the provider with a real 401,
|
||
and failed requests are not billed, so no cost-write span."""
|
||
route = "/v1/messages"
|
||
_assert_otel_destination_configured(client)
|
||
|
||
model_name = f"otel-err-{unique_marker()}"
|
||
model_id = client.create_model(
|
||
model_name,
|
||
LiteLLMParamsBody(model="anthropic/claude-haiku-4-5", api_key=INVALID_UPSTREAM_API_KEY),
|
||
)
|
||
resources.defer(lambda: client.delete_model(model_id))
|
||
key = client.key_with_alias(f"otel-err-{unique_marker()}", models=[model_name])
|
||
resources.defer(lambda: client.delete_key(key))
|
||
|
||
deadline = time.monotonic() + client.proxy.poll_timeout
|
||
while True:
|
||
outcome = client.messages_raw(key, model_name, "trigger an upstream auth failure", max_tokens=16)
|
||
assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid"
|
||
if "AnthropicException" in outcome.body or time.monotonic() >= deadline:
|
||
break
|
||
time.sleep(client.proxy.poll_interval)
|
||
assert "AnthropicException" in outcome.body, (
|
||
"never saw the mapped upstream provider failure before the deadline; either the key is "
|
||
"still propagating or the messages route surfaced the raw unmapped provider error - "
|
||
f"last outcome {outcome.status_code}: {outcome.body[:200]}"
|
||
)
|
||
assert outcome.status_code == 401, (
|
||
f"an upstream auth failure must map to 401, got {outcome.status_code}: {outcome.body[:200]}"
|
||
)
|
||
assert outcome.call_id is not None, "failed responses must still carry x-litellm-call-id"
|
||
|
||
genai_span = f"chat {model_name}"
|
||
hits = otel_reader.poll_traces_for_call(
|
||
call_id=outcome.call_id,
|
||
settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False),
|
||
settled_prefixes={DB_SPAN_PREFIX},
|
||
)
|
||
_assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False)
|
||
|
||
root = next(span for span in hits[0].spans if not span.references)
|
||
assert str(_tag(root, "http.status_code")) == "401", (
|
||
f"the SERVER span must record the 401 the client received, got {_tag(root, 'http.status_code')!r}"
|
||
)
|
||
genai = next(span for span in hits[0].spans if span.operation_name == genai_span)
|
||
_assert_error_span_contract(genai)
|