Files
cleveragents-core/features/llm_trace.feature
freemo 3f14cbbf7e
CI / lint (pull_request) Successful in 40s
CI / security (pull_request) Successful in 32s
CI / quality (pull_request) Successful in 42s
CI / typecheck (pull_request) Successful in 1m1s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 14s
CI / unit_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 4m34s
CI / docker (pull_request) Successful in 1m6s
CI / coverage (pull_request) Successful in 6m21s
CI / quality (push) Successful in 16s
CI / lint (push) Successful in 21s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / build (push) Successful in 39s
CI / unit_tests (push) Successful in 2m38s
CI / integration_tests (push) Successful in 3m2s
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 4m9s
CI / benchmark-publish (push) Successful in 14m42s
CI / benchmark-regression (pull_request) Successful in 26m29s
feat(observability): add LLMTrace model and operational metrics
Add LLMTrace Pydantic v2 domain model with all required fields (trace_id,
plan_id, decision_id, actor, provider, model, prompt_tokens, completion_tokens,
cost_usd, latency_ms, tool_calls, context_hash, streaming, retry_count, error).

Define 14 OperationalMetricKey values (PLAN_DURATION_MS, PLAN_TOTAL_COST_USD,
PLAN_DECISION_COUNT, ACTOR_INVOCATION_COUNT, ACTOR_LATENCY_MS,
TOOL_INVOCATION_COUNT, TOOL_ERROR_RATE, CONTEXT_BUILD_TIME_MS,
CONTEXT_TOKEN_COUNT, LLM_CALL_COUNT, LLM_TOTAL_TOKENS, LLM_TOTAL_COST_USD,
LLM_AVG_LATENCY_MS, SUBPLAN_COUNT) with MetricEntry model and MetricCollector.

Add llm_traces database table (LLMTraceModel) with LLMTraceRepository for
persistence. TraceService provides recording, querying, metric computation,
plan lifecycle hooks, and optional LangSmith forwarding when
LANGCHAIN_TRACING_V2=true.

Wired into DI container as trace_service. Includes 28 Behave BDD scenarios,
6 Robot Framework smoke tests, 3 ASV benchmark suites, and reference
documentation.

Fix pre-existing cli_core server_mode test flake by mocking
resolve_server_mode in test steps to avoid stale config file interference.

Closes #500
2026-03-03 17:04:17 -05:00

277 lines
11 KiB
Gherkin

