Files
letta-server/letta/services/provider_trace_backends/postgres.py
Kian Jones c1a02fa180 feat: add metadata-only provider trace storage option (#9155)
* feat: add metadata-only provider trace storage option

Add support for writing provider traces to a lightweight metadata-only
table (~1.5GB) instead of the full table (~725GB) since request/response
JSON is now stored in GCS.

- Add `LETTA_TELEMETRY_PROVIDER_TRACE_PG_METADATA_ONLY` setting
- Create `provider_trace_metadata` table via alembic migration
- Conditionally write to new table when flag is enabled
- Include backfill script for migrating existing data

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* chore: regenerate API spec and SDK

* fix: use composite PK (created_at, id) for provider_trace_metadata

Aligns with GCS partitioning structure (raw/date=YYYY-MM-DD/{id}.json.gz)
and enables efficient date-range queries via the B-tree index.

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* ammendments

* fix: add bulk data copy to migration

Copy existing provider_traces metadata in-migration instead of separate
backfill script. Creates indexes after bulk insert for better performance.

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix: remove data copy from migration, create empty table only

Old data stays in provider_traces, new writes go to provider_trace_metadata
when flag is enabled. Full traces are in GCS anyway.

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix: address PR comments

- Remove GCS mention from ProviderTraceMetadata docstring
- Move metadata object creation outside session context

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* fix: reads always use full provider_traces table

The metadata_only flag should only control writes. Reads always go to
the full table to avoid returning ProviderTraceMetadata where
ProviderTrace is expected.

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

* feat: enable metadata-only provider trace writes in prod

Add LETTA_TELEMETRY_PROVIDER_TRACE_PG_METADATA_ONLY=true to all
Helm values (memgpt-server and lettuce-py, prod and dev).

🐾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>

---------

Co-authored-by: Letta <noreply@letta.com>
2026-01-29 12:44:04 -08:00

105 lines
4.3 KiB
Python

"""PostgreSQL provider trace backend."""
from letta.helpers.json_helpers import json_dumps, json_loads
from letta.orm.provider_trace import ProviderTrace as ProviderTraceModel
from letta.orm.provider_trace_metadata import ProviderTraceMetadata as ProviderTraceMetadataModel
from letta.schemas.provider_trace import ProviderTrace, ProviderTraceMetadata
from letta.schemas.user import User
from letta.server.db import db_registry
from letta.services.provider_trace_backends.base import ProviderTraceBackendClient
from letta.settings import telemetry_settings
class PostgresProviderTraceBackend(ProviderTraceBackendClient):
"""Store provider traces in PostgreSQL."""
async def create_async(
self,
actor: User,
provider_trace: ProviderTrace,
) -> ProviderTrace | ProviderTraceMetadata:
if telemetry_settings.provider_trace_pg_metadata_only:
return await self._create_metadata_only_async(actor, provider_trace)
return await self._create_full_async(actor, provider_trace)
async def _create_full_async(
self,
actor: User,
provider_trace: ProviderTrace,
) -> ProviderTrace:
"""Write full provider trace to provider_traces table."""
async with db_registry.async_session() as session:
provider_trace_model = ProviderTraceModel(**provider_trace.model_dump())
provider_trace_model.organization_id = actor.organization_id
if provider_trace.request_json:
request_json_str = json_dumps(provider_trace.request_json)
provider_trace_model.request_json = json_loads(request_json_str)
if provider_trace.response_json:
response_json_str = json_dumps(provider_trace.response_json)
provider_trace_model.response_json = json_loads(response_json_str)
await provider_trace_model.create_async(session, actor=actor, no_commit=True, no_refresh=True)
return provider_trace_model.to_pydantic()
async def _create_metadata_only_async(
self,
actor: User,
provider_trace: ProviderTrace,
) -> ProviderTraceMetadata:
"""Write metadata-only trace to provider_trace_metadata table."""
metadata = ProviderTraceMetadata(
id=provider_trace.id,
step_id=provider_trace.step_id,
agent_id=provider_trace.agent_id,
agent_tags=provider_trace.agent_tags,
call_type=provider_trace.call_type,
run_id=provider_trace.run_id,
source=provider_trace.source,
org_id=provider_trace.org_id,
user_id=provider_trace.user_id,
)
metadata_model = ProviderTraceMetadataModel(**metadata.model_dump())
metadata_model.organization_id = actor.organization_id
async with db_registry.async_session() as session:
await metadata_model.create_async(session, actor=actor, no_commit=True, no_refresh=True)
return metadata_model.to_pydantic()
async def get_by_step_id_async(
self,
step_id: str,
actor: User,
) -> ProviderTrace | None:
"""Read from provider_traces table. Always reads from full table regardless of write flag."""
return await self._get_full_by_step_id_async(step_id, actor)
async def _get_full_by_step_id_async(
self,
step_id: str,
actor: User,
) -> ProviderTrace | None:
"""Read from provider_traces table."""
async with db_registry.async_session() as session:
provider_trace_model = await ProviderTraceModel.read_async(
db_session=session,
step_id=step_id,
actor=actor,
)
return provider_trace_model.to_pydantic() if provider_trace_model else None
async def _get_metadata_by_step_id_async(
self,
step_id: str,
actor: User,
) -> ProviderTraceMetadata | None:
"""Read from provider_trace_metadata table."""
async with db_registry.async_session() as session:
metadata_model = await ProviderTraceMetadataModel.read_async(
db_session=session,
step_id=step_id,
actor=actor,
)
return metadata_model.to_pydantic() if metadata_model else None