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
