* feat: add non-streaming option for conversation messages - Add ConversationMessageRequest with stream=True default (backwards compatible) - stream=true (default): SSE streaming via StreamingService - stream=false: JSON response via AgentLoop.load().step() 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * chore: regenerate API schema for ConversationMessageRequest * feat: add direct ClickHouse storage for raw LLM traces Adds ability to store raw LLM request/response payloads directly in ClickHouse, bypassing OTEL span attribute size limits. This enables debugging and analytics on large LLM payloads (>10MB system prompts, large tool schemas, etc.). New files: - letta/schemas/llm_raw_trace.py: Pydantic schema with ClickHouse row helper - letta/services/llm_raw_trace_writer.py: Async batching writer (fire-and-forget) - letta/services/llm_raw_trace_reader.py: Reader with query methods - scripts/sql/clickhouse/llm_raw_traces.ddl: Production table DDL - scripts/sql/clickhouse/llm_raw_traces_local.ddl: Local dev DDL - apps/core/clickhouse-init.sql: Local dev initialization Modified: - letta/settings.py: Added 4 settings (store_llm_raw_traces, ttl, batch_size, flush_interval) - letta/llm_api/llm_client_base.py: Integration into request_async_with_telemetry - compose.yaml: Added ClickHouse service for local dev - justfile: Added clickhouse, clickhouse-cli, clickhouse-traces commands Feature disabled by default (LETTA_STORE_LLM_RAW_TRACES=false). Uses ZSTD(3) compression for 10-30x reduction on JSON payloads. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: address code review feedback for LLM raw traces Fixes based on code review feedback: 1. Fix ClickHouse endpoint parsing - default to secure=False for raw host:port inputs (was defaulting to HTTPS which breaks local dev) 2. Make raw trace writes truly fire-and-forget - use asyncio.create_task() instead of awaiting, so JSON serialization doesn't block request path 3. Add bounded queue (maxsize=10000) - prevents unbounded memory growth under load. Drops traces with warning if queue is full. 4. Fix deprecated asyncio usage - get_running_loop() instead of get_event_loop() 5. Add org_id fallback - use _telemetry_org_id if actor doesn't have it 6. Remove unused imports - json import in reader 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: add missing asyncio import and simplify JSON serialization - Add missing 'import asyncio' that was causing 'name asyncio is not defined' error - Remove unnecessary clean_double_escapes() function - the JSON is stored correctly, the clickhouse-client CLI was just adding extra escaping when displaying - Update just clickhouse-trace to use Python client for correct JSON output 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * test: add clickhouse raw trace integration test * test: simplify clickhouse trace assertions * refactor: centralize usage parsing and stream error traces Use per-client usage helpers for raw trace extraction and ensure streaming errors log requests with error metadata. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * test: exercise provider usage parsing live Make live OpenAI/Anthropic/Gemini requests with credential gating and validate Anthropic cache usage mapping when present. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * test: fix usage parsing tests to pass - Use GoogleAIClient with GEMINI_API_KEY instead of GoogleVertexClient - Update model to gemini-2.0-flash (1.5-flash deprecated in v1beta) - Add tools=[] for Gemini/Anthropic build_request_data 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: extract_usage_statistics returns LettaUsageStatistics Standardize on LettaUsageStatistics as the canonical usage format returned by client helpers. Inline UsageStatistics construction for ChatCompletionResponse where needed. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * feat: add is_byok and llm_config_json columns to ClickHouse traces Extend llm_raw_traces table with: - is_byok (UInt8): Track BYOK vs base provider usage for billing analytics - llm_config_json (String, ZSTD): Store full LLM config for debugging and analysis This enables queries like: - BYOK usage breakdown by provider/model - Config parameter analysis (temperature, max_tokens, etc.) - Debugging specific request configurations * feat: add tests for error traces, llm_config_json, and cache tokens - Update llm_raw_trace_reader.py to query new columns (is_byok, cached_input_tokens, cache_write_tokens, reasoning_tokens, llm_config_json) - Add test_error_trace_stored_in_clickhouse to verify error fields - Add test_cache_tokens_stored_for_anthropic to verify cache token storage - Update existing tests to verify llm_config_json is stored correctly - Make llm_config required in log_provider_trace_async() - Simplify provider extraction to use provider_name directly 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * ci: add ClickHouse integration tests to CI pipeline - Add use-clickhouse option to reusable-test-workflow.yml - Add ClickHouse service container with otel database - Add schema initialization step using clickhouse-init.sql - Add ClickHouse env vars (CLICKHOUSE_ENDPOINT, etc.) - Add separate clickhouse-integration-tests job running integration_test_clickhouse_llm_raw_traces.py 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: simplify provider and org_id extraction in raw trace writer - Use model_endpoint_type.value for provider (not provider_name) - Simplify org_id to just self.actor.organization_id (actor is always pydantic) 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: simplify LLMRawTraceWriter with _enabled flag - Check ClickHouse env vars once at init, set _enabled flag - Early return in write_async/flush_async if not enabled - Remove ValueError raises (never used) - Simplify _get_client (no validation needed since already checked) 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: add LLMRawTraceWriter shutdown to FastAPI lifespan Properly flush pending traces on graceful shutdown via lifespan instead of relying only on atexit handler. 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * feat: add agent_tags column to ClickHouse traces Store agent tags as Array(String) for filtering/analytics by tag. 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * cleanup * fix(ci): fix ClickHouse schema initialization in CI - Create database separately before loading SQL file - Remove CREATE DATABASE from SQL file (handled in CI step) - Add verification step to confirm table was created - Use -sf flag for curl to fail on HTTP errors 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: simplify LLM trace writer with ClickHouse async_insert - Use ClickHouse async_insert for server-side batching instead of manual queue/flush loop - Sync cloud DDL schema with clickhouse-init.sql (add missing columns) - Remove redundant llm_raw_traces_local.ddl - Remove unused batch_size/flush_interval settings - Update tests for simplified writer Key changes: - async_insert=1, wait_for_async_insert=1 for reliable server-side batching - Simple per-trace retry with exponential backoff (max 3 retries) - ~150 lines removed from writer 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: consolidate ClickHouse direct writes into TelemetryManager backend - Add clickhouse_direct backend to provider_trace_backends - Remove duplicate ClickHouse write logic from llm_client_base.py - Configure via LETTA_TELEMETRY_PROVIDER_TRACE_BACKEND=postgres,clickhouse_direct The clickhouse_direct backend: - Converts ProviderTrace to LLMRawTrace - Extracts usage stats from response JSON - Writes via LLMRawTraceWriter with async_insert 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: address PR review comments and fix llm_config bug Review comment fixes: - Rename clickhouse_direct -> clickhouse_analytics (clearer purpose) - Remove ClickHouse from OSS compose.yaml, create separate compose.clickhouse.yaml - Delete redundant scripts/test_llm_raw_traces.py (use pytest tests) - Remove unused llm_raw_traces_ttl_days setting (TTL handled in DDL) - Fix socket description leak in telemetry_manager docstring - Add cloud-only comment to clickhouse-init.sql - Update justfile to use separate compose file Bug fix: - Fix llm_config not being passed to ProviderTrace in telemetry - Now correctly populates provider, model, is_byok for all LLM calls - Affects both request_async_with_telemetry and log_provider_trace_async DDL optimizations: - Add secondary indexes (bloom_filter for agent_id, model, step_id) - Add minmax indexes for is_byok, is_error - Change model and error_type to LowCardinality for faster GROUP BY 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: rename llm_raw_traces -> llm_traces Address review feedback that "raw" is misleading since we denormalize fields. Renames: - Table: llm_raw_traces -> llm_traces - Schema: LLMRawTrace -> LLMTrace - Files: llm_raw_trace_{reader,writer}.py -> llm_trace_{reader,writer}.py - Setting: store_llm_raw_traces -> store_llm_traces 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: update workflow references to llm_traces Missed renaming table name in CI workflow files. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: update clickhouse_direct -> clickhouse_analytics in docstring 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * chore: remove inaccurate OTEL size limit comments The 4MB limit is our own truncation logic, not an OTEL protocol limit. The real benefit is denormalized columns for analytics queries. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * chore: remove local ClickHouse dev setup (cloud-only feature) - Delete clickhouse-init.sql and compose.clickhouse.yaml - Remove local clickhouse just commands - Update CI to use cloud DDL with MergeTree for testing clickhouse_analytics is a cloud-only feature. For local dev, use postgres backend. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: restore compose.yaml to match main 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * refactor: merge clickhouse_analytics into clickhouse backend Per review feedback - having two separate backends was confusing. Now the clickhouse backend: - Writes to llm_traces table (denormalized for cost analytics) - Reads from OTEL traces table (will cut over to llm_traces later) Config: LETTA_TELEMETRY_PROVIDER_TRACE_BACKEND=postgres,clickhouse 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: correct path to DDL file in CI workflow 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * chore: add provider index to DDL for faster filtering 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: configure telemetry backend in clickhouse tests Tests need to set telemetry_settings.provider_trace_backends to include 'clickhouse', otherwise traces are routed to default postgres backend. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: set provider_trace_backend field, not property provider_trace_backends is a computed property, need to set the underlying provider_trace_backend string field instead. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: error trace test and error_type extraction - Add TelemetryManager to error trace test so traces get written - Fix error_type extraction to check top-level before nested error dict 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: use provider_trace.id for trace correlation across backends - Pass provider_trace.id to LLMTrace instead of auto-generating - Log warning if ID is missing (shouldn't happen, helps debug) - Fallback to new UUID only if not set 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: trace ID correlation and concurrency issues - Strip "provider_trace-" prefix from ID for UUID storage in ClickHouse - Add asyncio.Lock to serialize writes (clickhouse_connect not thread-safe) - Fix Anthropic prompt_tokens to include cached tokens for cost analytics - Log warning if provider_trace.id is missing 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> --------- Co-authored-by: Letta <noreply@letta.com> Co-authored-by: Caren Thomas <carenthomas@gmail.com>
168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
"""Schema for LLM request/response traces stored in ClickHouse for analytics."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from pydantic import Field
|
|
|
|
from letta.helpers.datetime_helpers import get_utc_time
|
|
from letta.schemas.letta_base import LettaBase
|
|
|
|
|
|
class LLMTrace(LettaBase):
|
|
"""
|
|
LLM request/response trace for ClickHouse analytics.
|
|
|
|
Stores LLM request/response payloads with denormalized columns for
|
|
fast cost analytics queries (token usage by org/agent/model).
|
|
|
|
Attributes:
|
|
id (str): Unique trace identifier (UUID).
|
|
organization_id (str): The organization this trace belongs to.
|
|
project_id (str): The project this trace belongs to.
|
|
agent_id (str): ID of the agent that made the request.
|
|
run_id (str): ID of the run this trace is associated with.
|
|
step_id (str): ID of the step that generated this trace.
|
|
trace_id (str): OTEL trace ID for correlation.
|
|
|
|
call_type (str): Type of LLM call ('agent_step', 'summarization', 'embedding').
|
|
provider (str): LLM provider name ('openai', 'anthropic', etc.).
|
|
model (str): Model name/identifier used.
|
|
|
|
request_size_bytes (int): Size of request_json in bytes.
|
|
response_size_bytes (int): Size of response_json in bytes.
|
|
prompt_tokens (int): Number of prompt tokens used.
|
|
completion_tokens (int): Number of completion tokens generated.
|
|
total_tokens (int): Total tokens (prompt + completion).
|
|
latency_ms (int): Request latency in milliseconds.
|
|
|
|
is_error (bool): Whether the request resulted in an error.
|
|
error_type (str): Exception class name if error occurred.
|
|
error_message (str): Error message if error occurred.
|
|
|
|
request_json (str): Full request payload as JSON string.
|
|
response_json (str): Full response payload as JSON string.
|
|
|
|
created_at (datetime): Timestamp when the trace was created.
|
|
"""
|
|
|
|
__id_prefix__ = "llm_trace"
|
|
|
|
# Primary identifier (UUID portion of ProviderTrace.id, prefix stripped for ClickHouse)
|
|
id: str = Field(..., description="Trace UUID (strip 'provider_trace-' prefix to correlate)")
|
|
|
|
# Context identifiers
|
|
organization_id: str = Field(..., description="Organization this trace belongs to")
|
|
project_id: Optional[str] = Field(default=None, description="Project this trace belongs to")
|
|
agent_id: Optional[str] = Field(default=None, description="Agent that made the request")
|
|
agent_tags: list[str] = Field(default_factory=list, description="Tags associated with the agent")
|
|
run_id: Optional[str] = Field(default=None, description="Run this trace is associated with")
|
|
step_id: Optional[str] = Field(default=None, description="Step that generated this trace")
|
|
trace_id: Optional[str] = Field(default=None, description="OTEL trace ID for correlation")
|
|
|
|
# Request metadata (queryable)
|
|
call_type: str = Field(..., description="Type of LLM call: 'agent_step', 'summarization', 'embedding'")
|
|
provider: str = Field(..., description="LLM provider: 'openai', 'anthropic', 'google_ai', etc.")
|
|
model: str = Field(..., description="Model name/identifier")
|
|
is_byok: bool = Field(default=False, description="Whether this request used BYOK (Bring Your Own Key)")
|
|
|
|
# Size metrics
|
|
request_size_bytes: int = Field(default=0, description="Size of request_json in bytes")
|
|
response_size_bytes: int = Field(default=0, description="Size of response_json in bytes")
|
|
|
|
# Token usage
|
|
prompt_tokens: int = Field(default=0, description="Number of prompt tokens")
|
|
completion_tokens: int = Field(default=0, description="Number of completion tokens")
|
|
total_tokens: int = Field(default=0, description="Total tokens (prompt + completion)")
|
|
|
|
# Cache and reasoning tokens (from LettaUsageStatistics)
|
|
cached_input_tokens: Optional[int] = Field(default=None, description="Number of input tokens served from cache")
|
|
cache_write_tokens: Optional[int] = Field(default=None, description="Number of tokens written to cache (Anthropic)")
|
|
reasoning_tokens: Optional[int] = Field(default=None, description="Number of reasoning/thinking tokens generated")
|
|
|
|
# Latency
|
|
latency_ms: int = Field(default=0, description="Request latency in milliseconds")
|
|
|
|
# Error tracking
|
|
is_error: bool = Field(default=False, description="Whether the request resulted in an error")
|
|
error_type: Optional[str] = Field(default=None, description="Exception class name if error")
|
|
error_message: Optional[str] = Field(default=None, description="Error message if error")
|
|
|
|
# Raw payloads (JSON strings)
|
|
request_json: str = Field(..., description="Full request payload as JSON string")
|
|
response_json: str = Field(..., description="Full response payload as JSON string")
|
|
llm_config_json: str = Field(default="", description="LLM config as JSON string")
|
|
|
|
# Timestamp
|
|
created_at: datetime = Field(default_factory=get_utc_time, description="When the trace was created")
|
|
|
|
def to_clickhouse_row(self) -> tuple:
|
|
"""Convert to a tuple for ClickHouse insertion."""
|
|
return (
|
|
self.id,
|
|
self.organization_id,
|
|
self.project_id or "",
|
|
self.agent_id or "",
|
|
self.agent_tags,
|
|
self.run_id or "",
|
|
self.step_id or "",
|
|
self.trace_id or "",
|
|
self.call_type,
|
|
self.provider,
|
|
self.model,
|
|
1 if self.is_byok else 0,
|
|
self.request_size_bytes,
|
|
self.response_size_bytes,
|
|
self.prompt_tokens,
|
|
self.completion_tokens,
|
|
self.total_tokens,
|
|
self.cached_input_tokens,
|
|
self.cache_write_tokens,
|
|
self.reasoning_tokens,
|
|
self.latency_ms,
|
|
1 if self.is_error else 0,
|
|
self.error_type or "",
|
|
self.error_message or "",
|
|
self.request_json,
|
|
self.response_json,
|
|
self.llm_config_json,
|
|
self.created_at,
|
|
)
|
|
|
|
@classmethod
|
|
def clickhouse_columns(cls) -> list[str]:
|
|
"""Return column names for ClickHouse insertion."""
|
|
return [
|
|
"id",
|
|
"organization_id",
|
|
"project_id",
|
|
"agent_id",
|
|
"agent_tags",
|
|
"run_id",
|
|
"step_id",
|
|
"trace_id",
|
|
"call_type",
|
|
"provider",
|
|
"model",
|
|
"is_byok",
|
|
"request_size_bytes",
|
|
"response_size_bytes",
|
|
"prompt_tokens",
|
|
"completion_tokens",
|
|
"total_tokens",
|
|
"cached_input_tokens",
|
|
"cache_write_tokens",
|
|
"reasoning_tokens",
|
|
"latency_ms",
|
|
"is_error",
|
|
"error_type",
|
|
"error_message",
|
|
"request_json",
|
|
"response_json",
|
|
"llm_config_json",
|
|
"created_at",
|
|
]
|