Platform monitoring and alerting

Scrape the control plane and runners with your monitoring system.

How-to · Updated 2026-10-27 · Describes release 0.1.164

Evidence reviewed 8 October 2026; operating baseline 0.1.130 (the 16 October 2026 handover build; see the release notes).

Scrape the control plane and runners with your monitoring system. Route platform failures to an independent on-call receiver, never into the platform's own ingest: an unavailable platform cannot investigate itself.

Connect the scrape

GET /metrics is a separate listener on 9464, controlled by AIOPS_METRICS_PORT (0 disables it). The chart exposes ClusterIP Services <release>-metrics and headless <release>-runner-metrics; 9464 is excluded from the application Service, Ingress, HTTPRoute and load-balancer targets. It requires the bearer token in AIOPS_METRICS_TOKEN (the chart's secrets.AIOPS_METRICS_TOKEN, derived from AIOPS_SECRET_KEY when left empty; with existingSecret, add the key yourself) and must remain inside the cluster network. Without a token, any workspace's API connection aimed at 9464 reads every workspace's series, because the control plane dials connectors from inside the cluster.

Use the two endpoint-discovery jobs in Scrape with plain Prometheus, or enable metrics.serviceMonitor.enabled with labels and namespace selection matching your Prometheus Operator. The runner namespace is normally aiops-agents.

Set metrics.runnerScrapeFrom.namespaceSelector and podSelector to your Prometheus peer. Empty selectors leave the chart's runner port denied. Verify network-plugin enforcement: on the measured EKS Auto Mode cluster the policy objects were inert, so runners answered even before a peer was allowed. Chart configuration alone is not an isolation test. The control-plane chart has no NetworkPolicy; add a customer policy if required.

The following command is quoted from the guide's tested in-cluster check, with release and namespace normalized to aiops:

TOKEN=$(kubectl -n aiops get secret aiops-secrets \
  -o jsonpath='{.data.AIOPS_METRICS_TOKEN}' | base64 -d)
kubectl -n aiops run curl --rm -it --image=curlimages/curl -- \
  curl -s -H "Authorization: Bearer $TOKEN" http://aiops-metrics:9464/metrics | grep aiops_build_info

Check the build version and all expected targets UP. The listener must not be reachable outside the cluster. Use your existing Prometheus and external network checks to confirm the boundary; do not publish metrics through ingress.

Seven starter rules

The expressions below are the corrected guide expressions. Put them in your existing rule group or PrometheusRule and use the guide's annotations. The for durations and thresholds are starter values, not SLOs.

groups:
  - name: aiops-platform
    rules:
      - alert: AiopsRunnerSilent
        expr: max by (tenant) (aiops_runner_last_claim_age_seconds) > 600
        for: 5m
        labels: { severity: critical }
      - alert: AiopsRunnerNotReady
        expr: (max by (tenant) (aiops_runner_ready) == 0) and on (tenant) (sum by (tenant) (aiops_investigations{status="queued"}) > 0)
        for: 5m
        labels: { severity: critical }
      - alert: AiopsDbPoolExhausted
        expr: (aiops_db_pool_in_use >= aiops_db_pool_size + aiops_db_pool_max_overflow) and (aiops_db_pool_size > 0)
        for: 2m
        labels: { severity: critical }
      - alert: AiopsIngest5xx
        expr: sum(rate(aiops_http_requests_total{route=~"/webhook/.*",status_class="5xx"}[5m])) > 0
        for: 5m
        labels: { severity: critical }
      - alert: AiopsBudgetTrip
        expr: >-
          (increase(aiops_budget_trips_total{kind=~"tenant_day|tenant_month|kill_switch"}[15m]) > 0)
          or
          (aiops_budget_trips_total{kind=~"tenant_day|tenant_month|kill_switch"}
            unless aiops_budget_trips_total{kind=~"tenant_day|tenant_month|kill_switch"} offset 15m)
        labels: { severity: warning }
      - alert: AiopsLeaderLost
        expr: (sum(aiops_leader) or vector(0)) == 0
        for: 2m
        labels: { severity: critical }
      - alert: AiopsAuditSealBacklog
        expr: max(aiops_audit_unsealed_rows) > 0
        for: 10m
        labels: { severity: warning }

Keep the non-zero pool check: API-mode runners export a zero-size pool, which the bare comparison incorrectly treats as exhausted. Keep the second budget-trip clause: the first trip creates a series at 1, so increase() alone misses it. The ingest rule needs sustained errors; an isolated first 5xx does not fire it. Use the incident table for each alert's response.

With the record archive on (AIOPS_ARCHIVE_BUCKET), add one more rule to the same group: a workspace whose last good archive attempt is two days old is no longer having its expired records archived or removed.

      - alert: AiopsArchiveStale
        expr: time() - max by (tenant) (aiops_archive_last_success_timestamp_seconds) > 2 * 86400
        for: 30m
        labels: { severity: warning }

The error is on the Ops console's Data retention default card, under the workspaces whose last attempt failed: a bucket setting gone (Paused), a refused credential, or a failed batch.

Scheduler sub-tick timing

The leader's scheduler runs its sub-ticks one after another every 60 seconds, so one slow sub-tick delays all the rest, the audit seal included. aiops_scheduler_subtick_seconds{subtick} times the slow ones: the change harvest, the scanner findings pull, the estate inventory, record retention, the audit seal, the lakehouse collector, group_triage and the synthetic-check monitors. To see which one holds the tick up:

histogram_quantile(0.99, sum by (subtick, le) (rate(aiops_scheduler_subtick_seconds_bucket[15m])))

A sub-tick whose p99 approaches 60 seconds stretches every tick; if aiops_scheduler_last_tick_timestamp_seconds also falls behind, this is the place to start.

Route, dashboard and evidence

Route these rules in your external Alertmanager to the named platform on-call service, with resolved notifications. RDS free-storage/connection alarms use the customer SNS topics or the confirmed alarm_email subscription. With no recipient configured the alarms exist but page no one. Portal notification settings concern estate incidents; they are not a replacement for external platform monitoring.

Import the platform Grafana dashboard. Counters are produced by the process doing the work, so sum them across control plane and runners. Database-derived gauges are refreshed every 15 seconds by the control-plane leader; absent live-work series means zero, not necessarily a scrape failure. Check target up separately.

The 6 October verification on 0.1.105 found 14/14 targets UP, all seven rules healthy, and a first AiopsBudgetTrip delivered to a separate receiver. That receiver was an echo server, not proof that a customer's on-call integration is configured. Repeat a controlled route test in your monitoring system and retain the target/rule results plus firing and resolved delivery receipts.

Components log to stdout; forward them using the customer's log pipeline. Platform traces and additional counters for lease expiry/resume, approval decisions and database pool wait are outside the delivered monitoring scope.

Sources: Monitoring guide, verification record, and RDS settings.

Platform monitoring — scrape the AIOps platform from your Prometheus

The platform is a service your operations team runs. This guide tells you what it exports, where the metrics are reachable (only inside the cluster — by design), how to scrape them with plain Prometheus or the Prometheus Operator, and which alerts and dashboard to start from. Companion: deploy/aiops-helm/README.md for the chart values referenced here.

Which path do I need?

You run… Do this
Plain Prometheus (prometheus.yml) Add the two kubernetes_sd jobs under Scrape with plain Prometheus
Prometheus Operator / kube-prometheus-stack --set metrics.serviceMonitor.enabled=true (+ the selector labels your operator expects)
The platform's runner pods (remote execution mode) Also set metrics.runnerScrapeFrom.* — their port is closed until you do
Grafana Import deploy/grafana/aiops-platform-dashboard.json

What is exposed — and the in-cluster-only guarantee

Every control-plane pod and every per-tenant runner pod serves GET /metrics (Prometheus text format) on a separate listener, port 9464 (AIOPS_METRICS_PORT; 0 turns it off). That port:

Labels are deliberately low-cardinality and contain no PII: tenant slug (never an id), pillar, alert source, status, model id, HTTP route template (/api/aiops/{tid}/incidents, never the raw URL), method, status class. Requests that match no route are labelled by mount prefix (/portal, /mcp, …) or unmatched. A webhook call with bad credentials is counted under tenant="unknown" — the slug in that URL is untrusted input. Investigation series carry a bounded trigger label (alert, schedule, manual, recheck, correlate, change, compliance, xray, chat, api, else other) so interactive chat/api turns never read as incident runs — filter trigger!~"chat|api" for run-rate panels.

Which process counts what: counters increment in the process where the event happens (webhooks and HTTP on the control plane, LLM usage in the process running the agent — a runner pod in remote mode), so scrape both Services and sum() counters across replicas in queries. Gauges marked DB are computed every 15 s from the database on the control-plane leader replica only (non-leaders export none of them) and are the estate-wide truth; aiops_leader, the DB pool gauges and aiops_build_info are per replica. Benchmark runs are excluded from operational series (they are test spend).


Scrape with plain Prometheus

Only the control-plane pod carries prometheus.io/scrape|port|path annotations. Runner pods carry none on purpose: where the cluster enforces their NetworkPolicy it keeps port 9464 closed until metrics.runnerScrapeFrom names your Prometheus, so an annotation-driven pod job would only show DOWN targets — scrape runners through the headless <release>-runner-metrics Service instead. An annotation-driven job that scrapes the control-plane pod needs the same authorization block as the jobs below: annotations cannot carry a token, so without it that target answers 401 and shows DOWN.

Upgrading to a chart that sets the token: the token takes effect as the control plane restarts, so give every scrape job its authorization block before the upgrade, or expect its targets DOWN until you do. With existingSecret, the listener stays open until that Secret holds AIOPS_METRICS_TOKEN: make your Prometheus send the token first, then add the key, then set metrics.serviceMonitor.bearerToken: true if you use the ServiceMonitor. Explicit endpoints jobs need no annotations. Each job sends the metrics token: mount the AIOPS_METRICS_TOKEN key of the release Secret into your Prometheus (copy it into the monitoring namespace if Prometheus runs there) and point credentials_file at it:

scrape_configs:
  - job_name: aiops-control-plane
    authorization:
      credentials_file: /etc/prometheus/aiops/AIOPS_METRICS_TOKEN
    kubernetes_sd_configs:
      - role: endpoints
        namespaces: { names: ["<release-namespace>"] }
    relabel_configs:
      - source_labels: [__meta_kubernetes_service_name]
        regex: <release>-metrics
        action: keep
      - source_labels: [__meta_kubernetes_endpoint_port_name]
        regex: metrics
        action: keep
  - job_name: aiops-runners
    authorization:
      credentials_file: /etc/prometheus/aiops/AIOPS_METRICS_TOKEN
    kubernetes_sd_configs:
      - role: endpoints
        namespaces: { names: ["aiops-agents"] }
    relabel_configs:
      - source_labels: [__meta_kubernetes_service_name]
        regex: <release>-runner-metrics
        action: keep
      - source_labels: [__meta_kubernetes_pod_label_aiops_tenant]
        target_label: runner_tenant

Open the runner port for that Prometheus (values file):

metrics:
  runnerScrapeFrom:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: <monitoring-namespace> }
    podSelector:
      matchLabels: { app.kubernetes.io/name: prometheus }

