Skip to content

Observability

The tatara operator ships observability first-class: a ServiceMonitor for Prometheus scraping, a Grafana dashboard as a sidecar-discovered ConfigMap, and a PrometheusRule for loop-failure alerting - all enabled by default, all cluster-agnostic. A companion repository, tatara-observability, holds the full per-component Grafana alert rule set managed as code and applied by CI.

Signal flow

graph LR
    A["operator :9090\n/metrics"] -->|"ServiceMonitor\n30 s interval"| P[Prometheus]
    W["wrapper pods\nephemeral"] -->|"POST /internal/metrics/push"| PR["operator\npush receiver"]
    PR --> P
    P --> D["Grafana\nTatara Loop dashboard"]
    P --> R["PrometheusRule\ntatara-loop group"]
    OA["tatara-observability\nalerts/*.yaml"] -->|"PR = plan\nmerge = apply"| GA["Grafana\nTatara folder rules"]
    R -->|"system=tatara fires"| WH["operator\n/webhooks/tatara/grafana"]
    GA -->|"system=tatara fires"| WH
    WH --> IT["incident Task"]
    IT --> IB["brainstorm issue\n(emergency cycle)"]

1. Metrics catalog

Operator metrics

The operator exposes /metrics on the metricsAddr port (default :9090, exposed as the metrics port on the operator Service). This port is not ingress-routed. It is only reachable in-cluster via the Service; the optional Ingress covers only the main HTTP port (:8080).

Core counters

