mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
* feat(otel): attribute Prisma database spans to PostgreSQL instead of localhost Prisma reaches PostgreSQL through a query engine on loopback, so transport instrumentation attributes database waits to localhost and operators cannot tell the work is PostgreSQL or correlate it with the database's own metrics. Datastore service spans now carry db.system.name, db.system, db.operation.name and, for PostgreSQL, server.address, server.port and db.namespace derived from DATABASE_URL, and are emitted as CLIENT spans. Only host, port, database and schema are read, so no credential reaches an exporter. Endpoint attributes are omitted when a read replica is configured, because routing is decided per Prisma call underneath the span. * fix(otel): reject a mis-split DSN authority and name socket-only databases An unencoded '/' in the password truncates the URL authority, so urlparse reports the username as the host and the password tail as the database, which put credential material in db.namespace. Postgres drivers reject that DSN outright, so the only safe reading is no endpoint at all. A hostless 'postgresql:///litellm' is a valid local-socket DSN that Prisma accepts, and it now yields db.namespace with no server address rather than nothing. The default schema is matched case-insensitively, since an unquoted PostgreSQL identifier folds and one deployment must yield one namespace. * fix(otel): keep a non-default schema in db.namespace Prisma quotes the schema name, so a DSN with ?schema=PUBLIC provisions a second schema alongside public rather than reusing it. Observed on a live proxy: the PUBLIC schema came up with its own 70 tables next to public's 78, and a key created under one was not visible under the other. Case-folding the two into a single namespace therefore reported two different schemas as the same database, which is the misattribution this feature exists to remove. Match the default literally. * fix(otel): reject any DSN whose userinfo fell outside the authority An unencoded '#' or '?' in the password sends the tail to the fragment or query, leaving the path empty, so the marker check on the database segment never fired and urlparse's hostname (the database username) was exported as server.address. The stranded userinfo '@' is the general tell for every mis-split, so guard on that instead of enumerating the characters that cause it. * fix(otel): allow an at-sign inside a well-formed DSN query The previous guard rejected any DSN whose userinfo at-sign fell outside the netloc, which also caught libpq parameters that legitimately carry one, so ?application_name=svc@prod and ?user=admin@company.com lost their endpoint attributes. Discriminate instead: a PostgreSQL DSN never has a fragment, its database name cannot hold an unencoded at-sign or slash, and an at-sign in the query is only suspicious when the query did not parse as parameters. * fix(otel): resolve the database endpoint per span instead of once per process The endpoint was cached for the process lifetime on the premise that DATABASE_URL is deployment-static. It is not. The RDS IAM refresh rebuilds the URL from DATABASE_HOST/PORT/NAME/SCHEMA on every rotation, the reconnect path re-reads DATABASE_URL, and the DB-backed environment_variables config overlay sets arbitrary keys post-startup with no blocklist covering DATABASE_*. A process that had genuinely failed over kept exporting the old server. Read the environment per span, which is also what Prisma connects with, so the span can no longer name a different server than the one serving the query; get_secret_str consulted a secret manager first and could diverge from it. Only the parse is memoized, keyed on the URL. * fix(otel): reject a question-mark mis-split whose tail parses as parameters A '?' in a password strands the rest of the authority in the query, and that tail can still parse as key=value, so testing only for an unparseable query let the login through as server.address. One spelling hijacked the host= parameter and put the password suffix there directly. A legitimate at-sign in a query always follows a database path, and a '?'-mis-split never leaves one, so require both. * refactor(otel): drop the DSN parse cache that retained rotated credentials The cache was keyed on the full DATABASE_URL, so up to eight complete DSNs, each carrying a password or a retired IAM token, stayed referenced for the process lifetime and outlived the rotation that replaced them. Nothing reached a span, but a heap dump or crash report would have surfaced them. Parsing costs about four microseconds against a span emission that costs orders of magnitude more, so the cache bought nothing worth that. * fix(otel): avoid a set construction the tightened LIT002 budget rejects * fix(otel): refuse an ambiguous DSN authority instead of guessing at it A password holding both an unencoded slash and a query-like tail defeated all three shape checks: the slash left a clean path carrying the password remainder, the query still parsed as parameters, and no fragment survived. The login went out as server.address, the password's leading digits as server.port and the rest as db.namespace. A DSN whose at-sign sits in a query parameter is indistinguishable from that mis-split by any property of the parse; both leave no userinfo, a host, a port and a path. Guessing wrong publishes a credential fragment, so the ambiguity now resolves to refusing the endpoint. Such a DSN loses server.address and db.namespace and keeps the rest of the span; percent-encoding the at-sign restores them. Also honour port= alongside host=, which libpq pairs and this read ignored. * docs(otel): fix a spliced sentence and a stale cache claim in db_endpoint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""Tests for litellm/integrations/otel/model/db_endpoint.py
|
|
|
|
Prisma talks to PostgreSQL through a loopback query engine, so a DB span with no
|
|
``server.address`` gets attributed to ``localhost`` by the backend. These cover
|
|
the endpoint derivation that names the real server, for the local engine and for
|
|
remote and read-replica deployments, and pin the rule that no credential is ever
|
|
exported.
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from litellm.integrations.otel.model.db_endpoint import (
|
|
DatabaseEndpoint,
|
|
db_span_attributes,
|
|
parse_database_endpoint,
|
|
postgres_endpoint,
|
|
)
|
|
|
|
LOCAL_DSN = "postgresql://llmproxy:dbpassword9090@localhost:5432/litellm"
|
|
REMOTE_DSN = "postgresql://llmproxy:s3cr3t@litellm-prod.abc123.us-east-1.rds.amazonaws.com:6432/litellm?schema=reporting&sslmode=require"
|
|
REPLICA_DSN = "postgresql://reader:r3ad0nly@litellm-prod-ro.abc123.us-east-1.rds.amazonaws.com/litellm_replica"
|
|
|
|
|
|
def _resolve(service, call_type=None, database_url=None, read_replica_url=None):
|
|
"""Resolve attributes with the two DB env vars set, as the proxy sets them."""
|
|
env = {k: v for k, v in (("DATABASE_URL", database_url), ("DATABASE_URL_READ_REPLICA", read_replica_url)) if v}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
for absent in {"DATABASE_URL", "DATABASE_URL_READ_REPLICA"} - set(env):
|
|
os.environ.pop(absent, None)
|
|
return dict(db_span_attributes(service, call_type))
|
|
|
|
|
|
def test_local_prisma_engine_endpoint_is_the_postgres_server_not_the_engine():
|
|
assert parse_database_endpoint(LOCAL_DSN) == DatabaseEndpoint(
|
|
address="localhost", port=5432, namespace="litellm"
|
|
)
|
|
|
|
|
|
def test_remote_endpoint_keeps_host_port_and_schema_qualified_namespace():
|
|
assert parse_database_endpoint(REMOTE_DSN) == DatabaseEndpoint(
|
|
address="litellm-prod.abc123.us-east-1.rds.amazonaws.com",
|
|
port=6432,
|
|
namespace="litellm|reporting",
|
|
)
|
|
|
|
|
|
def test_read_replica_dsn_parses_to_the_replica_host_and_database():
|
|
assert parse_database_endpoint(REPLICA_DSN) == DatabaseEndpoint(
|
|
address="litellm-prod-ro.abc123.us-east-1.rds.amazonaws.com",
|
|
port=5432,
|
|
namespace="litellm_replica",
|
|
)
|
|
|
|
|
|
def test_default_schema_is_not_spelled_out_in_the_namespace():
|
|
"""``?schema=public`` and no schema at all are the same deployment, so they
|
|
must not split a group-by on db.namespace."""
|
|
assert parse_database_endpoint("postgresql://u:p@db.internal/litellm?schema=public") == parse_database_endpoint(
|
|
"postgresql://u:p@db.internal/litellm"
|
|
)
|
|
|
|
|
|
def test_unix_socket_host_parameter_wins_over_the_netloc():
|
|
"""libpq and the Cloud SQL connector both put the real target in ``host=``
|
|
behind a localhost netloc, which is the attribution this module removes."""
|
|
assert parse_database_endpoint(
|
|
"postgresql://u:p@localhost:5432/litellm?host=/cloudsql/proj:us-east1:inst"
|
|
) == DatabaseEndpoint(address="/cloudsql/proj:us-east1:inst", port=5432, namespace="litellm")
|
|
|
|
|
|
def test_socket_only_dsn_without_a_netloc_host_still_resolves():
|
|
assert parse_database_endpoint("postgresql:///litellm?host=/var/run/postgresql") == DatabaseEndpoint(
|
|
address="/var/run/postgresql", port=5432, namespace="litellm"
|
|
)
|
|
|
|
|
|
def test_percent_encoded_database_name_is_decoded():
|
|
endpoint = parse_database_endpoint("postgresql://u:p@db.internal/litellm%20prod")
|
|
assert endpoint is not None and endpoint.namespace == "litellm prod"
|
|
|
|
|
|
MISPARSED_AUTHORITY_DSNS = (
|
|
("postgresql://litellm:/kJ8xQz+9wT@db.internal:5432/litellm", "kJ8xQz+9wT"),
|
|
("postgresql://litellm:12345/aBcD@db.internal:5432/litellm", "aBcD"),
|
|
# '#' sends the tail to the fragment and '?' to the query, so the path is
|
|
# empty and only the stranded userinfo '@' reveals the mis-split.
|
|
("postgresql://litellm:12345#aBcD@db.internal/litellm", "aBcD"),
|
|
("postgresql://litellm:12345?aBcD@db.internal/litellm", "aBcD"),
|
|
# A '?'-stranded tail that happens to parse as parameters, including one
|
|
# that hijacks the host= parameter into server.address.
|
|
("postgresql://litellm:12345?a=aBcD@db.internal/litellm", "aBcD"),
|
|
("postgresql://litellm:12345?host=aBcD@db.internal/litellm", "aBcD"),
|
|
# Both '/' and '?key=value' together: the slash leaves a clean path holding
|
|
# the password remainder and the query still parses, so only the stranded
|
|
# at-sign gives it away.
|
|
("postgresql://litellm:12345/aBcD?x=1@db.internal/litellm", "aBcD"),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(("dsn", "secret"), MISPARSED_AUTHORITY_DSNS)
|
|
def test_unencoded_slash_in_password_never_yields_an_endpoint(dsn, secret):
|
|
"""An unencoded '/' truncates the authority, so urlparse reports the username
|
|
as the host and the password tail as the database. Postgres drivers reject
|
|
such a DSN outright, so the only safe reading is no endpoint at all."""
|
|
assert parse_database_endpoint(dsn) is None
|
|
|
|
|
|
@pytest.mark.parametrize(("dsn", "secret"), MISPARSED_AUTHORITY_DSNS)
|
|
def test_unencoded_slash_in_password_never_reaches_a_span(dsn, secret):
|
|
attrs = _resolve("postgres", "get_data", database_url=dsn)
|
|
exported = " ".join(str(value) for value in attrs.values())
|
|
assert secret not in exported
|
|
assert "db.namespace" not in attrs
|
|
assert "server.address" not in attrs
|
|
|
|
|
|
def test_extra_path_segment_yields_no_endpoint():
|
|
"""A database name cannot hold an unencoded '/', so a second path segment
|
|
means the authority was mis-split even when no '@' survived into the path."""
|
|
assert parse_database_endpoint("postgresql://db.internal:5432/litellm/extra") is None
|
|
|
|
|
|
@pytest.mark.parametrize("dsn", [d for d, _ in MISPARSED_AUTHORITY_DSNS])
|
|
def test_a_mis_split_authority_never_exports_the_database_username(dsn):
|
|
"""The username lands in ``parsed.hostname`` when the authority truncates, so
|
|
a span would name the DB user as the server."""
|
|
attrs = _resolve("postgres", "get_data", database_url=dsn)
|
|
assert "server.address" not in attrs
|
|
assert "litellm" not in " ".join(str(v) for v in attrs.values())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dsn",
|
|
[
|
|
"postgresql://db.internal:5432/litellm?application_name=svc@prod",
|
|
"postgresql://db.internal:5432/litellm?user=admin@company.com",
|
|
],
|
|
)
|
|
def test_an_unencoded_at_sign_in_a_query_forfeits_the_endpoint(dsn):
|
|
"""This shape is byte-for-byte indistinguishable from a mis-split password,
|
|
so it resolves to no endpoint rather than risking a credential fragment.
|
|
Percent-encoding the at-sign restores the attributes."""
|
|
assert parse_database_endpoint(dsn) is None
|
|
assert parse_database_endpoint(dsn.replace("@", "%40")) is not None
|
|
|
|
|
|
def test_host_and_port_query_parameters_are_honoured_together():
|
|
assert parse_database_endpoint("postgresql://ignored/litellm?host=real.internal&port=6543") == DatabaseEndpoint(
|
|
address="real.internal", port=6543, namespace="litellm"
|
|
)
|
|
|
|
|
|
def test_percent_encoded_password_still_resolves_the_endpoint():
|
|
"""The encoded spelling is the one a driver accepts, so it must keep working."""
|
|
assert parse_database_endpoint("postgresql://litellm:pa%2Fssw0rd@db.internal:5432/litellm") == DatabaseEndpoint(
|
|
address="db.internal", port=5432, namespace="litellm"
|
|
)
|
|
|
|
|
|
def test_hostless_socket_dsn_still_names_the_database():
|
|
"""``postgresql:///litellm`` is a valid local-socket DSN that Prisma accepts,
|
|
so the database is knowable even though no server address is."""
|
|
assert parse_database_endpoint("postgresql:///litellm") == DatabaseEndpoint(
|
|
address=None, port=None, namespace="litellm"
|
|
)
|
|
|
|
|
|
def test_hostless_socket_dsn_emits_namespace_without_a_server():
|
|
attrs = _resolve("postgres", "get_data", database_url="postgresql:///litellm")
|
|
assert attrs["db.namespace"] == "litellm"
|
|
assert "server.address" not in attrs
|
|
assert "server.port" not in attrs
|
|
|
|
|
|
def test_dsn_with_neither_host_nor_database_yields_no_endpoint():
|
|
assert parse_database_endpoint("postgresql://") is None
|
|
|
|
|
|
def test_prisma_default_schema_is_left_implicit():
|
|
endpoint = parse_database_endpoint("postgresql://u:p@db.internal/litellm?schema=public")
|
|
assert endpoint is not None and endpoint.namespace == "litellm"
|
|
|
|
|
|
@pytest.mark.parametrize("spelling", ["PUBLIC", "Public", "reporting"])
|
|
def test_a_non_default_schema_stays_in_the_namespace(spelling):
|
|
"""Prisma quotes the schema name, so ``?schema=PUBLIC`` provisions a second
|
|
schema alongside ``public`` with its own tables. Case-folding them into one
|
|
namespace would report two different schemas as the same database."""
|
|
endpoint = parse_database_endpoint(f"postgresql://u:p@db.internal/litellm?schema={spelling}")
|
|
assert endpoint is not None and endpoint.namespace == f"litellm|{spelling}"
|
|
|
|
|
|
def test_postgres_scheme_alias_is_accepted():
|
|
assert parse_database_endpoint("postgres://u:p@db.internal/litellm") == DatabaseEndpoint(
|
|
address="db.internal", port=5432, namespace="litellm"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"dsn",
|
|
[
|
|
None,
|
|
"",
|
|
"mysql://u:p@db.internal:3306/litellm",
|
|
"postgresql://u:p@db.internal:not-a-port/litellm",
|
|
"not a url at all",
|
|
],
|
|
)
|
|
def test_unusable_dsn_degrades_to_no_endpoint(dsn):
|
|
assert parse_database_endpoint(dsn) is None
|
|
|
|
|
|
def test_database_without_name_or_schema_has_no_namespace():
|
|
assert parse_database_endpoint("postgresql://u:p@db.internal:5432/") == DatabaseEndpoint(
|
|
address="db.internal", port=5432, namespace=None
|
|
)
|
|
|
|
|
|
def test_postgres_service_span_carries_system_operation_and_endpoint():
|
|
assert _resolve("postgres", "get_data", database_url=REMOTE_DSN) == {
|
|
"db.system.name": "postgresql",
|
|
"db.system": "postgresql",
|
|
"db.operation.name": "get_data",
|
|
"server.address": "litellm-prod.abc123.us-east-1.rds.amazonaws.com",
|
|
"server.port": 6432,
|
|
"db.namespace": "litellm|reporting",
|
|
}
|
|
|
|
|
|
def test_legacy_db_system_is_dual_emitted_for_datadog():
|
|
"""Datadog's OTLP intake infers the database span type from ``db.system``,
|
|
not from the semconv-current ``db.system.name``."""
|
|
assert _resolve("postgres", "get_data", database_url=LOCAL_DSN)["db.system"] == "postgresql"
|
|
assert _resolve("redis", "set")["db.system"] == "redis"
|
|
|
|
|
|
def test_batch_write_service_is_also_attributed_to_postgres():
|
|
attrs = _resolve("batch_write_to_db", "_PROXY_track_cost_callback", database_url=REMOTE_DSN)
|
|
assert attrs["db.system.name"] == "postgresql"
|
|
assert attrs["server.address"] == "litellm-prod.abc123.us-east-1.rds.amazonaws.com"
|
|
|
|
|
|
def test_redis_service_never_borrows_the_postgres_endpoint():
|
|
assert _resolve("redis", "set", database_url=REMOTE_DSN) == {
|
|
"db.system.name": "redis",
|
|
"db.system": "redis",
|
|
"db.operation.name": "set",
|
|
}
|
|
|
|
|
|
def test_non_datastore_service_gets_no_db_attributes():
|
|
assert _resolve("reset_budget_job", "reset_budget", database_url=REMOTE_DSN) == {}
|
|
|
|
|
|
def test_configured_read_replica_suppresses_the_endpoint_rather_than_naming_the_primary():
|
|
"""Reads are routed to the replica per Prisma call, underneath the span, so
|
|
naming the writer would pin replica latency onto the primary."""
|
|
attrs = _resolve("postgres", "get_data", database_url=REMOTE_DSN, read_replica_url=REPLICA_DSN)
|
|
assert attrs == {
|
|
"db.system.name": "postgresql",
|
|
"db.system": "postgresql",
|
|
"db.operation.name": "get_data",
|
|
}
|
|
|
|
|
|
def test_endpoint_attributes_are_omitted_when_database_url_is_unset():
|
|
assert _resolve("postgres", "get_data") == {
|
|
"db.system.name": "postgresql",
|
|
"db.system": "postgresql",
|
|
"db.operation.name": "get_data",
|
|
}
|
|
|
|
|
|
def test_blank_call_type_does_not_emit_an_empty_operation_attribute():
|
|
assert "db.operation.name" not in _resolve("postgres", "")
|
|
assert "db.operation.name" not in _resolve("postgres", None)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("dsn", "secrets"),
|
|
[
|
|
(LOCAL_DSN, ("dbpassword9090", "llmproxy")),
|
|
(REMOTE_DSN, ("s3cr3t", "llmproxy", "sslmode")),
|
|
(REPLICA_DSN, ("r3ad0nly", "reader")),
|
|
],
|
|
)
|
|
def test_no_credential_reaches_any_exported_attribute(dsn, secrets):
|
|
attrs = _resolve("postgres", "get_data", database_url=dsn)
|
|
assert attrs["server.address"]
|
|
exported = " ".join(str(value) for value in attrs.values())
|
|
for secret in secrets:
|
|
assert secret not in exported
|
|
|
|
|
|
def test_a_runtime_endpoint_change_is_reflected_on_the_next_span():
|
|
"""The RDS IAM refresh, the reconnect path and the DB-backed
|
|
environment_variables overlay can all rewrite DATABASE_URL after startup, so
|
|
a value cached for the process lifetime would report a server the process no
|
|
longer talks to."""
|
|
first = _resolve("postgres", "get_data", database_url=LOCAL_DSN)
|
|
assert first["server.address"] == "localhost"
|
|
moved = _resolve("postgres", "get_data", database_url=REMOTE_DSN)
|
|
assert moved["server.address"] == "litellm-prod.abc123.us-east-1.rds.amazonaws.com"
|
|
|
|
|
|
def test_a_replica_configured_after_the_first_span_suppresses_the_endpoint():
|
|
assert _resolve("postgres", "get_data", database_url=REMOTE_DSN)["server.address"]
|
|
later = _resolve("postgres", "get_data", database_url=REMOTE_DSN, read_replica_url=REPLICA_DSN)
|
|
assert "server.address" not in later
|