Leaving both selectors empty keeps the runner port closed (deny-all). The control-plane pod has no NetworkPolicy in the chart; add your own if your cluster policy requires one (allow TCP 9464 from the same peer).

Scrape with the Prometheus Operator

metrics:
  serviceMonitor:
    enabled: true
    labels: { release: kube-prometheus-stack }   # whatever your serviceMonitorSelector wants
    interval: 30s
  runnerScrapeFrom: { … as above … }

One ServiceMonitor (<release>, release namespace) selects both component=metrics Services across the release and runner namespaces, and sends the token from the release Secret's AIOPS_METRICS_TOKEN key. With existingSecret, set metrics.serviceMonitor.bearerToken: true once that Secret holds the key. If the operator watches only its own namespace, set serviceMonitorNamespaceSelector on your Prometheus to include the release namespace.


Metric catalogue

Counters end in _total; histograms expose _bucket/_sum/_count. DB = refreshed on the control plane from the database every 15 s.

Metric Type Labels Meaning
aiops_webhooks_total counter tenant, source, outcome=received|rejected|rate_limited|dedup|grouped Alert items per webhook batch. rejected = 401 (labels unknown); rate_limited = 429, labelled with the real source; grouped = incidents merged into the failing service they depend on (before 0.1.147: storm, alerts folded into the storm incident)
aiops_incidents_opened_total counter tenant, pillar New incidents opened by ingest (reopens excluded)
aiops_alert_to_incident_seconds histogram tenant, source Time from the opening alert's fire time to the incident row (time-to-recognise); joins, reopens and alerts older than 24h are not observed
aiops_ingest_seconds histogram — Time to persist one webhook batch
aiops_investigations gauge (DB) tenant, pillar, trigger, status=queued|running|paused_approval Live agent runs; queued = not yet claimed by a runner. Absent = 0
aiops_investigations_finished_total counter tenant, pillar, trigger, status=done|failed|cancelled Terminal outcomes (cancelled = operator stop / kill switch)
aiops_investigation_seconds histogram pillar Wall time of a finished run (chat/api turns excluded)
aiops_investigation_steps histogram pillar Agent turns in a finished run (chat/api turns excluded)
aiops_llm_cost_usd_total counter tenant, model LLM spend booked by the engine (needs the model price map)
aiops_llm_tokens_total counter tenant, model, kind=prompt|completion Tokens booked by the engine
aiops_budget_trips_total counter tenant, kind=run_cap|tenant_day|tenant_month|incident|kill_switch|cancelled Guardrail stops where a run was actually attempted (periodic probes never count): kill_switch = the tenant-wide pause stopped a run, cancelled = an operator cancelled that one run
aiops_runner_last_claim_age_seconds gauge (DB) tenant Seconds since the newest claim/heartbeat of a running job. Absent while the tenant is idle
aiops_runner_claims_total counter tenant Job claims taken (control plane for api-mode runners, the runner for db-mode)
aiops_runner_jobs_running gauge tenant Jobs the scraped process is executing right now
aiops_runner_job_seconds histogram — Wall time of one job attempt in that process
aiops_runner_ready gauge tenant 1 when the tenant's provisioned runner has a ready replica, 0 when the Deployment exists but no pod runs (unschedulable, crashlooping). Set by the control-plane reconcile; absent when provisioning is off
aiops_approvals_pending gauge (DB) tenant Approval requests waiting for a human
aiops_remediations_total counter tenant, outcome=executed|failed|not_needed|rolled_back Remediation executions (not_needed = the run stopped because its only target already complied, nothing changed; rolled_back = a rollback ran / restore PR opened)
aiops_ticket_operations_total counter tenant, system=jira|servicenow|azure-devops|advanced, op=file|close|ask, outcome=ok|failed ITSM ticket filings, write-backs and the one question on a ticket held for missing fields (ticket-systems.md); advanced = a hand-written route
aiops_change_records gauge (DB) tenant, status Open change-ledger rows by status (planned, pending_approval, executing, failed, …). Finished rows (completed, rolled_back, not_needed, rejected, recorded) are not counted
aiops_db_pool_in_use / aiops_db_pool_overflow / aiops_db_pool_size / aiops_db_pool_max_overflow gauge — SQLAlchemy pool on this control-plane replica (capacity = size + max_overflow)
aiops_leader gauge — 1 on the replica running the singleton loops (always 1 with leader election off)
aiops_scheduler_last_tick_timestamp_seconds gauge — Unix time of the last scheduler tick on this replica (0 on non-leaders)
aiops_scheduler_subtick_seconds histogram subtick=harvest|scanner|inventory|retention|seal|collector|group_triage|monitors Wall time of one run of the slow scheduler sub-ticks on the leader, a failed run included. The tick runs them one after another, so one slow sub-tick delays the rest
aiops_audit_unsealed_rows gauge (DB) — Audit rows not yet sealed into the hash chain
aiops_migration_pending gauge (DB) name 1 while a background index or one-shot data migration of an upgrade is not done, 0 once it is (upgrade checks)
aiops_archive_last_success_timestamp_seconds gauge (DB) tenant Unix time of the workspace's last record-archive attempt without an error (workspaces on the archive only; kept through a later failure and through a pause, so the series ages)
aiops_http_requests_total counter method, route, status_class HTTP requests on the app port by route template
aiops_http_request_seconds histogram route HTTP latency by route template (/healthz excluded)
aiops_factory_runs_running gauge (DB) — Test-factory runs in progress
aiops_factory_last_pass_rate gauge tenant, lane=all|incident|change|compliance Pass rate (0–1) of the last finished factory run
aiops_detection_episodes_total counter tenant, detector=cloudwatch|kubernetes|scheduled_events|provider_health, mode=sent|shadow Built-in detection episodes that began: sent = the alert went into the pipeline, shadow = recorded only (the workspace or the rule still watches in shadow)
aiops_detection_reads_total counter tenant, detector, outcome=ok|failed Built-in detection reads: one per account and region (CloudWatch, scheduled events), cluster (Kubernetes) or provider check per poll; failed = denied, refused or unreachable (the card says which)
aiops_build_info gauge version Always 1; the package version

