docs / observability

Observability & KPIs

Every metric Fred exports to Prometheus, the port it's on, and exactly which labels survive — plus the handful of near-misses (audit events, dead metric names, generic process stats) that look like Prometheus data but aren't.

Grafana sees one stream of three

Fred separates three kinds of signal that used to be conflated under one "KPI" umbrella. Prometheus/Grafana is only ever Stream 1 — operational health. If you're here looking for per-user, per-team, or per-agent-instance breakdowns, they live elsewhere by design, not by omission.

StreamAnswersDestinationContains identity?
1 — Operational metricsIs the platform healthy?Prometheus → GrafanaNo — structurally excluded, see below
2 — Product analyticsHow is the platform used, by whom?OpenSearch, via Fred's own authorization-scoped APIYes, access-scoped per viewer
3 — Security & audit trailWho did what, when, with what outcome?Structured log line → the platform's log pipelineYes — that's its entire purpose
A tool call's start/completion is logged twice, for two different audiences: a Stream-3 audit-log line (agent.tool.invocation.started / .completed, with outcome and identity) and, separately, a Stream-1 metric (agent.tool_latency_ms, no identity). They share a name prefix but are not the same data — see the callout in Defined, but not on /metrics.

For per-user/per-team dashboards or an audit trail, see Access model for how that data is scoped and reached.

Source: fred docs docs/swift/platform/OBSERVABILITY-AND-AUDIT.md §2–§3

Where metrics are exposed

Metrics are not served on the API port. Each backend that emits Prometheus metrics runs a second, dedicated HTTP server just for scraping (prometheus_client.start_http_server), entirely separate from the service's own request routing.

ServiceExposes metricsNotes
control-plane-backendYesdedicated exporter, on by default (config-controlled)
knowledge-flow-backendYesdedicated exporter, on by default (config-controlled)
fred-agentsYesdedicated exporter, on by default (config-controlled)
knowledge-flow Temporal workerYesno HTTP API of its own — the exporter is all it serves
control-plane Temporal workerNono exporter of its own — and unlike the knowledge-flow worker, it currently emits no KPI events at all. agent.suspended_total / agent.suspension_cleared_total are emitted from the control-plane-backend API process instead (see row above), not the worker
The exporter is opt-out, on by defaultKpiPrometheusSinkConfig.enabled defaults to true in fred-core, so a deployment exports to Prometheus unless observability.kpi.prometheus.enabled: false is set explicitly. The checked-in dev configuration.yaml for control-plane-backend, knowledge-flow-backend, and fred-agents all set it to false — confirm what your own deployment's config does before assuming metrics are (or aren't) being exported.
Exact host and port are deployment-specific and intentionally not hardcoded here: a local dev stack (docker-compose) exposes them as plain container ports, while a real cluster deployment reaches them however its manifests wire scraping (a Kubernetes Service, a GMP PodMonitoring, etc.) — check your own deployment's service manifests for the actual values. The GCP/GKE reference deployment lives in the fred-deployment-factory repository if you need a concrete example to start from.

Source: fred-core kpi/kpi_factory.py, common/structures.py (KpiPrometheusSinkConfig) · per-service main.py/context.py exporter startup

The label allow-list

One flat allow-list, shared by every metric. Any dimension not on this list is silently dropped before the metric ever touches the Prometheus client — this is an explicit, structural guarantee, not operator discipline: a new dimension needs a deliberate code change to ever appear as a label.

tool_name, status, error_code, exception_type, http_status,
template_agent_id, model, model_name, finish_reason,
runtime_id, source_runtime_id, service, env, cluster,
route, method, actor_type, file_type, agent_step

Deliberately excluded: user_id, session_id, exchange_id (identity); team_id, agent_instance_id, agent_instance_name, agent_id — "usage by team/agent instance" is a Stream-2 product-analytics question with its own access-scoped answer, so it's deliberately not duplicated here as an unscoped, platform-wide-visible label; and trace_id, correlation_id, checkpoint_id, doc_uid, scope_id — unique per call, no aggregate value as a label, and a pivot vector back into raw logs from a wide-audience dashboard.

This means several metrics are emitted with far more dims than actually reach /metrics — the reference below lists only what survives, not what the call site passes in.

Source: fred-core kpi/prometheus_kpi_store.py (PROMETHEUS_ALLOWED_LABELS, _prometheus_label_dims)

Metric reference

Metric names use dots in code; Prometheus wire names sanitize every non-alphanumeric character to _ (so agent.tool_latency_msagent_tool_latency_ms on the wire, and a histogram adds the usual _bucket/_sum/_count suffixes). Table below uses the code-form name; apply the substitution when writing PromQL.

Agent & tool execution

MetricTypeSurviving labels
agent.tool_latency_mshistogramtool_name, template_agent_id; on failure also status, error_code, exception_type
agent.tool_failed_totalcountertool_name, template_agent_id, status, error_code, exception_type
agent.turn_completedhistogramtemplate_agent_id, runtime_id, model_name, finish_reason (team_id, agent_instance_id/_name are sent but dropped)
agent.turn_error_totalcountersame as above; only emitted when the turn errors
agent.turn_completed_quantity_input_tokens_total
agent.turn_completed_quantity_output_tokens_total
agent.turn_completed_quantity_tool_count_total
countersame labels as agent.turn_completed — auto-derived per-call from its quantities; this is where token counts actually live today, not any cost.* series
agent.suspended_total
agent.suspension_cleared_total
counternone informative — team_id, agent_instance_id, reason are all sent but all dropped

