Skip to content

symfonic.core.observability.metrics_execution

metrics_execution

Metadata-only execution tracing mixed into conversation metrics.

One channel. Stage events ride the ordinary kernel event stream, the same one Agent.stream yields, so what an operator reads here and what a consumer sees live are the same events in the same order -- not two feeds that can disagree about what a turn did. There used to be a second delivery path for internal traces; a reader had no way to tell whether the two agreed.

MetricsExecutionMixin

Capture and durably fan out kernel units without their payloads.

execution_snapshots

execution_snapshots(conversation_id: str) -> list[dict[str, object]]

Return safe execution metadata for one conversation.

Source code in src/symfonic/core/observability/metrics_execution.py
def execution_snapshots(self, conversation_id: str) -> list[dict[str, object]]:
    """Return safe execution metadata for one conversation."""
    return [
        {key: value for key, value in row.items() if key != "_persisted"}
        for row in self._execution_events
        if row["conversation_id"] == conversation_id
    ]

record_model_round

record_model_round(event: object, conversation_id: str, tenant_id: str) -> None

File one model round in the same log the stages are filed in.

Here rather than beside the conversation record, because there is one execution log and it should have one builder: two places appending rows to it is how the sequence counter and the column set drift apart without anything noticing.

Source code in src/symfonic/core/observability/metrics_execution.py
def record_model_round(
    self, event: object, conversation_id: str, tenant_id: str
) -> None:
    """File one model round in the same log the stages are filed in.

    Here rather than beside the conversation record, because there is one
    execution log and it should have one builder: two places appending
    rows to it is how the sequence counter and the column set drift apart
    without anything noticing.
    """
    run_id = str(getattr(event, "run_id", "") or "")
    sequence = self._execution_sequence[run_id]
    self._execution_sequence[run_id] += 1
    root_run_id, parent_run_id = self._current_lineage.get(run_id, (run_id, None))
    row = {
        "conversation_id": conversation_id,
        "tenant_id": tenant_id,
        "run_id": run_id,
        "root_run_id": root_run_id,
        "parent_run_id": parent_run_id,
        "kind": "model_call",
        "sequence": sequence,
        "stage_id": getattr(event, "node_name", "") or "react",
        "phase": "model",
        "capability": "model",
        "stage_reason_code": "",
        "outcome": getattr(event, "outcome", ""),
        # Not on the kernel stream: a model round is this log's own row.
        "event_index": -1,
        "tool_name": "",
        "tool_call_id": "",
        "duration_ms": getattr(event, "duration_ms", 0.0),
        # A model round counts nothing of its own; the column is named by
        # the insert, so the key is present and empty rather than absent.
        "counts": "",
        "created_at": datetime.now(UTC),
    }
    self._execution_events.append(row)
    self._pending_execution_events[run_id].append(row)