Alert rules to start from

Drop this into a PrometheusRule (operator) or the groups: of a rule file. Placeholders: <release> is your Helm release name. Thresholds are starting points, not SLOs.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: aiops-platform
spec:
  groups:
    - name: aiops-platform
      rules:
        - alert: AiopsRunnerSilent
          expr: max by (tenant) (aiops_runner_last_claim_age_seconds) > 600
          for: 5m
          labels: { severity: critical }
          annotations:
            summary: "Runner for tenant {{ $labels.tenant }} has not heartbeated for 10m"
            description: "A claimed job stopped renewing its lease — the runner pod is dead or wedged. Its work resumes from the committed transcript once the lease expires; check the runner Deployment in the runner namespace."
        - alert: AiopsRunnerNotReady
          expr: (max by (tenant) (aiops_runner_ready) == 0) and on (tenant) (sum by (tenant) (aiops_investigations{status="queued"}) > 0)
          for: 5m
          labels: { severity: critical }
          annotations:
            summary: "Runner for tenant {{ $labels.tenant }} has no ready pod while work is queued"
            description: "The runner Deployment exists but its pod is not running — usually unschedulable (node capacity) or crashlooping. Queued investigations stay `running` at zero cost until it comes up. `kubectl -n aiops-agents describe pod` for the scheduling event."
        - alert: AiopsDbPoolExhausted
          # a runner in api mode has no pool and exports 0/0/0, which the bare
          # comparison would read as exhausted
          expr: (aiops_db_pool_in_use >= aiops_db_pool_size + aiops_db_pool_max_overflow) and (aiops_db_pool_size > 0)
          for: 2m
          labels: { severity: critical }
          annotations:
            summary: "Control-plane DB pool exhausted on {{ $labels.instance }}"
            description: "Every pooled connection is checked out; API and ingest start failing. Raise AIOPS_DB_POOL_SIZE / AIOPS_DB_MAX_OVERFLOW or lower AIOPS_MAX_CONCURRENT_INVESTIGATIONS."
        - alert: AiopsIngest5xx
          expr: sum(rate(aiops_http_requests_total{route=~"/webhook/.*",status_class="5xx"}[5m])) > 0
          for: 5m
          labels: { severity: critical }
          annotations:
            summary: "Webhook ingest is returning 5xx"
            description: "Alert deliveries are failing at the platform; senders retry, but a sustained 5xx means alerts are not becoming incidents. The 5xx series is created by the first failure, so one isolated 5xx never fires this — only a sustained run does."
        - alert: AiopsBudgetTrip
          # second half: a series that did not exist 15 minutes ago. Counters
          # are created on their first increment, so a tenant's FIRST trip
          # appears as a bare 1 and increase() alone never sees it.
          expr: >-
            (increase(aiops_budget_trips_total{kind=~"tenant_day|tenant_month|kill_switch"}[15m]) > 0)
            or
            (aiops_budget_trips_total{kind=~"tenant_day|tenant_month|kill_switch"}
              unless aiops_budget_trips_total{kind=~"tenant_day|tenant_month|kill_switch"} offset 15m)
          labels: { severity: warning }
          annotations:
            summary: "Agent spend blocked for tenant {{ $labels.tenant }} ({{ $labels.kind }})"
            description: "New investigations are failing at start and their incidents are escalated to humans. Raise the budget on the Guardrails page or lift the kill switch."
        - alert: AiopsLeaderLost
          expr: (sum(aiops_leader) or vector(0)) == 0
          for: 2m
          labels: { severity: critical }
          annotations:
            summary: "No control-plane replica is leader"
            description: "The scheduler, sweeps, provisioner and audit sealer are not running anywhere. Check the Postgres advisory-lock connection (AIOPS_LEADER_ELECTION)."
        - alert: AiopsTicketsFailing
          # every ticket call of a tenant failing for 30 minutes: an expired
          # token, a revoked user, a sleeping developer instance
          expr: >-
            (sum by (tenant, system) (increase(aiops_ticket_operations_total{outcome="failed"}[30m])) > 0)
            unless
            (sum by (tenant, system) (increase(aiops_ticket_operations_total{outcome="ok"}[30m])) > 0)
          labels: { severity: warning }
          annotations:
            summary: "Ticket filing/write-back failing for tenant {{ $labels.tenant }} ({{ $labels.system }})"
            description: "No ticket call succeeded for 30 minutes and some failed. Incidents show 'Ticket filing failed: <reason>' with Retry; the usual cause is an expired API token or PAT — Test the connection on the Connections page."
        - alert: AiopsAuditSealBacklog
          expr: max(aiops_audit_unsealed_rows) > 0
          for: 10m
          labels: { severity: warning }
          annotations:
            summary: "Audit chain sealer has a backlog"
            description: "Audit rows have stayed unsealed for 10 minutes; the tamper-evident chain is not advancing. Usually the leader's scheduler tick is stalled — see aiops_scheduler_last_tick_timestamp_seconds."
        - alert: AiopsArchiveStale
          # a workspace on the record archive with no good attempt for two days:
          # retention is not removing (nor archiving) its expired records
          expr: time() - max by (tenant) (aiops_archive_last_success_timestamp_seconds) > 2 * 86400
          for: 30m
          labels: { severity: warning }
          annotations:
            summary: "Record archive stalled for tenant {{ $labels.tenant }}"
            description: "No archive attempt has completed for two days. The Ops console's Data retention default card names the error under the workspaces whose last attempt failed (bucket gone = Paused, a refused credential, a failed batch)."