Feature: LLM trace observability
As a platform operator
I want to record LLM call telemetry and compute operational metrics
So that I can analyse cost, latency, and usage patterns
Background:
Given a trace service with an in-memory repository
# --- model validation --------------------------------------------------
Scenario: Create a valid LLM trace
Given a valid LLM trace payload
When I create the LLM trace model
Then the trace should have all required fields populated
And the trace should be frozen
Scenario: Reject trace with invalid trace_id
When I create a trace with an invalid trace_id
Then a validation error should be raised
Scenario: Reject trace with negative prompt tokens
When I create a trace with negative prompt_tokens
Then a validation error should be raised
Scenario: Reject trace with negative cost
When I create a trace with negative cost_usd
Then a validation error should be raised
Scenario: Trace with optional fields omitted
Given a minimal LLM trace payload
When I create the LLM trace model
Then the trace decision_id should be None
And the trace tool_calls should be empty
And the trace context_hash should be None
And the trace streaming should be False
And the trace retry_count should be 0
And the trace error should be None
# --- query model -------------------------------------------------------
Scenario: Create a valid trace query
When I create a trace query with plan_id filter
Then the query should have default limit 100
And the query should have default offset 0
Scenario: Reject query with limit below 1
When I create a trace query with limit 0
Then a validation error should be raised
# --- metric keys -------------------------------------------------------
Scenario: All 14 operational metric keys are defined
Then the OperationalMetricKey enum should have exactly 14 members
Scenario: Metric keys have correct string values
Then PLAN_DURATION_MS should equal "plan_duration_ms"
And LLM_AVG_LATENCY_MS should equal "llm_avg_latency_ms"
And SUBPLAN_COUNT should equal "subplan_count"
# --- metric entry ------------------------------------------------------
Scenario: Create a metric entry via collector
Given a plan_id for metrics
When I record a PLAN_DURATION_MS metric with value 1500.0
Then the metric entry key should be PLAN_DURATION_MS
And the metric entry value should be 1500.0
And the metric entry should have a timestamp
Scenario: Metric entry with labels
Given a plan_id for metrics
When I record an ACTOR_LATENCY_MS metric with actor label
Then the metric entry labels should contain actor
# --- trace recording ---------------------------------------------------
Scenario: Record and retrieve a trace
Given a valid LLM trace
When I record the trace via the service
Then I should be able to retrieve it by trace_id
And I should be able to list it by plan_id
Scenario: Record multiple traces for a plan
Given three valid LLM traces for the same plan
When I record all traces via the service
Then listing by plan_id should return 3 traces
Scenario: List traces by decision
Given two traces with the same decision_id
When I record all traces via the service
Then listing by decision_id should return 2 traces
Scenario: Get non-existent trace returns None
When I query a non-existent trace_id
Then the trace query result should be None
# --- metric computation ------------------------------------------------
Scenario: Compute metrics for a plan with traces
Given three valid LLM traces for the same plan
When I record all traces via the service
And I compute metrics for the plan
Then I should get LLM_CALL_COUNT equal to 3
And I should get LLM_TOTAL_TOKENS greater than 0
And I should get LLM_TOTAL_COST_USD greater than 0
And I should get LLM_AVG_LATENCY_MS greater than 0
Scenario: Compute metrics for a plan with no traces
When I compute metrics for a plan with no traces
Then the metrics list should be empty
# --- lifecycle hooks ---------------------------------------------------
Scenario: Plan start hook returns decision count metric
Given a plan_id for metrics
When I call on_plan_start
Then I should get a PLAN_DECISION_COUNT metric with value 0
Scenario: Actor invocation hook returns metrics
Given a plan_id for metrics
When I call on_actor_invocation with actor "planner" and latency 250.0
Then I should get ACTOR_INVOCATION_COUNT and ACTOR_LATENCY_MS metrics
Scenario: Tool execution hook returns metrics
Given a plan_id for metrics
When I call on_tool_execution with tool "file_write" and no error
Then I should get a TOOL_INVOCATION_COUNT metric
And I should not get a TOOL_ERROR_RATE metric
Scenario: Tool execution hook with error returns error rate
Given a plan_id for metrics
When I call on_tool_execution with tool "file_write" and error
Then I should get both TOOL_INVOCATION_COUNT and TOOL_ERROR_RATE metrics
# --- LangSmith forwarding ----------------------------------------------
Scenario: LangSmith forwarding when env var is set
Given LANGCHAIN_TRACING_V2 is set to "true"
And a valid LLM trace
When I record the trace via the service
Then the LangSmith forwarder should have been called
Scenario: LangSmith forwarding skipped when env var is not set
Given LANGCHAIN_TRACING_V2 is not set
And a valid LLM trace
When I record the trace via the service
Then the LangSmith forwarder should not have been called
Scenario: LangSmith forwarding failure is logged not raised
Given LANGCHAIN_TRACING_V2 is set to "true"
And a valid LLM trace
And the LangSmith forwarder will raise an exception
When I record the trace via the service
Then no exception should propagate
And the trace should still be persisted
# --- convenience collectors --------------------------------------------
Scenario: MetricCollector plan_duration convenience
Given a plan_id for metrics
When I call MetricCollector.plan_duration with 2000.0
Then the metric key should be PLAN_DURATION_MS
And the metric value should be 2000.0
Scenario: MetricCollector plan_cost convenience
Given a plan_id for metrics
When I call MetricCollector.plan_cost with 0.05
Then the metric key should be PLAN_TOTAL_COST_USD
And the metric value should be 0.05
Scenario: MetricCollector llm_call_count convenience
Given a plan_id for metrics
When I call MetricCollector.llm_call_count with 10
Then the metric key should be LLM_CALL_COUNT
And the metric value should be 10.0
# --- database model ----------------------------------------------------
Scenario: LLMTraceModel table exists
Then the LLMTraceModel should have tablename "llm_traces"
# --- repository integration (SQLAlchemy in-memory) ---------------------
Scenario: Repository rejects None session_factory
When I create a repository with None session_factory
Then a ValueError should be raised for the repository
Scenario: Repository save and get roundtrip
Given a SQLAlchemy in-memory repository
And a valid LLM trace
When I save the trace via the repository
Then I should be able to get it by trace_id from the repository
Scenario: Repository save trace with tool calls
Given a SQLAlchemy in-memory repository
And a valid LLM trace with tool calls
When I save the trace via the repository
Then I should be able to get it by trace_id from the repository
And the retrieved trace should have tool calls
Scenario: Repository get non-existent trace returns None
Given a SQLAlchemy in-memory repository
When I get a non-existent trace from the repository
Then the repository result should be None
Scenario: Repository list by plan returns matching traces
Given a SQLAlchemy in-memory repository
And two traces for the same plan in the repository
When I save both traces via the repository
Then listing by plan from the repository should return 2 traces
Scenario: Repository list by plan returns empty for unknown plan
Given a SQLAlchemy in-memory repository
When I list traces for a non-existent plan from the repository
Then the repository list should be empty
Scenario: Repository list by decision returns matching traces
Given a SQLAlchemy in-memory repository
And two traces for the same decision in the repository
When I save both traces via the repository
Then listing by decision from the repository should return 2 traces
Scenario: Repository list by decision returns empty for unknown decision
Given a SQLAlchemy in-memory repository
When I list traces for a non-existent decision from the repository
Then the repository list should be empty
Scenario: Repository save raises DatabaseError on failure
Given a SQLAlchemy repository with a broken session
And a valid LLM trace
When I save the trace via the broken repository
Then a DatabaseError should be raised with message containing "save"
Scenario: Repository get raises DatabaseError on failure
Given a SQLAlchemy repository with a broken session
When I get a trace from the broken repository
Then a DatabaseError should be raised with message containing "get"
Scenario: Repository list_by_plan raises DatabaseError on failure
Given a SQLAlchemy repository with a broken session
When I list by plan from the broken repository
Then a DatabaseError should be raised with message containing "list"
Scenario: Repository list_by_decision raises DatabaseError on failure
Given a SQLAlchemy repository with a broken session
When I list by decision from the broken repository
Then a DatabaseError should be raised with message containing "list"
# --- LangSmith internal forwarder detail --------------------------------
Scenario: LangSmith forwarder sends trace data via SDK
Given a valid LLM trace
And the langsmith SDK is mocked as available
When I call the internal LangSmith forwarder
Then the langsmith Client create_run should have been called
Scenario: LangSmith forwarder includes error field when present
Given a valid LLM trace with error "connection timeout"
And the langsmith SDK is mocked as available
When I call the internal LangSmith forwarder
Then the langsmith Client create_run should have been called with error
Scenario: LangSmith forwarder skips when SDK not installed
Given a valid LLM trace
And the langsmith SDK is not importable
When I call the internal LangSmith forwarder
Then no langsmith Client should have been created
Scenario: LangSmith enabled check is case insensitive
Given LANGCHAIN_TRACING_V2 is set to "TRUE"
Then the langsmith_enabled check should return True
Scenario: Repository save trace with no tool calls stores null
Given a SQLAlchemy in-memory repository
And a valid LLM trace
When I save the trace via the repository
Then the raw tool_calls_json in the database should be null