Metric Labels Description
operator_reconcile_total kind, result Reconcile attempts per resource kind. kind is the CR type (Project, Repository, Task); result is ok or error. The primary health denominator for the control loop.
operator_task_terminal_total kind, phase, reason Every Task terminal transition, incremented once at the terminate() chokepoint. phase is only ever Succeeded or Failed - the two Task.Status.Phase terminal values that terminate() passes. (Done, Stopped, and Parked are Status.LifecycleState values, not Phase, and no code path feeds them to this counter.) reason carries the failure class (e.g. PodLost, TurnTimeout, PlanningStalled, BootCrashLoop). This is the uniform loop success / failure denominator - do NOT use operator_reconcile_total as a proxy for task outcomes.
operator_task_tokens_total project, repo, kind, issue, model, type Cumulative agent token spend. type is input, output, cache_read, or cache_creation; model carries the resolved Claude model ID (this is what the dashboard's per-model breakdown keys on). Provides the global / project / repo / issue / model cost breakdown visible in the dashboard.
tatara_scan_tasks_created_total - Tasks created by the hourly scan. Zero for 3+ hours is the scan-stall deadman signal.
tatara_scan_items_total - Items (issues / MRs) evaluated per scan. Used alongside tatara_scan_tasks_created_total for the loop-liveness check.
tatara_lifecycle_giveup_total reason Issue-lifecycle Tasks abandoned by the loop.
operator_turn_submit_total result Turn-submit attempts from the operator to wrapper pods.
operator_turn_timeout_total - Agent turns that stalled past the inactivity deadline.
operator_agent_boot_crash_total outcome, reason Wrapper pod boot budget exhaustions. outcome=failed means the Task was aborted.
operator_agent_unreachable_termination_total - Tasks killed because the wrapper stayed unreachable past the boot deadline.
operator_ingest_job_total result, mode Repo ingest job completions. mode=full distinguishes scheduled / self-heal full re-ingests from incremental runs.
operator_scm_writes_total kind, result SCM write-backs (comments, labels, approvals, PRs). kind=write / kind=read; result=ok or result=error.
operator_scm_request_errors_by_status_total verb, status SCM errors broken down by HTTP verb and status code. Use this to distinguish token failures (401/403), rate limits (429), or network errors from a write error alert.
operator_webhook_events_total provider, kind, action, result Inbound SCM webhook events, keyed by provider (github/gitlab), event kind (push/issue/mr/other), action, and result. There is no ok result: an accepted delivery is accepted or (when it spawns work) task_created. Other result values: ignored, duplicate, no_repo, unknown_project, provider_mismatch, too_large, bad_signature, bad_request, reactivated, error. A reporter-allowlist intake drop is counted as ignored (not dropped - there is no dropped value).
operator_writeback_outcome_total result Writeback disposition per Task: ok, skip_4xx, skip_4xx_capped (permanent give-up).
operator_writeback_skip_4xx_total status, reason Per-skip detail when a writeback is skipped on a 4xx.
operator_reap_delete_error_total - Failures in the orphan-pod reaper.

Gauges

Metric Labels Description
operator_tasks_inflight - Tasks currently running (Running + WritebackPending). The concurrency-cap deadman threshold is configurable (prometheusRule.tasksInflightThreshold, default 8).
operator_queue_depth project, class QueuedEvents waiting to be admitted per project and priority class.
operator_memory_stacks phase Per-project memory stack count by phase (Ready, Provisioning, Failed, etc.). A Failed value above zero is a critical alert.
operator_lightrag_documents project, status Per-project LightRAG corpus size, polled during gauge recompute from /documents/status_counts.

Histograms

Metric Labels Description
operator_turn_submit_duration_seconds kind SubmitTurn latency from the operator to the wrapper pod HTTP API. Buckets ExponentialBuckets(0.05, 2, 10) top out at 25.6 s. p95 above prometheusRule.turnSubmitP95LatencyThreshold (default 5 s, not 30) fires TataraTurnSubmitLatencyHigh.
operator_turn_duration_seconds - Wall-clock duration of a completed agent turn.
operator_agent_http_duration_seconds method Wall-clock duration of operator-to-wrapper HTTP calls, per HTTP method.
operator_ingest_job_duration_seconds - Wall-clock duration of a completed repo-ingest Job.

There is no operator_reconcile_duration_seconds histogram. operator_agent_http_total (labels method, outcome; outcome includes ok, unreachable, timeout, transport_error) is a counter, not a histogram - the wrapper HTTP call latency lives in operator_agent_http_duration_seconds above.

Push-receiver / wrapper series

Ephemeral agent pods (wrapper, ingest) cannot be scraped directly - they have no stable /metrics endpoint and a per-run lifetime measured in minutes. Instead they push their metrics to the operator's push receiver at the end of each run. The operator re-exposes them through its own /metrics.

The allowlist is controlled by the pushMetricsAllowedPrefixes Helm value. The chart default (kept in sync with the code fallback DefaultAllowedPrefixes) is:

wrapper_,agent_,ccw_,tatara_wrapper_,ingest_,analyzer_,semantic_,scip_,llm_

ccw_ and tatara_wrapper_ (the wrapper families) and ingest_,analyzer_,semantic_,scip_,llm_ (the repo-ingester families) are all admitted out of the box, so the ccw_* series below are present with no allowlist edit. Series whose name matches no prefix are dropped and counted in operator_push_series_dropped_total{reason="reserved_name"}.

Metric Labels Description
operator_push_receive_total result Push receive outcomes: accepted, rejected, too_large, deleted, empty. rejected pushes mean wrapper metrics are being lost.
operator_push_series_dropped_total reason Metric name families dropped by the allowlist (reason=reserved_name), or on a type_conflict / build_error.
ccw_turns_total result Claude turns per wrapper run.
ccw_turn_tokens_total - Tokens consumed per wrapper run.
ccw_commit_push_total result Git commit + push attempts from the wrapper.
ccw_http_requests_total status_code Operator callback HTTP responses as seen by the wrapper.
ccw_http_panics_total - Recovered panics in the wrapper HTTP handler.
ccw_lifecycle_hook_total hook, result Lifecycle hook executions (preClone, postClone, etc.).

Push series are unreliable for rate-based alerts

Because pushed series TTL-evict and reset their run ID per pod lifecycle, rate(), increase(), and absent() over pushed series behave unpredictably across pod boundaries. The shipped alert rules deliberately key only on the operator's own continuously-present series, never on ccw_* or tatara_wrapper_* for threshold alerting.


2. Dashboard: Tatara Loop

The chart ships the "Tatara Loop" dashboard as a ConfigMap labelled grafana_dashboard: "1" for automatic sidecar discovery. When the Grafana sidecar (kiwigrid/k8s-sidecar) is deployed with the matching label selector it loads the dashboard without manual import.

# values.yaml knobs
dashboard:
  enabled: true          # default: true
  folder: "tatara"       # Grafana folder for placement
  additionalLabels: {}   # match non-default sidecar label selectors

The dashboard hardcodes no datasource UID. A $datasource template variable lets you select any Prometheus instance in the cluster. Two additional template variables, $project and $repo, filter every panel to a specific project or repository.

Panel layout

Row Panels
Loop golden signals Lifecycle state by state (timeseries), Reconcile rate by result (timeseries), Tasks in-flight by kind (timeseries), Turn duration heatmap
Failures and scan cadence Failure rates (timeseries), Ingest success ratio (timeseries), Scan cadence per hour (timeseries)
Task outcomes Terminal transitions by phase/reason per hour (timeseries), Task success-rate SLO 6h window (stat)
Token usage Total tokens by type (stat), Token rate by project 1h (timeseries), Token rate by repo 1h (timeseries), Top issues by tokens (table), Input vs output tokens (pie chart)
Token budget (issue #189) Token budget used ratio (timeseries), Admission blocked (token budget) (timeseries)
Memory corpus LightRAG documents by project/status (timeseries), Memory stacks by phase (pie chart)

3. Alerts shipped

Tatara ships alert rules from two sources with different scopes and update paths.

Chart PrometheusRule (tatara-loop group)

A PrometheusRule CR in the tatara-loop group is rendered by the chart and enabled by default (prometheusRule.enabled: true). These rules cover only the operator's own continuously-present series and are intended as a minimal, always-correct baseline that installs automatically with the operator.

# values.yaml knobs
prometheusRule:
  enabled: true
  severityLabel: "warning"       # stamped on every alert
  tasksInflightThreshold: 8      # cap for the TataraTasksInflightPinned deadman
  additionalLabels: {}           # cluster-specific: match Prometheus ruleSelector

ruleSelector

The chart bakes no selector labels. Add the label your cluster's Prometheus ruleSelector matches via prometheusRule.additionalLabels in your helmfile values.

Class A: deadman / liveness

These alerts catch silent stalls - conditions that emit no error event but indicate the loop has stopped producing work.

Alert Expression summary Fires after Description
TataraOperatorDown up{job=~".*tatara-operator.*"} == 0 5 m Scrape target absent; no counter-based alerts below can fire while this is true.
TataraLoopWedged increase(operator_reconcile_total[15m]) == 0 15 m Workqueue wedged with pod alive - zero reconciles.
TataraLoopStalled increase(tatara_scan_tasks_created_total[3h]) + increase(tatara_scan_items_total[3h]) == 0 30 m Hourly scan producing no work at all. The worst failure class - zero errors but zero output.
TataraMemoryStackFailed operator_memory_stacks{phase="Failed"} > 0 15 m At least one project memory stack failed; agents for that project cannot read or write recall.
TataraTasksInflightPinned operator_tasks_inflight >= tasksInflightThreshold 2 h Concurrency cap saturated with nothing draining; deadlock signature.

Class B: active failures

Alert Signal Description
TataraReconcileErrors rate(operator_reconcile_total{result="error"}[15m]) > 0 for 15 m Reconciliation failing for at least one kind.
TataraTaskFailures increase(operator_task_terminal_total{phase="Failed"}[30m]) > 0 Tasks reaching terminal Failed. The reason label carries the failure class.
TataraTurnTimeouts increase(operator_turn_timeout_total[1h]) > 0 Agent turns stalled past the inactivity deadline.
TataraAgentBootCrashLoop increase(operator_agent_boot_crash_total{outcome="failed"}[30m]) > 0 Wrapper pods exhausted the boot-respawn budget without /readyz coming up.
TataraAgentUnreachable increase(operator_agent_unreachable_termination_total[15m]) > 0 Tasks killed because wrapper stayed unreachable past the boot deadline.
TataraIngestJobFailing operator_repository_ingest_failing > 0 for 15 m A repo is stuck in a failing ingest state (status Phase=Failed or unresolved consecutive failures); recall corpus going stale. Keys on the live per-repo gauge, so it clears the moment a re-ingest succeeds and does not linger after a transient incremental burst self-heals via the full-ingest fallback.
TataraIngestStale (time() - operator_repository_last_ingest_timestamp_seconds) > ingestStaleAfterSeconds for 30 m A repo has not been re-ingested within the staleness budget (prometheusRule.ingestStaleAfterSeconds, default 93600 s / 26 h) even though no ingest Job is actively failing - e.g. the re-ingest cron is not landing.
TataraLifecycleGiveups increase(tatara_lifecycle_giveup_total[1h]) > 0 issueLifecycle Tasks gave up; the reason label identifies why.
TataraSCMWriteErrors increase(operator_scm_writes_total{kind="write",result="error"}[15m]) > 0 SCM write-backs (comments, labels, approvals, PRs) failing. Break down operator_scm_request_errors_by_status_total by (verb, status) to distinguish token, rate-limit, and network failures.
TataraSCMWriteFailureRatioHigh ratio > 30%, >= 3 writes attempted, for 15 m Elevated fraction of SCM writes failing. Guards against alert spam during low-volume bursts.
TataraWritebackGaveUp4xx increase(operator_writeback_outcome_total{result="skip_4xx_capped"}[1h]) > 0 A Task permanently abandoned writeback on repeated 4xx; no PR was opened. Investigate via operator_writeback_skip_4xx_total by (status, reason).
TataraPushMetricsRejected increase(operator_push_receive_total{result="rejected"}[15m]) > 0 Ephemeral pod metric pushes rejected at the receiver; wrapper/ingest metrics are being lost.
TataraPushMetricsDropped increase(operator_push_series_dropped_total{reason="reserved_name"}[15m]) > 0 A short-lived pod pushed a metric family whose name matches no prefix in pushMetricsAllowedPrefixes (allowlist drift). Add the dropped prefix (logged at WARN by the receiver) to the value.
TataraTokenBudgetBlocked increase(operator_admission_blocked_total{reason="token_budget"}[30m]) > 0 for 15 m The token-budget admission gate is holding work for a project (the project and pool class are in the series labels). Expected when a window is genuinely exhausted; investigate if unexpected. Only fires where a tokenBudget block is configured - see Tuning.
TataraReaperDeleteErrors increase(operator_reap_delete_error_total[1h]) > 0 Orphan-pod reaper cannot delete pods; zombie pod leak.

Class C: latency / saturation

Alert Signal Description
TataraTurnSubmitLatencyHigh histogram_quantile(0.95, operator_turn_submit_duration_seconds) > turnSubmitP95LatencyThreshold for 15 m SubmitTurn p95 latency above the threshold (prometheusRule.turnSubmitP95LatencyThreshold, default 5 s). The quantile is gated on sum(rate(..._count[15m])) > 0 so idle windows yield no series (never a NaN false-fire). The histogram tops out at 25.6 s, so the threshold must be kept under that ceiling.

tatara-observability alert rules

The tatara-observability repository holds a richer, per-component rule set managed as code and applied via Terraform CI. These rules live in the Grafana Tatara folder and complement the chart PrometheusRule with additional coverage per component, workload-generic pod-health rules, Loki-based log alerts, and finer-grained thresholds.

Each alerts/tatara-<component>.yaml file defines one Grafana rule group:

interval_seconds: 60
default_no_data_state: "OK"    # absent series do not fire
rules:
  - name: "Operator reconcile loop wedged"
    queries:
      - expression: |
          sum(increase(operator_reconcile_total{namespace="tatara",job="tatara-operator"}[15m]))
    math_operator: "<"
    threshold: 1
    for: 15m
    annotations:
      summary: "No reconciles in 15m (increase={{ index $values \"C\" }})."
    labels:
      homelab: "true"
      system: "tatara"          # routes to the incident webhook (omit on info rules)
      component: "operator"
      severity: "critical"      # warning|critical trigger an incident; info -> email only

The expression produces the VALUE; the comparison is math_operator + threshold. The Terraform module builds the reduce/round/threshold chain automatically. For LogQL rules (Loki), add datasource_uid and query_type: "loki" under the query.

Coverage by component file:

File Coverage highlights
tatara-operator.yaml Workload pod health (crash-loop, OOMKill, stuck-waiting), control-loop liveness (reconcile wedged, scan stalled, reconcile error ratio), turn-pipeline failures (submit error ratio, p95 latency, agent HTTP spike, boot crash budget), SCM write ratio, webhook error ratio, writeback 404 loop, memory stack failed, memory retrieval surface absent, tasks inflight, queue backlog
tatara-wrapper.yaml Wrapper pod health (crash-loop, OOMKill, stuck-waiting, not-ready); push-based series (commit/push ratio, turns erroring, HTTP 5xx, panics, token spend runaway) - active once ccw_ is in the push allowlist
tatara-memory.yaml Memory API pod health; component series (HTTP 5xx ratio, LightRAG error/latency, ingest job failure, code-graph query errors, analytics stalled with dirty repos) - component-specific rules dark until a ServiceMonitor is wired for mem-* pods
tatara-ingester.yaml Full re-ingest failure (operator-side), ingest Job stuck active > 30 m, ingest pod OOMKill/stuck-waiting, ingester-side run failure ratio
tatara-chat.yaml Chat pod health, HTTP 5xx ratio, store operation errors/latency, sweeper stall/error, handler panics, auth rejection rate (info only)
tatara-logs.yaml Loki-based: agent internal_issue_report action detected, operator/memory/chat ERROR log bursts > 20 lines / 5 m

4. Alert routing

Tatara alerts route to an operator webhook that creates an incident Task, kicking off an emergency brainstorm cycle. The routing is label-driven.

Required labels on any alert that should open an incident:

labels:
  homelab: "true"      # matches the global homelab notification policy
  system: "tatara"     # routes to the tatara-specific contact point
  severity: "warning"  # or "critical" - both trigger an incident
                        # omit system= on info-only rules (email only)

Boundary: Contact points and the system=tatara notification policy live in the global infra Terraform (infra/terraform/grafana), not in tatara-observability. The tatara-observability repo owns only the Grafana Tatara folder and the tatara-* rule groups. Routing works regardless of folder or rule ownership.

CI pipeline (tatara-observability):

PR opened     ->  terraform plan  ->  sticky comment on PR showing planned rule changes
Merge to main ->  terraform apply ->  rules live in Grafana within ~60 s

Required GitHub Actions secrets: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (S3 Terraform state), TF_VAR_GRAFANA_API_KEY (Grafana Editor SA token), TF_VAR_GRAFANA_URL.

To add or modify a rule, edit the relevant alerts/tatara-<component>.yaml and open a PR. The terraform module handles the Grafana API interaction; no terraform edits required for rule changes.


5. Scrape gotchas

Leader-only metrics

The operator runs with leaderElection: true (default). Business metrics - task tokens, terminal transitions, scan counts, memory stack gauges, lightrag documents - are emitted only by the leader replica. If you run replicaCount > 1 for high availability, always aggregate with sum() or max() rather than querying a single instance:

# Correct: aggregates across all replicas
sum(rate(operator_reconcile_total{result="error"}[5m]))

# Wrong with HA: single-replica query may hit a non-leader
rate(operator_reconcile_total{instance="tatara-operator-abc:9090",result="error"}[5m])

Workload infrastructure metrics from kube-state-metrics (pod restart counts, readiness, container waiting reasons) are always available regardless of leader election.

Push receiver allowlist

The push receiver enforces a prefix allowlist on metric names. The chart default already admits every family the wrapper and repo-ingester pods actually push:

pushMetricsAllowedPrefixes: "wrapper_,agent_,ccw_,tatara_wrapper_,ingest_,analyzer_,semantic_,scip_,llm_"

Series whose names do not match any prefix are dropped and counted in operator_push_series_dropped_total{reason="reserved_name"}. Because ccw_ and tatara_wrapper_ are in the default, the ccw_* wrapper push-series rules in tatara-observability are active out of the box - no allowlist edit is required. Only edit pushMetricsAllowedPrefixes if a component starts pushing a new metric family under a prefix not in the list above (TataraPushMetricsDropped fires and the receiver logs the dropped family name at WARN).

Go runtime metrics from wrappers

Wrappers currently forward go_* and process_* runtime metrics to the push receiver, which drops them as reserved names. This is a known wrapper-side issue and does not indicate a misconfigured allowlist.

Memory API pods are not scraped

The tatara-memory API pods (mem-*) do not have an active ServiceMonitor. The component-specific rules in alerts/tatara-memory.yaml that reference memory service metrics (http_requests_total, lightrag_calls_total, lightrag_call_duration_seconds, ingest_jobs_total, ingest_items_total, code_graph_query_total, etc.) are dark until scraping is wired. The default_no_data_state: "OK" setting prevents these from false-firing. The workload-generic pod-health rules (kube-state-metrics) fire correctly without scraping.

Viewing raw metrics in-cluster

# Port-forward the metrics port (not exposed via Ingress)
kubectl -n tatara port-forward svc/tatara-operator 9090:metrics

# Sample operator series
curl -s http://localhost:9090/metrics | grep -E '^operator_task_tokens_total'

# Check push receiver drops
curl -s http://localhost:9090/metrics | grep operator_push_series_dropped_total