API & LLM calls

MetricTypeSurviving labels
api.request_latency_mshistogramroute, method, status, http_status, error_code, exception_type — emitted for every HTTP request to control-plane, knowledge-flow, and fred-agents alike
api.error_totalcountersame as above; only on non-2xx/3xx
error.totalcountergeneric error counter emitted alongside api.error_total, same labels
llm.call_latency_mshistogrammodel_name only — agent_id and operation are sent but dropped

Retrieval & ingestion

MetricTypeSurviving labels
rag.search_latency_mshistogramnone by default — backend, index are sent but dropped; status/error_code survive when set
rag.search_total
rag.search_error_total
rag.search_hits_total
rag.search_top_k_total
rag.search_empty_total
counterstatus
rag.search_hit_ratiogaugestatus
rag.rerank_latency_ms
rag.rerank_total
rag.rerank_docs_total
rag.rerank_top_r_total
timer/counterstatus, model
document.created_total
document.deleted_total
counterfile_type only — source_type, team_id are sent but dropped
ingestion.document_duration_mshistogramfile_type, statussource is sent but dropped
app.phase_latency_mshistogramagent_step only, and only from the ReAct/Graph runtime call sites that set it — phase is sent everywhere but always dropped; the knowledge-flow and vector-store call sites don't set agent_step at all, so those observations are effectively unlabeled
temporal.system.activity_queue_wait_mshistogramnone — phase, activity_type, task_queue, workflow_type, attempt are all sent but all dropped
temporal.system.activity_duration_mshistogramstatus, error_code, file_typephase, activity_type, task_queue, workflow_type, attempt, source_type, source_tag are sent but dropped
temporal.ingestion.documents_totalcounterstatus, error_code, file_type — same dims as temporal.system.activity_duration_ms above; the rest dropped
temporal.ingestion.workflows_totalcounterstatus only — task_queue, workflow_type, has_error, has_document_uid, has_filename, workflow_id_prefix are sent but dropped

Source: fred-runtime react/middleware/tool_observability.py, tracing_kpi.py, app/agent_app.py · fred-core kpi/http_middleware.py, kpi/kpi_writer.py, kpi/kpi_phase_metric.py · knowledge-flow-backend features/metadata/service.py, features/ingestion/ingestion_controller.py, features/scheduler/kpi_utils.py · vector_search_service.py · control-plane-backend agent_instances/suspension.py

Defined, but not on /metrics

These look like Prometheus metrics — same naming convention, same codebase — and aren't. Don't build a panel against them:

agent.tool.invocation.started / .completed are Stream-3 audit-log events, not metrics. They're written to the audit logger and never call into the KPI/Prometheus pipeline at all.
process.memory.* / process.db_pool.* KPI gauges are explicitly excluded from Prometheus by name-prefix check (they stay in the log/OpenSearch pipeline). What you'll find under a similar-looking process_* name on /metrics instead — process_resident_memory_bytes, process_cpu_seconds_total, process_open_fds, etc. — comes from prometheus_client's own generic process collector, and carries no DB-pool or Fred-specific data at all.
agent.step_total, llm.rate_limit_events_total, llm.request_latency_ms, and every cost.* field (cost.tokens_prompt, cost.tokens_completion, cost.tokens_total, cost.usd) are declared in code but have zero call sites today — nothing emits them. Token counts are live today, just under a different name: see agent.turn_completed_quantity_input_tokens_total / _output_tokens_total above.

Scrape config & PromQL

Minimal prometheus.yml shape — swap in your deployment's actual metrics host and port for each service (see the callout above; it's not the same value as the API port):

scrape_configs:
  - job_name: fred-control-plane
    static_configs:
      - targets: ["<control-plane-metrics-host:port>"]
  - job_name: fred-knowledge-flow
    static_configs:
      - targets: ["<knowledge-flow-metrics-host:port>"]
  - job_name: fred-agents
    static_configs:
      - targets: ["<fred-agents-metrics-host:port>"]
  - job_name: fred-knowledge-flow-worker
    static_configs:
      - targets: ["<knowledge-flow-worker-metrics-host:port>"]

Example queries, matched to the verified type/label pairs above:

# p95 tool latency by tool
histogram_quantile(0.95,
  sum(rate(agent_tool_latency_ms_bucket[5m])) by (le, tool_name))

# tool failure rate by tool
sum(rate(agent_tool_failed_total[5m])) by (tool_name)
  / sum(rate(agent_tool_latency_ms_count[5m])) by (tool_name)

# API error rate by route
sum(rate(api_error_total[5m])) by (route)
  / sum(rate(api_request_latency_ms_count[5m])) by (route)

# API p95 latency by route
histogram_quantile(0.95,
  sum(rate(api_request_latency_ms_bucket[5m])) by (le, route))

# LLM call latency p95 by model
histogram_quantile(0.95,
  sum(rate(llm_call_latency_ms_bucket[5m])) by (le, model_name))

# turn error rate by agent template
sum(rate(agent_turn_error_total[5m])) by (template_agent_id)
  / sum(rate(agent_turn_completed_count[5m])) by (template_agent_id)

Types confirmed from recording calls in prometheus_kpi_store.py: "timer"/"distribution" → Histogram (use histogram_quantile on _bucket), "counter" → Counter (use rate()), "gauge" → Gauge (query directly).