Skip to content

tatara-observability

Alerts-as-code for the tatara platform. Grafana alert rules live in versioned YAML files under alerts/, rendered into Grafana by Terraform on PR merge. Both humans and enrolled agents change alerts through the same PR workflow - open a PR, review the terraform plan diff, merge to apply.

Repository: github.com/szymonrychu/tatara-observability


What it is

The repo contains one YAML file per component under alerts/tatara-<component>.yaml. Each file maps to exactly one Grafana rule group in the Tatara folder. The modules/grafana_alert Terraform module reads every alerts/*.yaml and renders the rules into Grafana via the Grafana provider.

Current rule groups:

File Grafana rule group Datasources
alerts/tatara-operator.yaml tatara-operator Prometheus
alerts/tatara-wrapper.yaml tatara-wrapper Prometheus
alerts/tatara-memory.yaml tatara-memory Prometheus
alerts/tatara-ingester.yaml tatara-ingester Prometheus
alerts/tatara-cd.yaml tatara-cd Prometheus
alerts/tatara-quality.yaml tatara-quality Prometheus
alerts/tatara-usage-gate.yaml tatara-usage-gate Prometheus
alerts/tatara-logs.yaml tatara-logs Loki

tatara-cd covers the merge/deploy cascade (sequential-merge stalls, apply failures), tatara-quality the model-keyed review/CI quality-feedback signals, and tatara-usage-gate originally covered the turn- and pod-recreation budget gates (maxTurnsPerTask, maxPodRecreations, maxReviewRounds) that replaced the retired per-Task token-budget admission gate. Those three are themselves now deprecated with zero effect (tatara-operator#582) - what bounds a runaway Task today is the 24h residency cap plus the operator_pod_recreations_total alert.

alerts/tatara-chat.yaml (10 rules) and its log-burst rule are deleted along with the tatara-chat component itself. alerts/tatara-cd.yaml and alerts/tatara-operator.yaml also gain the new alert classes below - both merge-blocked/deploy-blocked (cascade) and the platform-wide operator health signals (contract mismatch, unexpected merge, sweep heartbeat) attach to their existing per-component groups; no new file is introduced for them.

All rules land in the Grafana Tatara folder, which is managed exclusively by this repo. The infra/terraform/grafana state never touches the Tatara folder, so the two states never collide.


Alert-as-code flow

sequenceDiagram
    participant E as Editor<br/>(human or agent)
    participant GH as GitHub
    participant CI as GitHub Actions
    participant TF as Terraform
    participant G as Grafana

    E->>GH: open PR editing alerts/*.yaml
    GH->>CI: pull_request event
    CI->>TF: terraform plan
    TF->>G: read current state (provider)
    TF-->>CI: plan output
    CI->>GH: sticky comment with plan diff
    E->>GH: review plan, merge PR
    GH->>CI: push event (main)
    CI->>TF: terraform apply
    TF->>G: create/update/delete rule group
    G-->>TF: confirmed
    CI-->>GH: apply success

Rule schema

Each alerts/tatara-<component>.yaml file is a rule group definition. The file is parsed by grafana.tf with yamldecode and passed to modules/grafana_alert.

Group-level fields

Field Type Default Description
interval_seconds integer 60 Evaluation interval for all rules in the group
default_no_data_state string "NoData" State when a query returns no data. The module default is "NoData"; every tatara group file sets it to "OK" by convention to avoid noise on scrape gaps
default_datasource_uid string "prometheus" Fallback datasource for rules that do not specify one

Rule-level fields

Field Required Type Description
name yes string Alert name shown in Grafana and in incident task titles
queries yes list One or more query definitions (see below)
threshold yes number Numeric threshold for the comparison
math_operator no string Comparison operator: >, <, >=, <=, ==, !=. Default ">"
for no string Hold-off duration before the alert fires. Default "1m". Use Prometheus duration syntax (5m, 15m, 1h)
decimal_points no integer Rounding precision applied to the query result before comparison. Default 2
annotations no map Grafana alert annotations. Supports Go template expressions (see below)
labels no map Alert labels for routing. Replaces module defaults; set all four required keys
no_data_state no string Per-rule override for the no-data state
exec_err_state no string State when query execution errors. Module default is "Error"; tatara group files commonly set "OK" by convention
is_paused no bool Pause the rule without deleting it. Default false

Query object fields

Field Required Type Default Description
expression yes string - PromQL or LogQL expression
datasource_uid no string "prometheus" Override datasource. Use the Grafana datasource UID
query_type no string "prometheus" "prometheus" or "loki"
relative_time_range_from no integer 1200 Query look-back in seconds (1200 = 20 min). Increase for metrics with long update intervals
relative_time_range_to no integer 0 End of query range relative to now (0 = now)

Query pipeline

The module builds a four-stage pipeline for every rule:

expression (PromQL/LogQL)
   └─► Reduce    (last value of each series)
       └─► Round (to decimal_points precision)
           └─► Threshold (math_operator compared against threshold)

expression provides the raw value. The comparison is expressed entirely through math_operator and threshold. For example, "alert when the error ratio exceeds 20%":

math_operator: ">"
threshold: 0.2

And "alert when fewer than one replica is up" (reverse comparison):

math_operator: "<"
threshold: 1

Annotation templates

Grafana annotation values are Go templates evaluated at alert time. The module exposes two template variables:

  • {{ index $values "C" }} - the numeric result of the reduce/round stage (the value compared against the threshold)
  • {{ index $labels "<key>" }} - a label value from the firing series (useful for pod, reason, component, etc.)
annotations:
  summary: >
    tatara-operator reconcile error ratio is {{ index $values "C" }} (>0.20) over 15m.
    Reconciles are failing en masse.

Example: Prometheus rule

interval_seconds: 60
default_no_data_state: "OK"
rules:
  - name: "Operator reconcile error ratio high"
    queries:
      - expression: |
          sum(increase(operator_reconcile_total{namespace="tatara",job="tatara-operator",result="error"}[15m]))
          / clamp_min(sum(increase(operator_reconcile_total{namespace="tatara",job="tatara-operator"}[15m])), 1)
    math_operator: ">"
    threshold: 0.2
    for: 15m
    decimal_points: 2
    annotations:
      summary: "Reconcile error ratio is {{ index $values \"C\" }} (>0.20) over 15m."
    labels:
      homelab: "true"
      system: "tatara"
      component: "operator"
      severity: "warning"

Example: Loki rule

For log-based alerts, override datasource_uid and set query_type: "loki" on the query object. The expression becomes a LogQL stream selector + pipeline:

rules:
  - name: "Tatara agent reported platform problem"
    queries:
      - expression: |
          sum by (description, category, severity) (
            count_over_time(
              {namespace="tatara", app="tatara-claude-code-wrapper"}
              | pattern `<_> <_> <_> <body>`
              | line_format `{{.body}}`
              | json
              | action="internal_issue_report"
              [5m]
            )
          )
        datasource_uid: "efihqbqlmroqod"
        query_type: "loki"
    math_operator: ">"
    threshold: 0
    for: 1m
    decimal_points: 0
    annotations:
      summary: "Agent reported a platform problem: {{ index $labels \"description\" }}"
    labels:
      homelab: "true"
      system: "tatara"
      component: "wrapper"
      severity: "warning"

Label requirements for routing

Every rule must carry four labels. The labels replace the module defaults - there is no merge. Omitting a label removes it from the rule.

Label Required value Effect
homelab "true" Matches the root homelab notification policy in Grafana
system "tatara" Routes to the tatara operator incident webhook. Omit for info-only rules
component e.g. "operator", "memory", "ingester" Identifies the firing component in incident task context
severity "warning", "critical", or "info" warning/critical trigger an incident Task; info routes to email only

Per-rule labels replace defaults

The labels map on a rule replaces the module's default_labels, which default to {} and are not wired to anything in this repo. Omitting labels renders the rule with no labels at all, not a homelab-routed fallback. Always set all four labels explicitly, and let CI catch a mistake: scripts/check_routing_labels.py blocks a PR whose rule drops homelab, uses an unrecognised severity, or violates the severity/system pairing above.

Info rules

Rules that should surface in Grafana but not page on-call omit system: "tatara". Without that label the system=tatara child policy does not match, and the alert falls through to the homelab email route.

Severity and incident routing

When an alert with homelab=true + system=tatara + severity=warning|critical fires, Grafana sends a POST to the operator's alert webhook (/operator/webhooks/tatara/grafana). The operator validates the bearer token, deduplicates by alert group, enqueues a QueuedEvent of class alert, and spawns an incident Task. The agent runs with access to a grafana-mcp sidecar for in-session Grafana queries.


Operator metrics this release depends on

The task-centric redesign deletes the operator's phase/lifecycleState/cascadeStage series wholesale and replaces them with a stage-keyed model. Any rule still querying a deleted metric does not error - it just returns no data, and under the file's default_no_data_state: "OK" that reports as a silently, permanently green alert. The rules keyed on phase, lifecycleState, cascadeStage, implementGiveUps, and linksSyncFailures - eight rules in total, including both CD-cascade alerts - must be rewritten against the metrics below, not left in place.

The metrics this release's rules key on (non-exhaustive; see tatara-operator for the full catalogue):

Metric Type Labels Alerting use
operator_task_state gauge state,kind replaces every phase/lifecycleState series
operator_task_state_age_seconds gauge task,state,kind state-stuck detection
operator_illegal_stage_transition_total counter from,to any nonzero value is a code bug (name unchanged since #521; the label VALUES are states)
operator_task_parked_total counter state,parkReason merge/deploy-blocked cycle caps firing
operator_agent_contract_mismatch_total counter expected,got,image any nonzero value is critical
operator_merge_cursor_stalled_seconds gauge task,repo a sequential merge that stopped advancing
operator_unexpected_merge_total counter repo the accepted-risk detector: a merge the operator did not initiate
operator_sweep_last_success_timestamp_seconds gauge activity heartbeat - noData IS the failure
operator_scm_ratelimited_total counter provider,path,limit_type
operator_object_too_large_total counter kind,name the etcd object byte-budget guard failed to evict enough
operator_doc_task_abandoned_total counter reason never_ran | timeout: the nightly doc batch starved
operator_queue_age_seconds gauge class,priority,state age of the oldest queued event per bucket - keys the incident-starvation alert

New alert classes for this release (minimum set; land in the existing per-component group that owns the metric - tatara-operator.yaml for platform-wide operator health, tatara-cd.yaml for the merge/deploy cascade - no new rule-group file is introduced):

- incident starvation (CRITICAL):
    operator_queue_age_seconds{class="alert",state="Queued"} > 300
- agent contract mismatch (CRITICAL):
    increase(operator_agent_contract_mismatch_total[5m]) > 0
- unexpected merge (CRITICAL):
    increase(operator_unexpected_merge_total[15m]) > 0
- object too large (CRITICAL):
    increase(operator_object_too_large_total[15m]) > 0
- illegal stage transition (WARNING):
    increase(operator_illegal_stage_transition_total[15m]) > 0
- sweep heartbeat stalled (CRITICAL, no_data_state: Alerting):
    time() - operator_sweep_last_success_timestamp_seconds > 7200
- SCM rate limited (WARNING):
    increase(operator_scm_ratelimited_total[10m]) > 0
- merge / deploy blocked (WARNING):
    increase(operator_task_parked_total{stageReason=~"merge-blocked|deploy-blocked"}[1h]) > 0
- docs never written (WARNING):
    increase(operator_doc_task_abandoned_total{reason="never_ran"}[25h]) > 0

CI

GitHub Actions in .github/workflows/apply.yml drives the full terraform lifecycle.

Triggers

The workflow runs on PRs and pushes to main for changes to:

  • alerts/**
  • **.tf
  • modules/**
  • .github/workflows/apply.yml

Steps

Event Steps
Pull request fmt -check -> init -> validate -> plan -> sticky PR comment
Push to main fmt -check -> init -> validate -> apply

The plan comment is posted (and updated on each push) via marocchino/sticky-pull-request-comment with header tatara-observability-plan. The concurrency group tatara-observability-tf is set to cancel-in-progress: false so concurrent applies queue rather than cancel.

Required secrets

Secret Purpose
AWS_ACCESS_KEY_ID S3 Terraform state backend (bucket szymonrychu-terraform-state, key terraform/tatara-observability)
AWS_SECRET_ACCESS_KEY S3 Terraform state backend
TF_VAR_GRAFANA_API_KEY Grafana Editor service account token
TF_VAR_GRAFANA_URL Grafana base URL

Ownership boundary

This repository has a deliberately narrow scope.

tatara-observability (this repo)
├── Grafana folder: "Tatara"
└── Rule groups: tatara-*

infra/terraform/grafana (separate repo + state)
├── Contact points (operator incident webhook, email)
├── Notification policies
│   ├── homelab root policy (homelab=true)
│   └── system=tatara child route -> operator webhook contact point
└── All other Grafana folders and rule groups

Do not touch routing here

Contact points and notification policies are outside this repo's scope. Changes to alert routing must go to infra/terraform/grafana. The two Terraform states manage disjoint resources and must not overlap.

The separation is intentional: tatara agents are enrolled on GitHub repos only. infra/terraform/grafana lives on GitLab and is not enrolled on the tatara Project. Moving alert rules to this GitHub repo lets agents propose and review changes to their own observability without access to global homelab infrastructure.


How agents edit alerts

tatara agents are enrolled on this repository as a standard Repository CR. The standard PR-based workflow applies:

  1. An agent (or a human) edits one or more files in alerts/.
  2. The agent opens a PR via the SCM API.
  3. GitHub Actions runs terraform plan and posts the diff as a sticky PR comment.
  4. A human reviewer reads the plan diff - it shows exactly which Grafana rule groups will change, what threshold values shift, and what labels are added or removed.
  5. On approval and merge, terraform apply pushes the changes to Grafana immediately.

No Terraform knowledge is required to author a rule. The YAML schema is the only interface. The plan comment makes the Grafana impact visible before any change reaches production.

Reviewing agent-proposed alert changes

The sticky plan comment renders a collapsed <details> block with the full terraform plan -no-color output. Focus on # grafana_rule_group resource diffs. A rule rename or label change shows as a destroy + create pair; a threshold change shows as an in-place update. Verify that system and homelab labels are present on all routed rules.


Operations notes

Loki datasource UID is hardcoded. Rules in tatara-logs.yaml use datasource_uid: "efihqbqlmroqod". This is the UID of the Loki datasource in the target Grafana instance. If you install tatara-observability against a different Grafana, update this value to match your Loki datasource UID (find it in Grafana under Configuration -> Data sources).

Ingest alert mode selector. The rule "Tatara ingest job failing" in tatara-ingester.yaml uses mode="full" in its PromQL selector. This is intentional and must not be removed. Failed incremental ingest jobs self-heal via the full-ingest fallback and are benign; alerting on them produces chronic noise. Only terminal full-ingest failures (which mean the recall corpus is going stale) warrant a page.

No-data defaults to OK, except for heartbeats. All tatara rule groups set default_no_data_state: "OK" at the file level. This suppresses spurious alerts during scrape gaps (component restarts, transient probe failures) for a gauge that legitimately disappears when idle. A component that is genuinely down will fire via the up == 0 or pod-not-ready rules, not via no-data state.

This default is wrong, and dangerous, for a heartbeat metric. A heartbeat series (e.g. operator_sweep_last_success_timestamp_seconds, emitted every sweep pass) stopping altogether - the sweep loop itself wedging - means the query returns no series at all, and under the file default that reports as OK forever: the exact failure mode invisible behind a green dashboard. Every heartbeat/liveness rule must set no_data_state: "Alerting" as a per-rule override, overriding the file default:

rules:
  - name: "Operator sweep heartbeat stalled"
    queries:
      - expression: "time() - operator_sweep_last_success_timestamp_seconds"
    math_operator: ">"
    threshold: 7200
    for: 5m
    no_data_state: "Alerting"
    labels:
      homelab: "true"
      system: "tatara"
      component: "operator"
      severity: "critical"

This bit the platform once already (recorded in MEMORY) and the redesign re-arms it at scale: deleting the pre-redesign phase/lifecycle metrics silently green-lights every alert still keyed on them (see below) unless each is rewritten against a metric that still exists.