Dashboard

deploy/grafana/aiops-platform-dashboard.json (Grafana schema v39, one DS_PROMETHEUS datasource input) — import via Dashboards → New → Import. Panels: webhook rate by outcome, incidents opened, live investigations by status, finished outcomes, LLM spend by model, runner liveness, pending approvals, HTTP latency p95 by route, DB pool, audit seal backlog.


Verify from inside the cluster

Verified on the hosted sandbox on 2026-10-06 (build 0.1.105): every target UP, the seven rules above evaluating, and one AiopsBudgetTrip delivered to a non-agent receiver — platform-monitoring-verification-2026-10.md.

TOKEN=$(kubectl -n <release-namespace> get secret <release>-secrets \
  -o jsonpath='{.data.AIOPS_METRICS_TOKEN}' | base64 -d)
kubectl -n <release-namespace> run curl --rm -it --image=curlimages/curl -- \
  curl -s -H "Authorization: Bearer $TOKEN" http://<release>-metrics:9464/metrics | grep aiops_build_info

Without the header the same call answers 401, and from outside the cluster the path must fail altogether — that is the contract.

Note: in remote-runner (api) mode the control plane folds an operator cancel and a tenant pause into one stop signal, so a per-run cancel there is booked as kind="kill_switch"; embedded and db modes book kind="cancelled".

Next steps