bex makes a running platform observable without kubectl: an operator or an AI agent debugging a deploy reaches App logs and metrics through the same bearer-authed bex-api it uses for lifecycle verbs. This is GOAL.md #2 ("basic obs for operation") and the AI-native pillar in ADR008-vision.md — agents can't fix a failing deploy they can't read.
Logs shipped first: highest operational value, and the simplest backend (pod logs, no metrics-server dependency). Metrics follow, over the same one-Core-many-adapters shape.
One Core logs read, three adapters — the bex-api invariant. The MCP list_logs tool is the agent surface; REST and GraphQL expose the same read to the public API and dashboard.
flowchart LR
rest["REST GET /v1/logs<br/>GET /v1/logs/subscribe"] --> core
gql["GraphQL logs(...)"] --> core
mcp["MCP list_logs"] --> core
core["Core.Logs / QueryLogs / FollowLogs"] --> hist["LogHistorySource<br/>(BEX_LOKI_URL)"]
core -.fallback.-> src["PodLogSource"]
core --> stream["PodLogStream (tail)"]
hist --> loki["Loki ← log-shipper DaemonSet"]
src --> pods["App pods (app.bex.co/app label)"]
stream --> pods
loki --> pods
Core.Logs(name, tail)— tail-N aggregation across replicas; the unfiltered convenience read. ReturnsLogEntry{timestamp, message, labels}(labelsservice/instance/container).Core.QueryLogs(LogQuery)— adds Render's filters and paging; the read path all three adapters (REST, GraphQL, MCPlist_logs) go through. Adpg-…resource dispatches to the Database-authorized Postgres path (w3/m28); ared-…resource dispatches to the KeyValue-authorized Valkey path (w3/m30). Both preserve the same public operation.Core.FollowLogs(LogQuery, emit)— live tail; the SSE stream.
Two backends sit behind the read verbs, each an injected source so the domain stays clientset/HTTP-free (like PodLogSource, both are faked in tests with no cluster):
LogHistorySource(NewLokiSource, gated byBEX_LOKI_URL) — the durable backendCore.QueryLogs/Logsprefer when wired. It translates the authorizedLogQueryinto a LogQLquery_range:{namespace, app}for services or{namespace, database}for managed Postgres, plus text, time, instance, and limit. Alloy feeds both sources. This is what makes logs survive a pod restart: pre-restart App and CNPG lines remain searchable.PodLogSource(NewPodLogSource) — the live fallback forQueryLogs/LogswhenBEX_LOKI_URLis unset: reads the kubelet'spods/logring buffer directly (the one subresource controller-runtime's client can't serve). Apps selectapp.bex.co/appand containerapp; Postgres selects exactcnpg.io/cluster=<dpg-id>and containerpostgres; Key Value selectsapp.bex.co/keyvalue=<red-id>and containervalkey. No history — a restart loses the buffer — but zero extra infrastructure.
PodLogSource/PodLogStream live in podlogs.go; LogHistorySource in loki.go. All three are injected in cmd/api/main.go.
Core.FollowLogs (the GET /v1/logs/subscribe SSE stream) always reads PodLogStream (pod logs), never Loki — even when Loki is wired. The default/App tail follows the App containers; an explicit type=build follows the newest running build pod in BEX_BUILD_NAMESPACE (BuildKit, signed BuildKit, or kpack, with running container names discovered from Pod status). The tail is for new lines going forward, where following the kubelet stream directly is real-time with zero ingest lag, adds no moving parts, and — critically — does not die when Loki is down: history degrades to the live buffer, the tail is unaffected. Loki's own tail endpoint would give a single history+tail source at the cost of a small ingest lag and a new failure mode on the live path; the durability win is on the historical query (QueryLogs), which Loki already owns, so the tail stays on pods. The one accepted consequence: a freshly-restarted pod's tail starts from that pod's new buffer — but the pre-restart lines are served by QueryLogs from Loki, so nothing is actually lost.
Request lines get a second producer (w4/m237, 2026-10-19). The rule above holds for app and build lines, but type=request has no pod source — the access line is written by Traefik, not the tenant's process — so a pod-only tail could never stream it, and the dashboard's "Live" request lines arrived on the history refetch (~30 s). The subscribe stream now also runs a store poll: every RequestPollInterval (2 s default) it reads request lines that reached Loki since an exactly-once cursor (newest emitted instant + the identities emitted at it, so a shared-timestamp group is neither dropped nor repeated) and merges them into the same stream. App/build lines keep zero ingest lag and never depend on Loki; request lines carry shipper + one poll interval of lag, and their structured filters are honored by the store query, forwarded from the subscription. A request-only subscription with no store wired fails closed (503) before any header; mixed streams without a store still serve their pod lines. Refusals that remain: store-only filters over app/build lines (labels exist only in the store) and producerless types — named 400s before a subscription slot is taken.
Loki keeps 21 days of searchable history (limits_config.retention_period: 504h, compactor-enforced) — enough for the ADR055 registry/static identity migration's 14-day clean window plus an evaluation buffer (w2/m93, runbooks/registry-static-identity-migration.md Phase 4). Render's searchable window is tiered by plan — Hobby 7 days, Pro 14, Scale/Enterprise 30 — so 21 days sits inside the Pro/Scale range; lowering back toward the Hobby floor is one knob (retention_period in deploy/gitops/base/loki.yaml, plus the PVC size) once Phase 4 closes. Loki runs single-binary on a filesystem PVC (no object store) — the same posture as Prometheus's PV and the etcd/OpenBao Raft volumes: history survives a pod restart because it's on a real PV. On prod the PVC rides the cluster's default hcloud-volumes StorageClass (a Hetzner Cloud Volume — a network volume that survives a node rebuild/loss), so a node going away does not lose the history; the disposable CAPD mock uses local-path (node-local), where a node loss does lose it. Cross-host log durability beyond one volume's lifecycle would mean an object-store backend, deferred like the others. The local overlay shrinks the PVC to CAPD scale (2Gi) since the mock cluster is disposable. The same durability covers tenant-node replacement (autoscaler scale-down/up churn), not just a crash: the log-shipper DaemonSet tolerates every taint so it ships from any node the autoscaler adds, and the hcloud volume follows Loki's pod to whichever node it's rescheduled onto — no further work needed.
deploy/gitops/base/loki.yaml runs the one Loki (single-binary, filesystem PVC, 21-day retention) and deploy/gitops/base/log-shipper.yaml runs the Grafana Alloy DaemonSet that ships App (type=app), request (type=request), build, managed-Postgres (type=postgres), managed-Valkey Key Value (type=keyvalue), and platform streams (type=platform: dashboard UI service=dashboard — w4/m88; Zot registry service=zot in bex-registry and static-server service=static-server in bex-system — w2/m93 migration clean-window evidence; bex-api service=bex-api and the operator manager service=operator in bex-system — w7/m157, so incident evidence survives replica replacement). Platform budget (w7/m157): the two core services add ≈10 MB/day raw (operator zap console lines, severity parsed from the level column; bex-api's few std-log lines stay level=unknown) to ≈31 MB/day across all streams measured 2026-09-28, against a 20 GiB Loki volume then 1.9 GB used; retention is the shared 21-day compactor window. The selection is a closed allowlist — activator, SNI proxies, egress meter and ssh-gateway stay out, and no new label carries a tenant id. The Postgres pipeline requires the operator-stamped app.bex.co/component=database marker, keeps only the postgres container, and labels the stream with the immutable CNPG cluster/Database id; platform CNPG clusters are excluded. The Key Value pipeline selects pods whose app.bex.co/keyvalue label is non-empty (operator-stamped tenant Valkey pods only), keeps only the valkey container, and labels the stream with the keyvalue=<red-id> label. Platform pod logs are selected by namespace / component alone (no fake app.bex.co/app); they are operator-facing and are not part of bex-api's tenant log API. scripts/registry-migration-readiness.sh queries the zot/static-server streams for legacy dual-read hits. BEX_LOKI_URL=http://loki.monitoring.svc:3100 points bex-api at it and flips historical reads from pod buffers to durable history.
Edge refusals remain request logs (w8/m115, 2026-10-18). A service inbound IP rule's Traefik 403 is retained in that App's request history even though the middleware never calls its backend (OriginStatus=0, no ServiceName). The shipper recognizes access lines by DownstreamStatus and attributes them from Traefik's trusted ingress name and anchored tenant/default namespace, retaining the older service-name fallback. Client-supplied RequestHost never selects a tenant stream. The existing status, path, method and host filters, unfiltered history, and status-value discovery expose these refusals consistently through REST, GraphQL, MCP, the CLI and the dashboard, matching Render's documented behavior. This adds no labels or API fields and makes no claim about rejections that occur before Traefik.
The Postgres shipper and direct-pod fallback classify a CNPG line by one allow-list (w5/m122). A logger=postgres, msg=record entry with a record object is unwrapped into PostgreSQL message/severity fields. A line that is not a JSON object passes unchanged. Every other JSON object is the instance manager's (named loggers, non-record PostgreSQL chatter, probes, its net/http server's TLS errors) and is dropped. Both read the line as the shipper's JSON stage does, with exact keys and any value types, and render a field CNPG omitted as nothing. Both sides are tested against one table, lego/backend/internal/logs/testdata/tenant-log-lines.json: the shipper harness (scripts/test_log_shipper.py) runs it through the real pipelines, and the Go direct reads through theirs, so the two cannot drift. Temporary classification labels are removed before Loki ingestion. REST, GraphQL, MCP, the CLI, and the dashboard read these same sources; the Render logs API likewise exposes Postgres resources and severity filtering. This is bex's CNPG normalization, not a claim that Render uses the same underlying log format.
App pods tail CRI files (w4/m174, 2026-10-05). Tenant App containers ship from the kubelet's on-disk CRI files (loki.source.file + stage.cri), as build and pre-deploy pods have since w6/m123, not through loki.source.kubernetes. The pinned API tailer skips any line not stamped strictly after the previous one (kubetail/tailer.go:277), and the CRI file is not in timestamp order: containerd copies stdout and stderr on separate goroutines that stamp each line when they read it. On kind, 4 of 20 runs of a five-line fast cron command left a line (nearly always the stderr one) written after a later-stamped line, and production lost a cron run's error: line in 3 of 6 runs. The file tailer reads by byte offset, so every line ships with its own write timestamp. The filename and stream labels the file source and stage.cri add are dropped, keeping the App stream labels unchanged. Positions live on the node (/var/lib/bex-log-shipper, mounted as Alloy's storage path), so a restarted replica resumes each file instead of re-reading it. The kubelet's answers once a container's log is gone (unable to retrieve container logs …, failed to try resolving symlinks …) never appear in the file, so the App pipeline no longer carries their drops. Before w4/m174 they were dropped there by their text, as the Postgres pipeline did until w5/m122. The direct pod-log read still re-types either body as the platform "logs for this instance are no longer available" line. One-time rollout effect: the first file-tailing replica on each node reads current container files from the start. Loki rejects lines more than its out-of-order window behind a stream's newest entry. Lines inside that window were shipped by the API tailer with a trailing newline that the file path does not carry, so they are not byte-identical and can appear twice in history once.
The file source must not lose lines to pod churn (w4/m174 t007, 2026-10-18). After the switch, production still lost about one line in six fast cron runs, a different line each time. scripts/log-shipper-kind-replay.py replays the production Loki and log-shipper charts on a throwaway kind cluster with the DoD command as Jobs. On Alloy v1.11.2 it lost 4 of 800 lines. Each lost line was read from its file (loki_source_file_read_lines_total) and never sent (loki_write_sent_entries_total), with nothing counted as dropped. The cause is v1.11.2's loki.source.file: it re-applies readers on every target change, which every pod churns, through a temporary drain goroutine that it then cancels, and an entry held mid-send is dropped. v1.11.3 forwards from one dedicated consumer and applies tasks separately, and lost 0 of 800. The log-shipper image is therefore built on v1.11.3 (deploy/log-shipper/Dockerfile), and the published digest also lost 0 of 800. Its --probe delayed mode, a321's begin marker, a 90-second wait and an end marker, kept 160/160 lines on both versions, which is too small a sample to confirm or rule out that single prefix loss. The replay is the check for any future Alloy bump.
Datastore pods tail CRI files too (w5/m122). The Postgres and Key Value pipelines moved to loki.source.file + stage.cri as well, so every tenant pipeline reads only what its containers wrote. The kubelet's answers once a container's log is gone come from its log API, never a file, and no tenant pipeline carries a message-text filter for them any more; the harness asserts that the app, build, Postgres and Key Value pipelines have file sources only. Every stage.cri caps a split line's reassembly at 200000 bytes, under Loki's 256 KiB limit, because rebuilding an unbounded one grows with its square and stalls the node's pipelines. The direct reads apply the same rule by source: every container line carries the timestamp the kubelet stamps with Timestamps: true, and its own answers carry none. An App read re-types an untimestamped line as the platform "logs for this instance are no longer available" line. A datastore read drops it, through the logs service and the datastore adapters' own fallback alike (datastorelogs.ParseLine). A follow is the one exception: it starts at its since second, and the kubelet stamps nothing on the rest of a split line whose first chunk it skipped as older. So a follow holds an unstamped first line, which is the kubelet's answer when the stream ends there and the tenant's text when more output follows. The rollout has the same one-time effect as w4/m174's: the first file-tailing replica on a node reads current Postgres and Key Value container files from the start. Lines inside Loki's out-of-order window can appear twice in history once, and the older lines Loki rejects raise the LogDeliveryLokiRejected and LogDeliveryEntriesDropped warnings once per node. Existing wrapped or placeholder lines already stored in Loki are unchanged and age out under normal retention.
A reassembled CRI record keeps its first chunk's timestamp (w4/m199). The runtime writes a long line, or one written in pieces with a delay, as partial (P) chunks completed by an F chunk, each stamped when it was written. The kubelet's pod-log API, and so the live tail, stamps the logical record with its first chunk's time. Upstream stage.cri (unfixed through v1.19.2) kept the last chunk's time on both merge branches, so a 50,075-byte record written in two pieces 120 ms apart reached history 120 ms after its live copy. The ids differed and the dashboard rendered it twice. The log-shipper therefore runs ghcr.io/bex-co/bex-alloy: Alloy v1.11.3 (v1.11.2 until w4/m174 t007) with one carried patch keeping the first chunk's time and extracted time on a repeated P and on the completing F. The recipe is deploy/log-shipper/ (pinned upstream commit, patch with its own regression test, official v1.11.3 image with only /bin/alloy swapped), published by .github/workflows/log-shipper-image.yml and pinned by digest in deploy/gitops/base/log-shipper.yaml. Message assembly, buffer keys (stream labels, so stdout and stderr stay apart), the 100-buffer count bound and the 200000-byte cap are unchanged, and a single F keeps its own time. scripts/test_log_shipper.py replays all four CRI pipelines (app, build, Postgres with CNPG unwrapping, Key Value) in the pinned image; BEX_ALLOY_IMAGE replays a candidate build before it is pinned. Records already stored with the last chunk's time keep it and age out under retention.
The pod-log producer keeps stdout and stderr records apart (w4/m200). The live tail reads one combined Timestamps:true pod-log response. The stock kubelet reader (k8s.io/cri-client ReadLogs) wrote each CRI partial chunk as it read it and tracked line starts with one flag shared by both streams. A stderr line written between a long stdout record's chunks therefore came out joined to the stdout prefix (32,768 + 89 bytes), followed by an orphan suffix. History was correct, and the dashboard showed the corrupt row beside it. No bex-api or frontend repair can restore the boundary once the response has erased it. Selecting one stream (alpha, and TailLines must then be nil) shares the same flag.
The node image therefore carries a patched kubelet (infra/packer/log-record-framing/, see infra/packer/README.md). It assembles each stream's record and writes it whole, stamped with its first chunk, as m199's collector does for history. It holds at most 1 MiB per stream, after which a longer record streams on as before. It flushes an unterminated record when a finite read or an exited container's follow ends. It skips a record whose first chunk is older than sinceTime, rather than emitting its unstamped rest, so the follow reader's held-unstamped-line rule now only meets the kubelet's own answers.
A TailLines start inside a split record still begins with that record's remaining chunks, which is the physical-line tail semantics. Live and Loki independence are unchanged. Proven on a real kubelet's pod API (kind, the same file read before and after the swap); production nodes get it only through a reviewed snapshot bake and roll.
A Loki label is a stream, so only bounded fields become labels; unbounded ones stay in the line and are filtered at query time. That single rule is the whole taxonomy:
| field | where it lives | why |
|---|---|---|
namespace, app or database or service |
label | the scope of every query — equality matchers resolved after App/Database authorization for tenant streams; service=dashboard is the bounded platform-UI key (w4/m88), never a caller-controlled selector |
type (app/request/build/postgres/platform) |
label | bounded source vocabulary |
pod, container |
label (app/platform logs) | bounded by replica count; store pod projects to Render's public instance id (name-derived; see ADR035 / w5/m87) |
level |
label (app/platform logs) | 5 values, hard-capped by the shipper's normalizer (below) |
method |
label (request logs) | the HTTP verb set (≤8) |
status |
label (request logs) | the status codes an App actually returns (≤~15); Render's statusCode |
path, host |
line only | unbounded per request — one label per URL would mint a stream per URL |
Worst case ≈ (replicas × levels) + (methods × statuses) streams per App — low hundreds; typically under 20. path/host are still fully filterable: the access line is JSON, so a query parses them out with LogQL's | json stage (request_path/request_host) instead of indexing them. Promoting path to a label would be a cardinality incident — TestPathAndHostNeverBecomeLabels guards it.
Request logs (type=request) are Traefik's JSON access log (logs.access in deploy/gitops/base/values/traefik.values.yaml), attributed back to the App by the access line's ServiceName — Traefik names an Ingress-backed service <namespace>-<service>-<port>@kubernetes, and the operator names the k8s Service after the App CR, so the shipper reconstructs the {namespace, app} labels bex-api's LogQL selector queries by (namespace = app.Namespace, app = app.Name) from that name. Tenant-namespace fix (w6/m131): unlike the app/build/postgres pipelines — which read the namespace straight from pod metadata — the request pipeline is the one that parses it out of ServiceName, and its regex was anchored to the literal default. Under ADR043 a tenant App lives in its workspace's own namespace (tea-<xid>) and its CR name is itself tenant-prefixed, so every real ServiceName is tea-<xid>-tea-<xid>-<app>-<port>@kubernetes — which the default-anchored regex never matched, so every tenant access line was dropped as not_a_tenant_app and type=request was silently empty for every service in production (found live, 69th /qa-find-bugs run). The regex now matches a tea-<xid> tenant namespace as well as the shared/storeless default; a mislabel could only ever hide a line, never surface another tenant's, because bex-api pins both namespace and app to the caller's own resolved values. A line the regex can't attribute is dropped, not guessed, except for an explicit host allowlist (w4/m88): RequestHost=dashboard.bex.co is retained under the bounded labels namespace=dashboard + service=dashboard (still type=request); host itself stays line-only. Other non-App edge hosts (bex-api, oauth, …) remain dropped until explicitly allowlisted the same way. Request headers are dropped at the source: they carry Authorization/Cookie, and a request log is not a place to leak a credential. So are four platform-internal fields (w8/047): RequestCount (Traefik's process-wide counter, which would let any tenant sample total edge traffic), ServiceAddr/ServiceURL (pod IPs) and RouterName (internal routing names). The message stays the raw JSON access line (every kept field intact and searchable) rather than a prettified summary — a divergence from Render's rendered request line, in favor of losing nothing a tenant may see. The kept fields are an allowlist (w5/076): accessLog.fields.defaultMode: drop with every field tenants already saw named keep, so a field a Traefik upgrade adds stays out of tenant logs until it is reviewed and listed. access_log_fields_test.go pins the list and fails when a field the shipper or bex-api reads is not kept. The chart-rendered flags were checked against Traefik v3.7.5 itself: the same request logs the same fields before and after.
level on app logs is parsed, never guessed. The shipper parses JSON and logfmt app lines and promotes their level (or severity) field, normalizing the spellings (err/fatal/panic/critical → error, warn → warning, notice → info, …) into the five buckets error|warning|info|debug|unknown. warning is Render's name, the value the pinned CLI's --level sends. Before w8/031 the bucket was warn, and a level=warning query matched nothing. The query side maps every Render level name onto the stored buckets (warning → warning|warn, so streams stored before the change stay findable within retention; notice → info; critical/alert/emergency → error). unknown stays: Render has no such value, but labelling an unclassified line as a guessed severity would be worse (decision recorded in w8/031). This includes Go's slog.NewTextHandler output (time=… level=ERROR msg="request failed"). A line without a recognized structured severity is labelled unknown — the honest answer, because bex does not know. Substring matching is deliberately not used: plain ERROR … and msg="level=error" remain unknown. bex recommends structured logging; it does not require it. scripts/test_log_shipper.py renders the locked Helm chart and runs its actual app pipeline in the chart's Alloy image, asserting JSON/logfmt extraction, normalization, message-only exclusions, and the five-value cardinality budget.
Historical line queries use Render's logs filter vocabulary, with the supported matching behavior and source limits below:
| filter | mechanism | notes |
|---|---|---|
type |
stream selection | app ∪ request ∪ build; default (no filter) = app ∪ request |
level |
label matcher | App and managed-Postgres logs; unknown is a real, queryable App value |
instance |
label matcher (pod) after translating the public instance id (or legacy raw-name bookmark) back to an authorized pod name |
app logs — a request line comes from the edge, not a replica |
statusCode |
label matcher (status) |
exact (404) or class (4xx) |
method |
label matcher | request logs |
path, host |
| json line filter |
request logs; unbounded, so never labels (above) |
text |
line filter | case-insensitive literal substrings; OR across all supplied values |
textMode |
Bex extension | raw (default) matches stored bytes; display matches the terminal-display text (below) |
startTime/endTime |
query range | |
direction |
backward (default) / forward |
which end of the window limit keeps, and the page's order: backward lists the most recent first, forward the oldest first (Render's List logs contract, w8/m116). hasMore says whether a line lies beyond the page: each page reads one line past limit, and when a shared-timestamp group kept whole absorbs that line, one more past the page, so a cursor walk does not land on an empty page. The bex mobile app and the dashboard order what they show by time, not by page order |
limit |
line cap | default 20, max 100 (Render's paging range) |
task, taskRun, sandbox |
refused | a named 400 on /logs, /logs/values and /logs/subscribe: Render's Workflows task-run and sandbox streams have no bex counterpart (ADR018 omission, DO_NOT_DO #18). They used to pass the strict-query gate and be ignored, which widened the read to every log (w8/132); TestEveryDeclaredLogFilterIsParsedOrRefused keeps every declared filter parsed or refused |
Managed Postgres uses the shared time/text/direction/limit behavior plus instance and level; service/request-only filters are named 400s. Severity filtering uses the same Render aliases and OR/* wildcard semantics as App history, so level=error includes normalized PostgreSQL ERROR/FATAL/PANIC records. The CNPG pod-buffer fallback applies the same severity match to its bounded raw tail before capping matching results; it does not recover older or restarted-pod history. Level discovery uses Loki and returns ErrLogStoreUnavailable when that source is absent. Instance discovery is scoped to {namespace, database} in Loki and falls back to the already authorized Database's exact CNPG pod set. See the captured contract and attribution rules. Managed Key Value (Valkey) supports time/text/direction/limit/instance, with no severity filter; instance discovery is scoped to {namespace, keyvalue} in Loki and falls back to pods matching the app.bex.co/keyvalue=<red-id> label. Live-tail (FollowLogs) returns a named 400 for Key Value resources — there is no persistent Valkey connection-scoped log stream to tail.
Multiple values for one line-query filter OR together; different filters AND. For example, text=errorMarker&text=infoMarker returns lines containing either marker while retaining the resource, time, instance and other supported filters. A line matching both terms is still returned once. Reversing the terms or repeating them does not change the result set or paging behavior.
Text terms (w2/m167). REST retains every text occurrence and MCP list_logs retains the full text array. The shared query drops exact empty strings, lowercases and deduplicates the remaining terms, and treats an omitted, empty or all-empty set as no text filter. It does not trim whitespace: " error " searches for those spaces too. Each term is a literal substring, including *, ., |, quotes and backslashes; neither commas nor regex syntax are interpreted by the server. A scalar GraphQL/dashboard search remains one term, so text: "error,info" searches for that literal comma-containing string. Datastore convenience reads likewise keep their scalar text contract. The same term set reaches App, Postgres and Key Value history, bounded pod-buffer fallbacks, the explicit pre-deploy pod read, App/build followers, and platform build-progress lines. This does not add unsupported sources or filters.
The Render CLI reference documents comma-separated --text values, and the logs API defines text as an array. At Bex's exact Render CLI v2.27.0 pin (a764810a768202704e7206eb7b87a47211fcd98e), cmd/logs.go uses StringSlice; logloader.go preserves the array, and the generated client emits repeated query keys. Thus bex logs --text errorMarker,infoMarker supplies two terms without a launcher rewrite. These sources establish the array/wire contract; OR within text and AND across filters is Bex's contract above, not a claim of a live Render-server comparison.
Display text search (w4/m189, a Bex extension). Log lines are byte-transparent, so a colored message stores its ANSI escapes (QA_\u001b[31mred\u001b[0m_end) and the raw text match cannot find the visible QA_red_end — the escape breaks the substring. The optional textMode=display (REST/subscribe query parameter, GraphQL logs/logLabelValues argument, MCP list_logs/list_log_label_values field) instead matches the same case-insensitive literal terms against the text a terminal shows: displayText (internal/logs/display.go) mirrors the dashboard's stripAnsi (dashboard/src/features/logs/lib/ansi.ts) — CSI/SGR dropped, erase-in-line applied, OSC envelopes and other escapes swallowed, malformed tails consumed, and carriage returns overwrite so hidden text never matches. Returned messages stay raw in every mode, so colors render and ids/hashes are unchanged. Omitted or raw keeps the exact pre-existing semantics, responses and errors; any other value is a named 400. Loki cannot evaluate the projection, so a display search sends only a superset prefilter (any term, or any line carrying ESC or CR — a line without either displays exactly as stored) and reads raw pages of up to 1000 lines inside the same 18 s budget, matching exactly in Go. Coverage advances by raw lines read, not matches: when a raw page fills, coverage stops at its far timestamp and that timestamp group is re-read whole, the caller's inclusive endTime is applied once so a full page on that instant still progresses, and a match behind thousands of unmatched lines is reachable and a budget-exhausted scan pages on (hasMore + cursor) rather than claiming unscanned history is empty. The pod-buffer fallback, followers and build progress apply the same predicate. The dashboard opts in only on the service Logs tab (history and live tail); the deploy panel already matches stripAnsi client-side, and the datastore viewer and build follower keep raw defaults.
Record identity in the readers (w4/m217). GraphQL LogEntry.id is REST's per-record id (instance, original timestamp, message hash). The dashboard keys every identified line by that id, whatever its timestamp precision, so distinct records written inside one millisecond stay distinct. This includes a record stamped exactly on the millisecond, which RFC3339Nano writes with three digits (a872). Before, twenty such records collapsed into one row, keyed by millisecond, instance and message. Live and history canonicalize the stamp before deriving the id, so one record always carries one id. Only an ID-less line keeps the millisecond-bucket key. As a fallback for those, LogIdentity treats the first ID-less copy and the first identified record in a bucket as one record, in either order, so the w9/053 precision-drifted reconnect replay still draws once. Two different ids are never paired. History, older pages, deploy logs, the live ring and the history/live merge all share it. LogEntry stays an embedded cache object (keyFields: false). The field shipped to bex-api before the dashboard selected it, so no dashboard queries a backend that lacks it.
Display changes keep the reader's line (w4/m228). The shared virtualized log list (service, deploy and datastore readers) keeps the reader's logical line when wrapping or timestamps are toggled: it saves the top row's key and offset (or the pinned end), drops and remeasures row sizes, turns off the virtualizer's own size-change scroll compensation and native overflow anchoring while it settles, and re-places that row by key on each measurement pass with the offset clamped to the row's new height. Settling ends after a quiet 250 ms, at most 1 s after the toggle, or on reader input (including Jump to latest); a near-end row clamps to the reachable range without padding.
One case fold, server and deploy panel (w4/m216). The server lowercases terms and messages with Go's strings.ToLower, which maps one rune at a time (unicode.ToLower). The deploy log panel filters lines it has already loaded in the browser, where JavaScript's toLowerCase behaves differently: it folds a word-final Σ to ς, and İ to two code points. So οσ missed an uppercase ΟΣ line, and ος wrongly matched it. The panel now folds through goLowercase, a compact runs table of Go's mapping. lego/backend/internal/logs/testdata/lowercase-runs.json is pinned to unicode.ToLower over every scalar value by a Go test (regenerate with GOWORK=off BEX_UPDATE_LOWERCASE_RUNS=1, the toolchain bex-api is built with, after a Go upgrade), and the dashboard's copy is pinned to that file. Other dashboard searches and every server contract are unchanged.
Render also documents wildcard and regex searches. Bex text search is literal, while structured level/status/method and request path/host matching supports the * wildcard subset. Other metacharacters stay literal. Loki's text predicate escapes each term before joining the alternatives, and every interpolated value is escaped (%q + regexp.QuoteMeta), so a term cannot inject a selector or pipeline stage.
Source limits and refusals:
- App pod-log fallback mode (
BEX_LOKI_URLunset): the labels live in the store, not in a pod's stdout — sotype=request,type=build, and thelevel/statusCode/method/path/hostfilters return 503 (ErrLogStoreUnavailable), rather than quietly answering a narrow question with unfiltered lines.type=app,text,instance, time anddirectionstill work, unchanged. - The live tail reads pod logs by design (see above). It supports the App tail and an explicit, standalone
type=build, including every text term; it refuses Postgres/Key Value tails, request/pre-deploy tails, mixed build types, and store-only filters. Admission errors are HTTP errors; after the stream opens, errors use the selected transport's terminal error frame (SSE usesevent: error). A build subscription with no active or pending build ends with the namedno running build is available to followerror. Severalresourcevalues are all followed and merged onto the one stream in arrival order (w8/024; it used to follow only the first). Each resource is authorized and holds its own subscription slot before the stream opens, and the first follower to fail ends the tail with its refusal. - An unknown
type,directionor label is a 400 naming the value — never a silently widened query.
Static-site request attribution (w4/m113). A static site's ServiceName names the per-App ExternalName alias the operator points at the shared static server — <ns>-bex-static-<app>-<port>@kubernetes (production ground truth, 2026-09-19: "ServiceName":"default-bex-static-hello-static-8080@kubernetes"). The attribution regex therefore recovered app=bex-static-<app>, a name no App has, so every static access line shipped with a label bex-api's selector could never match and type=request was empty for every static site — with bandwidth for the same traffic correct, which is what made it look like a metrics bug rather than a logging one. Traefik already puts the answer on the line: KubernetesIngressName is the App CR name (the operator names each App's Ingress after the App), for compute services too — verified the same day against both shapes. The pipeline now prefers it and keeps the ServiceName regex as the fallback, gated on that regex having matched so a platform edge line (which also carries an Ingress name) still falls to the bounded host allowlist instead of the tenant branch. Both captured production lines are pinned verbatim in shipper_attribution_test.go.
But "the store is up and this stream is not being produced" is not one of those refusals (w6/m131). The rules above all key on the store being absent. When the store is present and healthy but a stream is silently empty — as type=request was for every tenant service until the attribution fix above — the query takes the success path and returns 200 {"logs":[],"hasMore":false}, byte-identical to a genuinely quiet service. That is deliberate, and it is a limit rather than an oversight: bex cannot distinguish the two from a single resource's vantage point. Loki's label index is empty in both cases, so there is nothing to key on, and inventing an error would misreport every quiet service. The honest empty stays.
What closes the gap is out-of-band and platform-wide, where the distinction is decidable — across every tenant app, over hours, zero request lines means the pipeline, not the traffic:
scripts/request-logs-liveness.shreads Loki's label index every 6h from.github/workflows/ssh-edge-liveness.ymland fails loudly (opening a tracking issue) when a tenant log pipeline has no stream for anytea-<xid>tenant namespace. The tenant qualifier is the whole point:scripts/logs-verify.shexercisesAPP_NS=default, which is exactly the namespace the broken regex still matched, so it would have passed green throughout the outage. It now also asserts that the live deployed ConfigMap'sServiceNameregex attributes a tenant namespace — the check that actually answers "did the GitOps change reach the running shipper?".- Both tenant pipelines, one probe, two issues (w3/m83 t002).
STREAM_TYPESdefaults to"request app", so the same label-index read now also assertstype=app— the pipelinew3/m36found empty for every tenant, the identical silent zero on the other side. The types stay separate all the way to the tracking issue (request-logs-downvsapp-logs-down) because their remediations are unrelated: a dark request stream points at theServiceNameattribution regex, while the app pipeline reads its namespace straight from pod metadata, so the suspect there is the Alloy DaemonSet's reach and itsapp.bex.co/appselector. The script emits oneRESULT type=<t> status=live|darkline per type and the workflow keys the issues off those, not off the exit code — exit2already means "the probe could not run", and a type the probe never reached must not open an issue on evidence nobody gathered. Build and datastore streams are deliberately out of scope, with reasons recorded in the ADR088 §6 coverage table. TestShipperRegex*(lego/backend/internal/logs/shipper_attribution_test.go) compiles the regex out ofdeploy/gitops/base/log-shipper.yamlin CI, so the config and the query cannot drift apart again.
Discovery must not offer what a filter cannot match (w6/m131). host values come from app.Status.URLs rather than the store — the cardinality budget keeps host in the line, not in a label — which is why host discovery resolves even with no store wired. That is kept: it is the correct answer to "which hosts does this service serve", and with attribution fixed the host line filter matches exactly those values. The dashboard's static Method/Status-code/Level fallback lists are narrowed instead: they stand in for "we could not ask" (still loading, or discovery 503s with no store) and are dropped once logLabelValues answers with an empty set, so the Filters popover no longer advertises GET·POST·PUT·PATCH·DELETE on a service whose store says it has produced no request lines.
| method + path | effect |
|---|---|
GET /v1/logs |
historical query → {hasMore, next*Time, logs} |
GET /v1/logs/values |
filter-value discovery → a bare ["…"] array |
GET /v1/logs/subscribe |
live tail over WebSocket, Server-Sent Events or NDJSON |
graphql { logs(...) } |
same paging envelope, with flat LogEntry rows |
graphql { logLabelValues(...) } |
same discovery (the logs sibling of metricsFilters) |
MCP list_logs |
agent read (Core.QueryLogs), resource array + the full filter set |
MCP list_log_label_values |
agent discovery (Core.LogLabelValues), label + resource + the same filters |
Query params (Render vocabulary): resource (repeatable App, managed-Postgres or Key Value id), plus the filters above — type, level, instance, host, statusCode, method, path, text (all repeatable), startTime/endTime (RFC3339), direction, limit. /v1/logs/values takes the same set plus a required label; Postgres supports instance discovery and durable severity-value discovery. One Core verb per read (QueryLogs, LogLabelValues) backs REST, GraphQL and MCP. An omitted endTime means now. An omitted startTime reads back seven days. That matches retention, so a bare query surfaces stored history; it deliberately differs from Render's documented now() - 1 hour. The page cursors name that same window: they used to default to one hour, which inverted a backward page's next window once its lines were older than an hour, and the next request refused it with a 400 (w8/125). A startTime after now with no endTime therefore names a window that has not begun, and every surface answers it with an empty last page. That includes the Postgres and Key Value log reads. Before w8/120, Loki was sent the inverted range and the read failed with a 500. A deploy's log panel can ask this way, because it starts from the deploy's creation time and the database clock may run a moment ahead of the API's. An explicit inverted range is still a 400. An endTime alone is fine even in the future: the default lookback and the page cursors count back from whichever is earlier, endTime or now (w8/120). The read stops at now (w8/123). No line is stamped after now, so the window sent to the store ends there even when endTime is later. It ends ten minutes past the API's clock, the creation grace Loki allows a node whose clock runs ahead. An endTime inside that grace stays the caller's own inclusive end. Before this, a far-future endTime with no startTime sent the caller's end. Once that lay more than Loki's 720-hour maximum past the default start, the read came back as a 500; just under it, it walked weeks of empty future until a 503 timeout. BEX_MAX_QUERY_HOURS measures the same window that can hold lines, so startTime = one day ago with endTime = 40 days ahead reads that day. A startTime older than the cap is still a 400, and a window that has not begun is an empty page. Metrics differ: an omitted start counts back its default span from endTime, so a far-future endTime alone reads an empty future window, not an error.
Label discovery reads a stream index, not log lines. REST /v1/logs/values, MCP list_log_label_values and GraphQL logLabelValues accept their usual text arguments, but text, path and host do not narrow discovered values. Stream-label filters and time bounds still narrow the Loki index lookup. Preserving every text term in the shared REST/MCP parser does not change that limitation. App host discovery comes from the App's URLs, and datastore instance discovery can fall back to authorized pods; neither source evaluates line text.
Discovery is scoped to the App, always: the label-values call goes to Loki with the requested service's stream selector, so no caller can enumerate another tenant's pods, hostnames or statuses. host is the exception that proves the taxonomy — it is not a stream label, so its values come from the App's own status.urls (core.HostsFromURLs), which is why it resolves even with no store wired. (Logs-only: the metrics feature's discovery deliberately offers no HOST values — its query verb rejects the filter, w3/m12.)
The REST log object is Render's public-API log (all fields required): {id, message, timestamp, labels[]}. bex synthesizes a stable id from instance + timestamp + a message hash, and renders Core's map labels as Render's {name,value} array — type, resource (Core's service), instance, container, level, method, statusCode. A line carries only the labels its stream actually had: an app line has no method/statusCode, a request line no instance/container. Nothing is faked to fill the shape. The envelope carries all four required fields: hasMore, nextStartTime, nextEndTime, logs. The cursors name the next page's window for the query's direction — feed them back as startTime/endTime (Render's documented contract, and the official CLI's scroll-to-load path). Backward (default): nextStartTime = the query's start, nextEndTime = one nanosecond before the page's oldest line (so an inclusive endTime does not re-include the boundary). Forward: nextStartTime = one nanosecond past the page's newest line, nextEndTime = the query's end. Shared-timestamp siblings at a cut are kept on the same page (capToLimit expands the group) so the time cursor cannot drop one. GraphQL logs and MCP list_logs return the same envelope (w4/m107). (MCP's entry objects still use map labels verbatim, matching Render's MCP server — each adapter mirrors its own Render counterpart.)
A search is time-boxed and pages on honestly (w4/m140). A line filter (text, path, host) makes Loki read every line in the window, so a no-match search over a week of a busy service outlasted the 30s HTTP write deadline. The connection was cut before any answer was written, and the edge replied with a CORS-less 502 the dashboard could only report as "Failed to fetch". The durable source now scans a line-filtered window in slices from the end the direction keeps (1h, 2h, 4h, …) and stops when the page fills, the window is covered, or an 18s budget is spent. Any nonempty normalized text set selects this bounded scan, even when earlier supplied terms were empty; multiple terms form one OR predicate within the same budget. A budget-stopped read returns the matches it covered with hasMore:true and the cursor at the coverage edge, so the next page resumes exactly there. All three surfaces share this through one page builder, and across several resources the least-covered one bounds the page. Only when not even the first slice finishes does the read fail, as a named QUERY_TIMEOUT (503 on REST, a coded GraphQL error). Label-only reads stop at limit inside Loki and stay a single request. Separately, GraphQL execution is bounded at WriteTimeout − 5s, and any resolver that runs into it reads QUERY_TIMEOUT rather than a severed connection.
Relative-range reader continuity (w4/m151): the service Logs viewer identifies a reading session by resource, filters and selected range, separately from the moving request bounds. Automatic relative-range ticks retain loaded rows still inside the window and the older-page continuation while refreshing the head; the mounted list preserves an unpinned reader’s visible anchor. A deliberate resource/filter/range change resets the reader and rejects stale completions. Transient refresh errors remain visible alongside retained rows; authorization loss or resource absence clears them. Custom intervals stay fixed. Datastore viewers keep their existing fixed resolved windows, deploy history keeps its deployment-window reset policy, and Metrics/Scaling retain their clocks. The API’s 100-row cap, cursor envelopes and live transport are unchanged. Render’s logging docs, checked 2026-10-02, establish preset/custom/live controls, not this exact idle-clock guarantee; local regression coverage is not a hosted walkthrough or authenticated Render execution.
Selection-time relative clock (w4/m203): selecting a relative Logs or Metrics range samples the clock in that same render, so the first query ends at the selection time rather than at the page's mount or last tick; this covers preset → preset, Custom → preset (the same preset included) and a span-only change. The window then advances at its usual resolution cadence. Custom bounds stay fixed, and the API keeps honoring caller-supplied bounds without resampling.
Logs filters follow navigation (w4/m204): the service Logs viewer treats the URL as the authority for its filters and Live state. A URL it did not write — the service nav link, Back/Forward, a pasted link — is adopted in the same render, so the history query and the live-tail predicate describe the destination at once and the page never shows a narrower reader than its URL names. Its own debounced writes echoing back are recognized and leave newer typing alone. Datastore viewers and deploy log panels keep their separate state models.
One record, one id across history and live (w4/m96): because the id hashes the message, the durable-history reader (parseLokiStreams) canonicalizes each container record's message to the exact bytes the live/fallback pod reader emits — stripping only the single kubelet transport line terminator (\r?\n, precisely bufio.Scanner's ScanLines boundary), never trimming data whitespace, an embedded/JSON-escaped newline, or an empty record. The API-sourced pipelines (loki.source.kubernetes) keep that terminator (Alloy's parseKubernetesLog returns the timestamp-stripped remainder including the LF); the file-sourced ones (type=build, type=app since w4/m174, and type=postgres and type=keyvalue since w5/m122) go through loki.source.file + stage.cri and are already terminator-free; only a final \r, which the live reader drops too, is removed, and type=build is left byte-for-byte untouched. Without this, a line straddling the last historical page and the first live frame carried two message-byte shapes and two ids, so the viewer's key-based merge rendered it twice — a recurrence of the w9/053 duplicate through a different input mismatch. The same reader then applies the live reader's 64 KiB message cap (capLogMessage: a UTF-8-safe prefix plus …[truncated]) to every historical message, all types included, so a record over the cap is returned in one capped shape from history, live, and the pod-log fallback alike rather than whole from history and capped from live (w4/256). Text search, raw and display, matches the returned message, so a term that exists only beyond the cap does not match on any path. Raw timestamps and resume cursors are untouched; a CRI-split record whose historical and live nanosecond stamps differ still derives two wire ids.
Render's type is app/request/build, and bex serves all three (w7/m28) plus a bex-native predeploy (w1/m33):
app— the App's own container stdout/stderr, from every replica pod (labelapp.bex.co/app=<name>), aggregated.applicationis accepted as an input alias.request— Traefik's access log for that App (see Log labels), with truthfulmethod/statusCodeand a searchablepath/host.build— the in-cluster BuildKit/kpack output for a git-backed deploy, shipped by thebuild_podspipeline indeploy/gitops/base/log-shipper.yaml. Build pods carryapp.bex.co/component=build+app.bex.co/build=<name>(w7/m28); the shipper attributes them to the App and pushes{namespace, app, pod, container, type="build"}streams. Without the durable store (BEX_LOKI_URLunset) a historicaltype=buildquery returns 503, not a silent empty — the same honesty rule astype=request. The SSE subscription is independent of Loki and follows the newest running build pod directly (w3/m14). Pre-deploy Job pods (component=predeploy, w5/m100) are tailed by the same pipeline and also land astype=build, so Render-compatible clients whose--typeenum is closed overapp|request|buildcan retrieve migration stdout afterpre_deploy_failedwithout the bex-onlypredeploytype.predeploy(bex extension, w1/m33) — the pre-deploy step's Job-pod logs (spec.preDeployCommand, ADR004): a live read of thepredeploycontainer on the pod labelledapp.bex.co/predeploy=<name>in the App's namespace — the precise bex extension (mixing it withapp/request/buildis a 400). The same Job's stdout is also shipped into Loki astype=build(w5/m100), so after the pod is TTL-reaped the output remains on the Render-compatiblebuildpath; the livepredeployread is still empty once reaped. Instance filters resolve public IDs and legacy raw names against only the authorized App’s extant pre-deploy pods before reading logs (w2/039). Unknown, foreign and App-pod selectors return no lines; text/time filters remain AND constraints. This source does not use historical App-instance discovery or provide a pre-deploy subscription.
Asking for no type (or all) unions app + request — the default a Render client sees. Build logs are only included when the caller explicitly requests type=build; predeploy is likewise a separate, explicitly-requested source, never in that union.
GET /v1/logs/subscribe follows every authorized resource: a WebSocket upgrade serves the Render CLI, Accept: text/event-stream selects SSE, and other requests receive NDJSON. Each matching line is emitted once in the selected format; SSE uses data: <log JSON>. type=build selects the active build pod; absent type keeps the established App-pod tail. Text terms OR together on both producers, with the supported time and instance constraints still applying. The handler clears the server write deadline (http.NewResponseController) so the long-lived stream isn't killed by the api's WriteTimeout; the stream ends when the client disconnects (request context cancelled), or with a named terminal error when the requested source cannot be followed. Datastore tails (w5/m139): a dpg-… or red-… resource follows the datastore's pods' process container (CNPG postgres, valkey) with the same per-line parsing as the historical pod read — CNPG instance-manager JSON dropped, a PostgreSQL record unwrapped with its severity as level, a Valkey line's marker as its level — under the same authorization (AuthorizeDatabase/AuthorizeKeyValue, re-checked fresh by the tail watchdog), and instance, text and level narrow it per line. Each resource is validated against its own contract before any streaming header or WebSocket 101, so level is accepted for a datastore and still refused beside an App's pod tail. Pre-deploy tails remain unsupported.
Shapes verified against render-public-api-1.json and render-oss/render-mcp-server (pkg/logs/tools.go): the full filter param set, the {hasMore, nextStartTime, nextEndTime, logs} envelope, the {id, message, timestamp, labels[]} log object (with the label-name enum), and both MCP tools' names/arguments (list_logs, list_log_label_values — the latter's label enum is Render's exact six). Known, intentional deviations:
- subscribe transport — Render-compatible WebSocket (
101upgrade), with SSE and NDJSON alternatives for browser and terminal clients. ownerId— Render requires it; bex is single-tenant and omits it.- text is literal; structured wildcards are a subset — Render documents wildcard/regex text search; bex ORs case-insensitive literal text terms and supports
*in the structured filters named above (see Log filters). textMode(Bex extension) — Render documents no ANSI-aware search option;textMode=displayis bex-only and opt-in, and omitting it keeps Render's raw literal contract (see Log filters).type=build— requires the durable store (BEX_LOKI_URL); without it returns 503 (w7/m28 — the same honesty rule astype=request).- request-line message — the raw JSON access line, not a rendered request summary.
- GraphQL arity —
type/textonlogsandlogLabelValuesstay single-valued strings (the shape the dashboard sends for history); generic REST and MCP take Render's arrays. A scalar text value becomes one term without comma splitting. The request filters are lists on all three. Thelogsfield returns Render's{hasMore, nextStartTime, nextEndTime, logs}envelope (w4/m107), not a bare[LogEntry]list. - label discovery — text/path/host line predicates cannot narrow stream-index values, even though the discovery adapters accept these arguments.
get_postgres_logs/get_key_value_logs(MCP) — bex-only convenience tools; they stay a bare{logs:[…]}without the paging envelope. Agents that need to page uselist_logswith thedpg-/red-resource id, which carries the same envelope as REST/GraphQL.
The api ServiceAccount reads pods (get/list/watch) and pods/log (get) — added with the logs verb in lego/operator/config/api/rbac.yaml. No clientset lives in Core; only podlogs.go (and its main.go wiring) touch it. The Loki-backed history reaches Loki over HTTP (BEX_LOKI_URL), not the kube API, so it needs no extra bex-api RBAC — the log-shipper DaemonSet's own ServiceAccount (its chart's ClusterRole) does the pod-log reads, exactly as the Prometheus scrape's ServiceAccount does for metrics.
Render's logging guide tells apps to log the per-request Rndr-Id header and search for it, and its request logs carry the same value as requestID. The bex-client-ip-headers entrypoint plugin (ADR022) mints a random UUID-shaped Rndr-Id for every request. It replaces any caller value, both spellings included, and sets the id on the request to the app and on the response. Traefik's access log keeps that one request header (accessLog.fields.headers.names.Rndr-Id: keep; every other header stays dropped), so each request line carries request_Rndr-Id. bex-api reads it from the line when parsing request entries, as the requestID label. It is never a Loki stream label, since it is unique per request. Request lines shipped before the rollout have none. The dashboard's request row shows the raw access-log line, so the id is visible and searchable there too. Verified on the pinned traefik:v3.7.5: the response header, the app's rndr-id and the access log's request_Rndr-Id were one minted value for a request that sent a forged one.
An App's streams, pods and Traefik routers are keyed by its CR name, which follows the service name. Deleting a service and creating another with the same name therefore handed the newcomer its predecessor's history: paths, client IPs, SIGTERM lines and metric points. Render scopes logs and metrics to the service id, so every App read is now scoped to the current incarnation:
- Stream label. The log shipper copies the pod's
bex.co/app-idinto anapp_idstream label (deploy/gitops/base/log-shipper.yaml,app_pods). App reads addapp_id=~"<id>|"beside the namespace/app tenant matchers. The empty alternative keeps streams shipped before the label, and Job pods, which carry no id label. A predecessor's terminating pod carries its own id and is excluded. - Creation floor. Every log read (history, the initial tail, label values,
--tail) and every metric read starts no earlier than the App CR'screationTimestamp. The floor only narrows: an App older than the default 7-day lookback keeps the lookback. It removes the predecessor's unlabeled history and its request lines, which Traefik streams carry no pod label for. Month-to-date bandwidth starts there too. - Pods. Every pod list (live tail, instance discovery and filters, the pod-log fallback, metrics' snapshot paths) drops pods labelled with another id (
core.OwnAppPods). - cAdvisor series. Pod names cannot tell incarnations apart, so pod-scoped PromQL ends in
unless on(pod) (kube_pod_created{namespace=…} < <creation unix>). That isunless, notand, so a pod kube-state-metrics has no series for still charts.
Postgres and Key Value already select by immutable id.
The same one-Core-many-adapters shape as logs. Core.Metrics(MetricQuery) is the single read; REST (internal/metrics/rest.go) and GraphQL (internal/metrics/graphql.go) are the surfaces. Two backends, each an injected source so Core stays clientset-free (like PodLogSource):
flowchart LR
rest["REST GET /v1/metrics/{cpu,memory,instance-count,<br/>http-requests,http-latency,bandwidth}"] --> core
gql["GraphQL metrics(...)"] --> core
core["Core.Metrics"] --> rr["ResourceMetricsRange source"] --> promc["Prometheus ← cAdvisor (kubelets)"]
core -.fallback.-> rm["ResourceMetrics source"] --> ms["metrics-server (metrics.k8s.io)"]
core --> pods["App pods (limits + fallback instance count)"]
core --> qm["RequestMetrics source"] --> prom["Prometheus ← Traefik"]
- Resource —
cpu/memory/instance_countcome from cAdvisor scraped by Prometheus (NewPrometheusResourceSource, gated byBEX_PROM_URLlike the request metrics): aquery_rangeovercontainer_cpu_usage_seconds_total(per-pod rate → cores) andcontainer_memory_working_set_bytes(per-pod sum → bytes; also counted forinstance_count), one stepped series per replica taggedinstance+resource. The publicinstancelabel is the name-derived service-instance id (same asserviceInstances/ logs), not the raw Kubernetes pod name — PromQL still matches by pod name internally. The cpu and memory sums first takemax by (pod, container)so cAdvisor's duplicate series for one container across a restart do not double-count: while a container is OOM-killed and its replacement starts, cAdvisor briefly reports both instances (same pod + container, different cgroup id), and a baresum by (pod)added them — a Guaranteed 512 MiB App momentarily read ~745 MiB (≈145% of its enforced limit) at every OOM restart, which looked like an enforcement gap when the limit was in fact applied (w4/050, prod-confirmed: single-instance peak 508.9 MiB vs the double-counted 743 MiB; the operator sets requests==limits per tier at Guaranteed QoS, so the pod is OOM-killed at its plan limit rather than exceeding it).instance_countneeds no such guard — its innersum by (pod)already collapses a pod to one series. Since kubelet metrics carry pod names but not pod labels, an App's pods are matched by the Deployment pod-name shape — anchored, sowebnever matchesweb-apipods. The selector (egressquery.PodNameRegex, shared with the usage meter) accepts two shapes, because Kubernetes generates only one of them reliably: the untruncated<obj>-<rs-hash>-<5 random>, and the single-segment<obj>-<N alphanumerics>thatgenerateNameleaves once the base<obj>-<hash>-passes 58 chars and the cut eats the separating hyphen. A truncated name is always exactly 63 chars, soNis pinned to62-len(obj)rather than left open — that exact length plus the hyphen-free character class is what keeps a sibling App's pods out.<obj>is the Kubernetes object namecore.CRName(tenant, name), never the workspace-scoped public service name (w6/m110). Withpercentage=true, every sample is divided by its own instance's trustworthy limit at that same timestamp and reported 0..100 — normalization precedes replica aggregation, so mixed-limit replicas (0.4 of 0.5 vs 0.5 of 1 CPU) read 80%/50% and MIN/MAX/AVG aggregate to 50%/80%/65% (w5/m90). The denominator is the limit history (NewPrometheusResourceLimitSource:query_rangeover kube-state-metrics'kube_pod_container_resource_limits, summed by pod, same Prometheus as the usage history — no new scrape config), joined per timestamp with step interpolation, so a rollout mid-window keeps the old limit for old samples. Samples with no trustworthy denominator — a deleted pod (never another pod's limit), a sample predating limit retention, a zero limit — are omitted, never zero-filled or borrowed; a limit-source failure rejects the read rather than half-joining. Without a limit source (a test/dev shape — production wires both together) the pods' current spec limits stand in. Every tiered App has one (see ADR003-control-plane.md's tier catalog); this path only fires for a bare-CR App with nospec.tierset. - Resource fallback — without
BEX_PROM_URL,cpu/memorycome from metrics-server (metrics.k8s.io/v1beta1, viaNewResourceMetricsSource): a point-in-time snapshot, so each series carries a single current point regardless of the requested range.instance_countthen derives from the App's pods directly, needing no source at all. When Prometheus is configured but unreachable at query time, the error surfaces (no silent fallback — same contract as request metrics). - Request —
http_requests/http_latencycome from Traefik scraped by Prometheus (NewPrometheusRequestSource, gated byBEX_PROM_URL): aquery_rangeovertraefik_service_requests_total(rate) and_request_duration_seconds_bucket(histogram_quantile, default p95).bandwidthis the App's complete outbound rate: exact Traefik router response bytes + WebSocket downstream frames + direct public L3 bytes, using the sameegressqueryvocabulary as hourly usage. Since w1/m50 the interactive read is best-effort: a source failing its health product no longer errors the window — the series is served with adegraded_sourceslabel (andmonthToDateBandwidthadegradedSourcesfield) naming the unhealthy sources, while the hourly usage rollup keeps the strict billing gate (ADR023 § Observability reads vs billing reads).statusCodefilters the HTTPcodelabel (2xx→2..);groupBy(status/method) applies to HTTP request/latency series.
When a metric's source isn't wired at all (request metrics without BEX_PROM_URL, cpu/memory with neither Prometheus nor metrics-server), the endpoint returns 503 (ErrMetricsUnavailable) — the App exists, the data source doesn't.
Everything above is the TENANT-facing read: metrics about a tenant's App. bex-api's telemetry about itself — the origin-side per-route request histogram, the GraphQL-operation and MCP-tool families, and the two BexApiOrigin* alert rules — is platform-side, exposed on the internal :8091 /metrics registry and documented in ADR088 §6. It never reaches a tenant surface, and it obeys the same cardinality rule as everything else here: route labels are registered mux patterns, never paths, and tool/operation labels come from closed registries. Nothing in this section changed when it landed (w3/m84).
| method + path | metric |
|---|---|
GET /v1/metrics/cpu |
per-instance CPU (cores, or % of limit) |
GET /v1/metrics/memory |
per-instance memory (bytes, or % of limit) |
GET /v1/metrics/instance-count |
running replica count |
GET /v1/metrics/http-requests |
request rate (req/s) |
GET /v1/metrics/http-latency |
latency percentile (seconds) |
GET /v1/metrics/bandwidth |
outbound bytes/s |
Query params (Render vocabulary): resource (App id, repeatable; service is accepted as its alias), startTime/endTime (RFC3339), resolutionSeconds, quantile (0..1, latency), statusCode (request filter), host/path (request filters served from the log store, w5/m58), aggregateBy=statusCode|host on http-requests (one series per status code, labelled statusCode, or per request host, labelled host — w1/m155 retired the non-Render groupBy; w5/m140 served host from the request-log store, which never falls back to the host-less Traefik counter, so with no store it is the host filter's 503), aggregationMethod=AVG|MAX|MIN on cpu (Render's interval aggregation, w5/m140: MAX/MIN report each step's highest/lowest per-instance rate via a max_over_time/min_over_time subquery at the 15 s scrape grain; AVG/omitted is the plain rate; a named 400 on any other metric, or when cpu comes from the metrics-server snapshot), bex extras percentage=true (cpu/memory as a fraction of limit), instance (repeatable public instance ids from w5/m87; omit for all), and aggregateAllMethod=MIN|MAX|AVG (replica aggregate at each timestamp — w5/m89; distinct from the interval aggregationMethod, which MCP spells cpuUsageAggregationMethod and Render's MCP server passes straight to REST). Eligibility is metric-typed, not data-derived (w5/m91): instance is honored on cpu/memory/cpu_limit/memory_limit (the per-instance series, including the m89 limit consumers), so an authorized empty window succeeds with no series instead of misreporting a bad request; on any other metric it is a named 400 even when empty. Unresolved or foreign instance values yield an empty series rather than silently selecting everything. The dashboard retains an explicit selection across discovery gaps and window changes until an explicit Show-all or a resource navigation clears it, marking retained-but-unavailable choices instead of pruning them. Each endpoint returns Render's metrics array — [{labels:[{field,value}], unit, values:[{timestamp,value}]}]. GraphQL mirrors Render's dashboard shape: metrics(query: MetricsQueryInput!) — input fields filters (resource selectors, including INSTANCE), name (the metric: cpu/memory/instance_count/http_requests/http_latency/bandwidth), start/end, resolution, parameters, aggregateBy, aggregationMethod, aggregateAllMethod, plus the bex-extension percentage boolean (w5/m90 — no Render equivalent; mirrors REST ?percentage=true and MCP percentage) — returning MetricSeries { unit, labels{field,value}, values{time,value}, parameters } (the sample field is time in GraphQL, timestamp in REST). Companion dashboard queries: monthToDateBandwidth, metricsFilters, metricsPathFilterSuggestions. The MCP get_metrics tool (resource[] + metricTypes[]) exposes the same read to agents — three-adapter parity, like list_logs.
Shapes track Render's metrics endpoints (per-metric path segments; the {labels, unit, values} time-series). With Prometheus configured (BEX_PROM_URL), all six metrics are resolution-stepped series honoring startTime/endTime/resolutionSeconds — Render metrics-page parity. Known, intentional deviations:
- snapshot fallback — without
BEX_PROM_URL, resource metrics fall back to metrics-server and return a single current point (metrics-server has no history); Render always returns a stepped series. cpu_limit/memory_limitstay single-point — limits come from the current pod spec, and bex won't fabricate a history for a value it only knows now (a past limit may have differed). Stepped limit history for percentage denominators comes from the separate kube-state-metrics source above, not from these endpoints. The dashboard no longer divides client-side (w5/m90): it reads serverpercentageseries directly, and these single-point series feed only the header label and the Total tab's reference line — a uniform limit reads as one value, mixed per-replica limits read as "Limits vary", never one max presented as universal.host/pathfilters — rejected with a 400 naming them (w3/m12; confirmed infeasible w3/m18): Traefik's Prometheus counters (service- and router-level) intentionally carry no host or path labels — adding them would be unbounded cardinality.addRoutersLabels: trueis already enabled in the Traefik config and adds arouterlabel (the router name, e.g.my-app@kubernetes), not the matchedHost()orPathPrefix()values from the routing rule, so router-level metrics are equally unlabelled by host/path. Host/path-scoped request analysis requires parsing the access log (type=requestin Loki, the logs API withpath/hostfilters). A query with a host or path filter cannot be answered honestly and is refused rather than silently answered with whole-service series. GraphQL errors on aHOST/PATHfilter entry identically; MCP'sget_metricsdoesn't expose the parameters at all; and themetricsFiltersdiscovery verb reports emptyHOST/PATHvalues, so no client is offered a filter value the query verb refuses.- Traefik service selector — the App→Traefik-service match for HTTP count/latency is a heuristic (
service=~".*<app>.*"); bandwidth instead uses exact operator-owned router labels. The resource-metrics pod match (pod=~"<obj>-[a-z0-9]+-[a-z0-9]{5}|<obj>-[a-z0-9]{62-len(obj)}",egressquery.PodNameRegex) is the stricter cAdvisor sibling. Its two-segment-only predecessor returned no series at all for any App whose object name crossedgenerateName's 58-char truncation point — a service name of ~22 characters, well inside the 30ValidResourceNameallows — and the same matcher in the usage meter, called with the workspace-scoped store name instead of the object name, metered every App's compute as a healthy zero for the meter's whole life (w6/m110).
The metrics-server fallback adds read on metrics.k8s.io pods (get/list) to the api ServiceAccount (lego/operator/config/api/rbac.yaml); percentage mode reuses the existing pods read for limits. The Prometheus-backed metrics (resource history and request) reach Prometheus over HTTP (BEX_PROM_URL), not the kube API, so they need no extra RBAC on bex-api — the Prometheus ServiceAccount's chart-default ClusterRole covers the cAdvisor scrape (nodes, nodes/proxy, nodes/metrics).
deploy/gitops/base/prometheus.yaml runs the one Prometheus behind both history-backed metric families. Two scrape jobs feed bex-api's metrics: traefik (request counters, via deploy/gitops/base/traefik.yaml's metrics entrypoint :9100 with addServicesLabels) and kubernetes-cadvisor (per-container cpu/memory, scraped through the apiserver proxy so it works even where the pod network can't reach every kubelet). Four more feed the alerting rule pack below — kube-state-metrics (object state), kubernetes-kubelet (only kubelet_volume_stats_* for PVC usage), cert-manager (:9402 certificate series), and openbao (per-pod /v1/sys/metrics for seal state). A fifth, cnpg-tenant-db (w3/m10), scrapes every managed-Postgres CNPG pod's :9187 exporter across all app namespaces (bex's own bex-db stays on its own tightly-scoped cnpg-bex-db job), keeping cnpg_backends_total + cnpg_pg_replication_* — the extended-metrics series below. BEX_PROM_URL points bex-api at the server and enables the metric families. deploy/gitops/base/metrics-server.yaml installs metrics-server — now only the snapshot fallback (and kubectl top).
Production Prometheus exhausted its 512 MiB container limit while replaying the existing WAL. At 08:14:24 UTC the last exit was OOMKilled (137), with 577 restarts and no ready endpoint. The 10 GiB provisioned PVC still held about 1.48 GiB; retention alone does not bound replay memory. Prometheus keeps current head data in memory and reconstructs it from the WAL on startup (storage documentation).
The production overlay now requests 1 GiB and caps the server at 2 GiB; the shared/local budget stays 256/512 MiB, and CPU stays 50/500m. Both overlays use Helm parameters so they preserve the shared inline values. The local overlay previously used valuesObject, which Argo selects instead of inline values; the validation helper now models that choice correctly, and the local parameters restore the intended shared rules, storage and budget. This is a bounded recovery budget, not a measured successful replay peak. The hosting node had 7,651 MiB allocatable, 5,526 MiB of pod requests and about 3,599 MiB available at 08:18 UTC. Raising the request adds 768 MiB, leaving about 1,357 MiB of schedulable memory there. A full 2 GiB server working set would leave approximately 1.5 GiB of the observed available memory; that is a point-in-time capacity check, not a guarantee against concurrent workload peaks.
Keep the existing PVC, WAL, three-day retention, scrape jobs and rules. Before closing the incident, the platform release/QA owner must observe replay completion on that dataset, measure replay and query/steady-state peaks plus head-series cardinality, recheck node capacity, and verify an existing-data restart with a stable restart count for 15 minutes. Confirm fresh scoped REST/GraphQL/MCP series and the dashboard charts, alongside the pod-spec limit controls. Preserve any historical gaps and distinguish unavailable sources from empty windows; this recovery does not establish billing backfill. The detailed consumer matrix and remaining gate are in w4/m144 verification.
Four bex-extension series closing the render-parity ledger's last open metrics row (docs/ADR018-render-parity.md's "Extended metrics"). The first is App-scoped (Core.Metrics, same verb as cpu/memory); the other three are Database/KeyValue-scoped — a new Core.DatastoreMetrics verb (internal/metrics/datastore.go), since the resource isn't an App and can't go through s.GetApp. It re-resolves the Database/KeyValue CR by name (AuthorizeLabeled, the same cross-workspace gate internal/postgres/internal/keyvalue's own fetch helpers apply) rather than importing those feature packages — features never import each other.
cpu_target/memory_target— the App's configured autoscale-target utilization percentage (spec.autoscaling.targetCPUPercent/targetMemoryPercent, w1/m20), a single current-value point likecpu_limit/memory_limit(a config value, not a usage sample). Omitted — not a fake zero — when autoscaling is disabled or the specific target isn't set.disk/disk_capacity— a managed Postgres or Key Value instance's backing-PVC used/capacity bytes, viaquery_rangeover kubelet's already-scrapedkubelet_volume_stats_{used,capacity}_bytes{namespace,persistentvolumeclaim=~pattern}(no new scrape config — see Cluster enablement above). The PVC name pattern is derived from the operator's own naming:<name>-\d+for a Database (CNPG's per-instance PVCs),data-<name>-\d+for a KeyValue (its StatefulSet'sdatavolumeClaimTemplate).db_connections— a managed Postgres instance's live active-connection count, viaquery_rangeover CNPG's postgres_exportercnpg_backends_total(itsbackendsdefault-monitoring query'stotalcolumn, summed across everydatname/usename/state), scoped by thecnpg-tenant-dbscrape job'scnpg_io_clusterlabel. Postgres-only —DatastoreMetricserrors if asked for a KeyValue resource.replication_lag— a managed Postgres instance's replication lag in seconds, via CNPG'scnpg_pg_replication_lag(itspg_replicationdefault-monitoring query'slagcolumn). Gated, not degraded: the verb never queries Prometheus for this metric unlessDatabase.status.highAvailabilityEnabledis true — without a standby CNPG's own query returns0from a lone primary (not absence), which is exactly the fake-zero the omit-don't-fake rule (above) exists to avoid. An HA Postgres (w1/m22) returns a real series; non-HA Postgres returns nil and the dashboard renders a clear N/A state rather than a broken chart. (w3/m17)
REST: GET /v1/metrics/{cpu,memory}-target?resource=<app> (same shape as the other App-scoped endpoints) and GET /v1/metrics/{disk,disk-capacity,db-connections,replication-lag}?resource=<name>&kind=database|keyvalue (kind defaults to database). GraphQL: CPU_TARGET/MEMORY_TARGET join metrics' name enum; the other three ride a new datastoreMetrics(query: DatastoreMetricsQueryInput!) query — {kind, resource, name, start, end, resolution}, naming one resource directly rather than a RESOURCE filter array (a datastore metric always targets exactly one instance). MCP: cpu_target/memory_target join get_metrics' metricTypes; the datastore trio gets its own get_datastore_metrics tool (resource, kind, metricTypes[]). All four are bex extensions with no literal Render endpoint — Render's dashboard shows autoscale-target/disk/connection info as part of other views, not this metrics API shape — so parity here means "the same data, reachable the same way as bex's other metrics," not a captured Render wire format.
Logs and metrics above make tenant deploys observable. Platform alerting is the operator-facing half of GOAL.md #2: when bex itself breaks — a bad rollout in bex-system, a node gone, OpenBao sealed after a restart, a nightly backup silently rotting — a human gets paged instead of finding out at restore time. It rides the same Prometheus (w3/m6): the chart's bundled Alertmanager is enabled with an email receiver, and a small, high-signal rule pack (deploy/gitops/base/rules/alerting_rules.yml, mounted at /etc/rules from ConfigMap monitoring/prometheus-alerting-rules and watched by the configmap-reload sidecar; it moved out of the inline prometheus.yaml Helm values in w3/m177 because Argo retains those values roughly eightfold and the Application ran out of its size budget) evaluates platform and bex-specific invariants.
Collection stays scoped to the rules it serves: kube-state-metrics provides object state, the kubelet provides PVC usage, and a filesystem-only node_exporter provides tenant-node ephemeral-storage headroom. No pushgateway or unrelated host collector suite is introduced.
The groups carry actionable descriptions and links to diagnosis procedures:
| group | alert | fires when | severity |
|---|---|---|---|
platform |
PlatformPodCrashLooping |
a container in a platform namespace is CrashLoopBackOff >10m | warning |
platform |
PlatformDeploymentNotReady |
a platform Deployment has < desired available replicas >10m | warning |
platform |
PlatformGitOpsOutOfSync |
an expected Argo Application reports a non-Synced state for 15m | warning |
platform |
PlatformGitOpsUnhealthy |
an expected Argo Application reports a non-Healthy state for 15m | warning |
platform |
PlatformGitOpsApplicationMissing |
an expected Application lacks complete sync/health telemetry for 10m while the Argo scrape succeeds | warning |
platform |
PlatformGitOpsMetricsMissing |
the Argo application scrape is absent or has no successful target for 5m | warning |
platform |
ControlPlaneNodeNotReady |
a control-plane node is NotReady >5m (single CP node = high blast) | critical |
platform |
NodeNotReady |
a worker node is NotReady >5m | warning |
platform |
NodeDiskPressure |
a node's kubelet reports DiskPressure >2m (an eviction threshold has been crossed) | critical |
platform |
NodeDiskPressureRecovered |
a node had DiskPressure within the last 6h and has recovered | info |
platform |
NodeDiskPressureSignalMissing |
a node (or every node) has no kube-state-metrics DiskPressure series >15m | warning |
platform |
TenantNodeFilesystemSpaceLow |
an expected tenant backing device has <20% available bytes for 10m | warning |
platform |
TenantNodeFilesystemInodesLow |
an expected tenant backing device has <10% free inodes for 10m | warning |
platform |
TenantNodeFilesystemTelemetryMissing |
an expected tenant node/mount lacks complete usable filesystem evidence for 10m | warning |
platform |
TenantNodeFilesystemInventoryMissing |
kube-state-metrics exports no node pool labels for 10m | warning |
platform |
TenantNodeFilesystemFillingSoon |
an eligible falling trend projects <15% available bytes within 4h for 15m; advisory | warning |
platform |
EtcdMetricsMissing |
a control-plane node has no successful etcd scrape (or none at all) >10m — monitoring loss, not an outage |
warning |
platform |
EtcdNoLeader |
an etcd member reports no Raft leader >1m | critical |
platform |
EtcdFrequentLeaderChanges |
>3 leader changes in an hour on a member | warning |
platform |
EtcdHighFsyncLatency |
WAL fsync p99 >500ms for 10m (etcd-mixin threshold) | warning |
platform |
EtcdHighCommitLatency |
backend commit p99 >250ms for 10m (etcd-mixin threshold) | warning |
platform |
EtcdDatabaseNearQuota |
DB size >80% of the backend quota for 10m | warning |
platform |
PersistentVolumeFillingUp |
a PVC is >85% full >15m (hcloud-csi/local-path single-copy volumes) | warning |
platform |
CertificateNotReady |
a platform cert-manager Certificate is not-Ready >15m — every not-Ready cert except a tenant's own custom-domain cert (which bex can't fix; see the next row) | warning |
platform |
TenantCustomDomainCertNotReady |
a tenant custom-domain cert (tea-* ns, <app>-tls-<host> for a non-onbex.co host) is not-Ready >1h — almost always customer DNS not pointing at bex (e.g. a Cloudflare-proxied apex ⇒ ACME HTTP-01 404s). Routed to the null receiver (dashboard-surfaced, never paged); the platform <app>-tls onbex host cert stays on CertificateNotReady because bex owns *.onbex.co DNS |
info |
platform |
CertificateExpiringSoon |
an issued (Ready) Certificate expires in <14d and hasn't renewed — joined on ready_status == 1 because cert-manager exports expiry 0 for a never-issued cert (which made "0 − now < 14d" page for weeks), with the same tenant custom-domain carve-out as CertificateNotReady |
warning |
platform |
TenantCustomDomainCertExpiringSoon |
a tenant custom-domain cert was issued but is not renewing (<14d left) — the domain's DNS moved after issuance; the info-tier twin of the row above, null-routed |
info |
bex |
BackupCronJobStale |
etcd-backup/openbao-backup last succeeded >26h ago (silent rot) |
critical |
bex |
PlatformDatabaseBackupStale |
a platform CNPG primary's reported last archived WAL timestamp is older than 26h | critical |
bex |
PlatformDatabaseBackupTargetMissing |
an expected platform database has no successful primary scrape for 10m | warning |
bex |
PlatformDatabaseBackupTelemetryMissing |
an expected platform database has a successful primary scrape but no current archiver timestamp for 10m | warning |
bex |
ZotGarbageCollectionFailure |
the known missing-repository-metadata GC failure was freshly observed within the last 3h | warning |
bex |
ZotGarbageCollectionTelemetryMissing |
fresh Zot log evidence from a successfully scraped Alloy instance has been unavailable for 10m | warning |
log-delivery |
LogDeliveryEntriesDropped |
final Alloy writer drops were observed within 15m | warning |
log-delivery |
LogDeliveryLokiRejected |
Loki ingestion rejection attempts were observed within 15m | warning |
log-delivery |
LogDeliveryRetriesPersistent |
retry activity appears in every rolling 5m window for 10m | warning |
log-delivery |
LogDeliveryCollectorMissing |
an expected node lacks a successful Alloy scrape for 10m | warning |
log-delivery |
LogDeliveryInventoryMissing |
the independent node inventory is absent for 10m | warning |
log-delivery |
LogDeliveryCollectorUnhealthy |
a successfully scraped expected Alloy collector has faulty or incomplete configuration/component evidence for 5m | warning |
log-delivery |
LogDeliveryLokiMetricsMissing |
complete Loki scrape/process/WAL-counter evidence is unavailable for 5m | warning |
log-delivery |
LogDeliveryLokiWALDiskFull |
a Loki WAL disk-full write failure was observed within 15m | warning |
bex |
OpenBaoSealed |
any OpenBao member reports sealed >5m (⇒ 503s the env-vars API) | critical |
bex |
BexApiDown |
bex-api has zero available replicas >5m |
critical |
bex |
BexApiOriginHighErrorRate |
>5% of the requests bex-api itself completed on one surface are 5xx for >10m (above a 0.1 req/s floor) — the origin-side sibling of TraefikHigh5xxRate, read together with it to place the fault (ADR088 §6) |
warning |
bex |
BexApiOriginLatencyHigh |
bex-api's own p95 on one surface is above the recorded 2.5s baseline for >10m (above the same floor) | warning |
bex |
WebhookDeliveryAdmissionPressure |
>100 outbound webhook notifications remain capped in rolling 15m windows for >10m | warning |
bex |
WebhookDeliveryFailing |
>90% of outbound webhook attempts fail for >30m, above a 36-attempts/30m floor | warning |
bex |
PushDeliveryStale |
push is configured, rows are queued, and no provider operation has succeeded in >2h (for >30m) | warning |
bex |
AgentSessionProvisionFailing |
>25% of agent-session sandbox provisions fail over an hour, above a 10-provisions/hour floor | warning |
bex |
ClusterBuilderNotReady |
kpack ClusterBuilder bex missing/unknown/not Ready >15m |
warning |
bex |
ClusterBuilderImageStale |
committed builder resolution missing, malformed, or older than 30 days | warning |
bex |
StrandedNodeLocalImages |
an App pod is ImagePullBackOff/ErrImageNeverPull >10m | warning |
bex |
TraefikHigh5xxRate |
>5% of edge requests are 5xx for >10m (above a traffic floor) | warning |
etcd members (w7/m160). kubeadm binds etcd's metrics listener to 127.0.0.1:2381, and the client port needs an etcd client certificate — full keyspace access — so neither is scraped directly. deploy/gitops/charts/etcd-metrics runs one kube-rbac-proxy per control-plane node (host network, bound to the node's InternalIP only — tenant pods cannot reach node IPs) that forwards to the loopback listener. Each scrape is authenticated (TokenReview) and authorized for GET /metrics (SubjectAccessReview — the grant prometheus-server already has); TLS is a cert-manager private CA whose serving cert names etcd-metrics.monitoring.svc, which the etcd job pins with server_name so node-IP targets still verify. Prometheus mounts only the CA's public ca.crt. The job keeps a finite allowlist (leader, proposals, slow applies/reads, heartbeat failures, WAL fsync / backend commit / peer RTT histograms, DB size and quota). Scrape loss (EtcdMetricsMissing, per node) is deliberately separate from health (EtcdNoLeader, latency): one unreachable proxy is never read as a quorum problem. The slow-apply / ReadIndex / fdatasync log lines that motivated this are evidence for visibility, not proof that disks need replacing — compare the latency panels with the thresholds before acting.
Node disk pressure (w7/m159). The signal is kube-state-metrics' kube_node_status_condition{condition="DiskPressure"} — the kubelet's own eviction-threshold condition for node-local ephemeral storage (container writable layers, emptyDir, logs, images). PVCs are a separate budget (PersistentVolumeFillingUp). NodeDiskPressure reports an eviction threshold already crossed; the kubelet attempts resource reclaim and may evict pods, which is why it pages. The tenant filesystem headroom signals add a separate warning margin and advisory forecast; they do not replace this current-pressure condition. A shorter episode (the 2026-09-28 03:38 UTC EvictionThresholdMet on bex-tenant-0-bdn2q-bnzl6 cleared by 03:43) surfaces as the info-only NodeDiskPressureRecovered for 6h, and Prometheus keeps the condition series for its 3-day retention; Kubernetes Events expire after an hour, so read them first. Diagnose without deleting: kubectl describe node <node> (conditions, allocatable ephemeral storage), kubectl get events -A --field-selector involvedObject.name=<node>, then the pods on the node — kubectl get pods -A --field-selector spec.nodeName=<node> — looking for Evicted pods and their ephemeral-storage limits (a pod exceeding its own limit is evicted by the kubelet without node DiskPressure; that is the tenant's limit, not node capacity). Resizing machines or pruning images is a separate, evidence-backed decision.
ControlPlaneNodeNotReady vs NodeNotReady split on kube_node_role{role="control-plane"}: the CP pool is a single node until the quota lift restores 3 CP nodes, so its loss pages while a worker's only warns. OpenBaoSealed reads the per-pod telemetry gauge vault_core_unsealed (from the openbao scrape) — not readiness or the Service: the chart's readiness probe keeps a sealed member in rotation (sealedcode 2xx) so the round-robin Service and a kube_statefulset_ready check would both miss a sealed follower; a sealed member still serves /v1/sys/metrics reporting 0, and the alert fires on any member sealed. StrandedNodeLocalImages catches the node-local-image failure mode (App images are ctr imports, not registry-backed, so node replacement/scale-down strands them) — a platform defect, hence warn not page, even though it fires in the tenant default namespace. BackupCronJobStale reads kube_cronjob_status_last_successful_time from kube-state-metrics (the local overlay removes both backup CronJobs, so the series — and this alert — exist only where the jobs run: prod). ClusterBuilderNotReady / ClusterBuilderImageStale (docs/ADR060 D7) read operator-exported unlabeled gauges: readiness is the live kpack condition, age is the committed resolved_at in toolchain-freshness.json, never a mutable tag. Digest movement opens .github/workflows/build-toolchain-freshness.yml's tracking issue; accepting a digest remains a reviewed commit.
Production topology verified 2026-09-29 05:45 UTC. The four bex.co/pool=tenant nodes (bex-tenant-0-bdn2q-{2252c,5jjf9,bnzl6} and bex-tenant-lg-4wvlk-s5z8z) share one backing device per node: /dev/sda1, ext4, mounted at /, serving /var/lib/kubelet, /var/lib/containerd and /var/log/pods. Available bytes were 44.53–79.91% and free inodes 75.05–92.30%; inspection was read-only and did not induce pressure. The kubelet configuration sets hard nodefs.available<10%, imagefs.available<15%, and both inode thresholds at 5%, with no soft thresholds or minimum reclaim. Image GC high/low watermarks are 85%/80%; the 5m pressure transition period controls condition oscillation, not a hard-eviction grace period. CAPD's local eviction thresholds are zero, so these warning margins describe the production shape.
Deployed proof, 2026-09-29 06:09–06:12 UTC: feature cc410dc53 plus history-size repair caef683d2 are live. All four intended exporters are Ready and UP; each exposes /dev/sda1 at /, and expected-minus-observed coverage is empty. The live configuration and Cluster capacity panels 18–24 match the shipped render. All five alerts and ten records evaluate healthily, with no filesystem alert active. Each scrape retains nine samples; observed scrape duration was 4.4–7.2ms and filesystem collection 1.1–2.5ms. Each pod used about 1m CPU and 4–5Mi memory. These are point-in-time observations, not a load benchmark. Available-byte ratios were 33.8–78.9% and free-inode ratios 69.7–91.5%. All four forecasts correctly report insufficient history until their two-hour window matures; pressure and recovery were exercised only in isolated fixtures.
The existing kubelet scrape keeps PVC statistics and cannot provide these host filesystem families. The chart's filesystem-only node_exporter supplies available/total bytes and free/total inodes on tenant nodes through the private tenant-node-filesystem scrape. Independent bex:tenant_node_filesystem_expected{node,mountpoint} rows come from kube-state-metrics' tenant pool labels, with / required for the verified topology. Complete evidence requires a successful scrape and all required raw families on a writable, error-free mount. Records aggregate by node/device, so aliases of the same device do not duplicate warnings. A future separate kubelet/image/log filesystem requires updating the expected mount inventory and collector selection together; this root inventory does not claim coverage of undeclared storage.
The locked chart's node_exporter v1.8.2 enables only the filesystem collector. Its host /, /proc and /sys mounts are read-only; it uses no host network, PID/IPC namespace, host port or ServiceAccount token. Each tenant-node pod requests 10m CPU/32Mi memory and is limited to 100m/64Mi; the private scrape runs every 60s. Collector exclusions avoid pseudo/pod mounts before statfs, and ingestion retains only /, /var, /var/lib, /var/lib/containerd and /var/lib/kubelet candidates. Only independently required mounts feed alerts. The local override permits CAPD's overlay root; production excludes overlay filesystems.
TenantNodeFilesystemSpaceLow warns below 20% available for 10m, before the shared device's stricter 15% imagefs eviction boundary. TenantNodeFilesystemInodesLow warns below 10% free for 10m, before the 5% boundary. These are chosen operational margins, not a measured minimum lead time. Missing expected node/mount evidence warns separately after 10m, allowing shorter exporter startup and scheduling gaps; inventory disappears when a node leaves the declared tenant pool. Loss of all node pool labels has its own inventory warning, so an empty expected set cannot silently establish health. A functioning Prometheus evaluator remains necessary.
The advisory fill forecast requires at least 110 samples over 2h, a sample in the 5m window at the start of that history, and unchanged capacity across the window. With that history, it requires available space below 35%, negative 30m and 2h slopes, and a 2h linear projection below 15% available in 4h, persisting for 15m. The panel distinguishes insufficient/unstable history from an eligible forecast with no current risk, and missing telemetry from both. Garbage collection or capacity changes can reset or invalidate that evidence; forecast inactivity does not override a low-headroom warning.
Read Cluster capacity in four parts: the existing DiskPressure condition, current available bytes/inodes, collection coverage, and the advisory forecast. Node-local ephemeral/image storage, PVC capacity, and a pod's ephemeral-storage limit are separate budgets. A pod can exceed its own limit while the node has ample space; conversely, many individually compliant pods can exhaust the shared backing filesystem. DiskPressure indicates a kubelet threshold has been crossed; it does not prove that a particular pod has already been evicted. See the Kubernetes node-pressure model.
Start with read-only evidence for the node named in the alert:
storage_node="NODE_FROM_ALERT"
kubectl describe node "$storage_node"
kubectl get events -A --field-selector "involvedObject.kind=Node,involvedObject.name=$storage_node" --sort-by=.lastTimestamp
kubectl get pods -A --field-selector "spec.nodeName=$storage_node" -o wide
kubectl get --raw "/api/v1/nodes/$storage_node/proxy/configz" | \
jq '.kubeletconfig | {evictionHard, evictionSoft, evictionMinimumReclaim, evictionPressureTransitionPeriod, imageGCHighThresholdPercent, imageGCLowThresholdPercent}'
kubectl get --raw "/api/v1/nodes/$storage_node/proxy/stats/summary" | \
jq '{nodefs: .node.fs, imagefs: .node.runtime.imageFs, containerfs: .node.runtime.containerFs}'On the host, use findmnt -T on the kubelet/runtime storage paths to confirm which actual mount backs them, then compare df -B1 and df -i on that mount with the exporter. Inspect pod eviction messages and limits, runtime image usage, and recent write/log growth before choosing a response. Missing telemetry calls for checking node inventory, exporter scheduling, scrape health and mount selection; another node's healthy series cannot establish this node's coverage. These alerts do not delete images or data, expand disks, replace nodes or change pod limits automatically.
Forecasts are advisory extrapolations of past samples. Sudden writes, image pulls, workload placement changes and garbage collection can invalidate the trend; neither an inactive forecast nor a green historical chart guarantees advance notice. Confirm present byte/inode headroom and actual kubelet thresholds before acting, and keep warning delivery time separate from the rule's persistence window.
Prometheus retains three Argo Application revision-history entries because its inline rule pack is copied into each entry. On 2026-09-29, the default ten entries grew the Application to 1,567,464 bytes and etcd rejected the m164 update. A resourceVersion-guarded trim retained the newest three entries after saving the prior object; Git retains older configurations. The declarative revisionHistoryLimit: 3 prevents that accumulation, and rendered validation budgets the repeated source data below etcd's request ceiling. This limits Argo's local rollback list, not Prometheus metric retention. See Argo Application history.
The private argocd-applications job scrapes argocd-metrics.argocd.svc:8082 every 60s. It retains only argocd_app_info and its namespace, name, sync_status, health_status labels plus scrape identity (job, instance); revision SHAs, repository URLs and arbitrary Application labels are excluded. No public endpoint or administrator credential is introduced.
bex:platform_gitops_expected{namespace="argocd",name="…"}=1 is an independent recording rule in serverFiles.platform_gitops_expected.yml, derived from constants rather than exporter output. The base inventory names the production root bex-platform-prod and its children; the local overlay replaces that inventory with its own root and children. scripts/gitops-validate.sh compares each rendered inventory with the corresponding bootstrap root and child Applications, so adding or removing an Application requires updating its expectation. Loss of the exporter cannot erase the expected population.
PlatformGitOpsOutOfSync and PlatformGitOpsUnhealthy retain only the stable namespace/name identity and warn after 15m; changing among failing state labels does not restart their timers. The 15m grace is a chosen maintenance allowance for normal reconciliation, not a measured rollout or notification guarantee. Missing Application state gets 10m, and missing/failed scrapes get 5m. Application-missing alerts require a successful scrape so a controller metrics outage has one diagnosis rather than one alert per missing child. Recovery clears the corresponding condition; email follows the warning digest's separate timers below.
The Platform availability dashboard has a current expected-Application table, per-Application sync and health history, and scrape health. The table preserves native state names such as OutOfSync, Degraded and Progressing; its PRESENT value means telemetry exists, not that the Application is healthy. Missing/incomplete native data or a failed scrape produces UNKNOWN (-1) from the independent inventory, never a green zero. All native panel operands require a successful scrape, so a cached healthy sample cannot mask target loss.
Diagnosis is read-only first:
# Confirm root and child state, then inspect the operation that stopped delivery.
kubectl -n argocd get applications.argoproj.io
kubectl -n argocd get applications.argoproj.io bex-platform-prod -o yaml
application=prometheus # replace with the Application named in the alert
kubectl -n argocd get applications.argoproj.io "$application" -o yaml
kubectl -n argocd get events --sort-by=.lastTimestamp
# Separate target failure from missing Application telemetry.
kubectl -n argocd get service argocd-metrics
kubectl -n argocd get endpointslices -l kubernetes.io/service-name=argocd-metrics
kubectl -n argocd logs statefulset/argocd-application-controller --since=30m
kubectl -n monitoring port-forward service/prometheus-server 9090:80In the root and child YAML, inspect status.conditions, status.operationState.phase/message, and status.operationState.syncResult.resources for the failed resource and its message. Check that resource's argocd.argoproj.io/sync-wave annotation and compare earlier-wave resources with the child's desired state: a root failure in an early wave can withhold updated child Application specifications even while those children still report Synced. In the port-forwarded Prometheus UI, inspect the argocd-applications target and loaded rules, then compare bex:platform_gitops_expected with argocd_app_info{job="argocd-applications"}. Check the root's desired revision and the child/config revision actually running; root Synced alone does not establish that every child is healthy. Repair the demonstrated resource or configuration error through its owning workflow. This runbook does not call for forced sync, replacement, pruning or resource deletion.
Detection limit: Argo delivers this Prometheus configuration and these dashboards. If that delivery is already stalled, a rule edit is not active until the root and monitoring children reconcile; record the deployed revision and loaded rules separately from fixture-test results. These rules require a functioning Prometheus evaluator and cannot detect a whole-Prometheus outage. The committed rule tests establish failure/recovery behavior on isolated fixtures; they do not establish that production has loaded the change or bound real rollout duration.
Deployed verification (2026-09-29 05:08–05:09 UTC). Revision 32631b053 exposed 32 native and 32 independently expected Applications, all Synced/Healthy, with empty set differences; the private scrape was UP and all four rules loaded, healthy and inactive. The loaded Prometheus values/rule entries matched the revision, and the Grafana ConfigMap contained panels 16–19. After an explicit source-cache refresh, ordinary automated sync completed in 42s (root), 3s (Prometheus) and 4s (Grafana); root OutOfSync and Grafana Progressing were observed before recovery. Failure/recovery scenarios and four deliberate rule mutations were exercised only in isolated fixtures, not production.
The primary-only cnpg-platform-db job covers bex-system/bex-db and auth/{kratos-db,hydra-db,openfga-db}. bex:platform_database_backup_expected records these four namespace/cluster identities independently of discovery; rendered Cluster declarations are checked against the inventory. bex:platform_database_last_archived_time keeps only timestamp samples joined to up == 1 on namespace, cluster, pod and instance, then aggregates to namespace/cluster. Matching the individual target before aggregation prevents a cached timestamp from an old primary from hiding missing telemetry on its replacement.
The two absence warnings distinguish no successful target from a successful target missing the archiver metric. Both persist for 10m; a primary switch that restores coverage before that window expires stays quiet, and recovery clears the missing condition. This is an operational allowance, not a measured failover guarantee or notification deadline; warning email follows the digest schedule. The existing raw-series PlatformDatabaseBackupStale expression remains unchanged at >26h. A timestamp of zero or CNPG's never-archived -1 sentinel is present evidence and still produces an old age; neither is coerced into missing telemetry.
The Data plane dashboard retains a row for all four expected databases in separate target and archiver-coverage tiles. Its age chart uses current-target timestamps with an explicit UNKNOWN (-1 age) fallback, so an absent database cannot disappear behind healthy siblings. PRESENT in the telemetry tile does not establish fresh WAL or restorable backups. Missing telemetry is not proof of backup data loss: follow ADR031's exporter/collector diagnosis before investigating the archiver or recovery data. The pg_stat_wal collector and pg_stat_archiver are separate; a failure in the former does not establish absence of the latter.
The existing Zot platform-log source forwards to both retained Loki logs and a separate metrics branch. The branch accepts entries no older than 5m and emits two observation-time gauges: bex_zot_gc_failure_last_observed_timestamp_seconds for the known missing-repository-metadata GC signature, and bex_zot_log_last_observed_timestamp_seconds for any fresh Zot line. Gauges expire after 5m idle; repository identity and paths stay in logs. The private alloy job scrapes the log-shipper's HTTP metrics port every 30s, retaining these two families with bounded service=zot plus collector job, instance, node, pod attribution. The same private scrape also supplies the durable ingestion signals below.
ZotGarbageCollectionFailure compares wall time with the latest observation timestamp retained over 3h. The first fresh match warns immediately; repeated observations extend the window, and it clears 3h after the last observation. Prometheus history survives collector reload/expiry, while an old gauge reappearing cannot reopen an incident older than the window. Historical entries older than 5m cannot refresh the metric; replay within that allowance can extend observation time by up to 5m. This signal measures recurrence, not unique log events, GC runs or reclaimed bytes, and the metrics filter does not remove original Loki history.
bex:zot_log_collection_healthy independently requires an observation newer than 5m joined to successful up for the same Alloy instance. Ten minutes without that evidence warns through ZotGarbageCollectionTelemetryMissing. Cluster capacity shows recurrence, collection status and the retained failure logs next to registry capacity evidence; no failures with missing collection reads UNKNOWN, not healthy zero. The ADR060 D4 runbook distinguishes collection diagnosis from the bounded repository repair. These warnings neither prevent the upstream Zot defect nor automatically repair registry data, and runtime deployment evidence remains separate from fixture behavior.
The Platform availability dashboard's Durable log delivery row separates Alloy final drops, Loki rejection attempts, sustained retries, collector coverage/configuration, Loki scrape coverage and WAL disk-full risk. It reuses the private alloy job and adds a private Loki scrape. Metrics contain bounded target/component/reason dimensions, not log contents, repository names or tenant identifiers. Read collection status alongside activity: a successful scrape establishes reachability, not successful or durable ingestion.
Coverage and timing. bex:log_delivery_collector_expected{node} comes from kube_node_info, independently of Alloy discovery, and covers every node including the control plane. Missing collector or inventory evidence warns after 10m; node removal removes its expectation. Invalid configuration, nonhealthy components or missing native health families under a successful expected scrape warn after 5m. Loki requires a successful scrape plus current process-start and initialized WAL-counter evidence; incomplete evidence warns after 5m. Final drops, rejected attempts and WAL disk-full activity warn without an additional persistence delay. Retry activity uses a 5m window sustained for 10m, allowing isolated bursts to recover quietly. These are operational allowances, not notification deadlines: warnings use the existing digest's separate grouping/delivery timers.
Delivery semantics. Alloy v1.11.3 (its loki.write is unchanged from v1.11.2) retries connection failures, HTTP 429 and 5xx; other failed HTTP responses cause immediate final drops. Its dropped-entry counter also covers local size/stream-limit rejection. Loki rejection counters measure rejected attempts, which can include entries later accepted after Alloy retries; they are not a count of unique permanently lost lines. Intentional loki_process_dropped_lines_total filters, including cnpg_instance_manager and Zot metrics replay suppression, stay outside these loss alerts. Quiet tenants and absent lazy writer counters do not imply failure. See the pinned Alloy client and Loki rejection dimensions.
Buffering limits. The checked-in writer has no Alloy WAL enabled: batches are held in memory. Its defaults are a 1s batch wait, 1MiB batch size, 10s request timeout, 500ms initial backoff, 5m maximum backoff and max_backoff_retries=10. Backoff and request duration determine the actual delay; these settings establish neither a guaranteed buffering duration nor a delivery deadline. A process restart or exhausted retries can lose pending lines. The writer's component health only reports invalid configuration, so a healthy process can still drop logs. See pinned defaults and writer health/WAL behavior.
Counter evidence. Activity records combine 15m increases with a first-positive check for lazily created series. That fallback requires a successful scrape now and a marker already collected 15m ago: Alloy configuration success or Loki process start time. These markers were admitted with this monitoring rollout; older Alloy up history alone cannot turn a historical positive counter into a new incident. The first 15m of target/marker observation relies on increases, so a first positive sample without a baseline can be missed. Resets, failures entirely between scrapes, or loss of Prometheus can also hide activity. Ordinary measured increases remain visible within their lookback during scrape loss. A first-positive-only observation still requires current successful collection and can become UNKNOWN on target loss. These panels report observed evidence, not exact event counts or complete loss accounting.
Isolated failure/recovery evidence. The pinned Alloy 1.11.2/Loki 3.6.7 exercise uses fixture-only 100ms batching and fixed 1s backoff. A transient 503 retried and later delivered the original line without a final drop; an 8-day-old line received a real Loki 400 rejection and a final Alloy drop while readiness stayed 200. Sustained 503s exhausted the configured budget after ten failed requests and dropped a second line. Recovery delivered a new line; queries found only the delivered lines, and an Alloy restart reset counters without replaying the dropped batch. The CNPG filter control incremented only its intentional process-drop metric. This establishes the exercised paths, not a production buffering or detection-time guarantee.
Start with the target/node named by the alert, the loaded rule and the relevant panel, then inspect without restarting collectors:
kubectl get nodes -o wide
kubectl -n monitoring get daemonset log-shipper
kubectl -n monitoring get pods -l app.kubernetes.io/instance=log-shipper -o wide
kubectl -n monitoring logs ALLOY_POD_FROM_ALERT -c alloy --since=30m
kubectl -n monitoring get pods -l app.kubernetes.io/instance=loki -o wide
kubectl -n monitoring logs statefulset/loki --since=30m
kubectl -n monitoring get endpointslices -l kubernetes.io/service-name=loki
kubectl -n monitoring get pvcFor missing collectors, compare expected nodes with Alloy placement and Prometheus targets, including control-plane nodes; another node's successful scrape cannot cover the missing one. For configuration/component failures, inspect Alloy's rejected configuration and component errors. For retries or final drops, correlate the endpoint HTTP status/reason with Loki logs, availability and configured limits; rejection can happen while readiness stays green. For WAL disk-full risk, inspect the Loki PVC's available space and write errors. WAL replay duplicates are separate and do not establish new ingestion loss.
After repair, verify current collection, new successful pushes and a fresh read through the tenant-facing path. Existing request-log and tenant-view synthetics remain independent end-to-end checks. Recovery means subsequent delivery works; it does not prove already-dropped lines were recovered. Do not blindly replay logs, clear positions/WAL files, delete data or raise ingestion limits to silence an alert. Record the deployed revision and live coverage separately from isolated fault fixtures and their timing.
Deployed verification (2026-09-29 06:32–06:34 UTC). Verified 2026-09-29 06:32:23–06:34:21 UTC at 8d1cecba9: root, Prometheus and Grafana are Synced/Healthy; complete Prometheus ConfigMap and Platform availability dashboard/panels 21–28 match the committed render, with loaded rule/configuration semantics verified. Exactly one Alloy job covers all eleven current nodes and eleven Ready DaemonSet pods; expected, UP and healthy records each contain eleven nodes with empty coverage differences. One Loki target is UP with process marker and WAL counter present; completeness and inventory are one. All nine records evaluate healthily and all eight alerts are healthy/inactive. Selected native series total 84 for Alloy and five for Loki, within the declared label allowlists. Historical ingester_error drop totals were 212 and 41,469 on two tenant collectors, with no drop/retry increases observed during the rollout; these undated lifetime totals did not create false new incidents. Zot heartbeat advanced and was 6.94s old in the final snapshot, with collection healthy. The 15m marker baseline is still warming; fixtures establish first-positive behavior after that baseline. No production log stream was deliberately interrupted.
bex_webhooks_delivery_admissions_total{result="admitted|capped|deduplicated"} counts the dispatcher's committed queue decisions. The vocabulary is closed; workspace ids, endpoint ids, hostnames, URLs, event ids, and payloads are never labels. bex_webhooks_delivery_capped_batch_size is a bounded histogram of the aggregate capped count from one committed feed page. An overflow also produces at most one aggregate log line per dispatch pass, not one line or evidence row per notification.
WebhookDeliveryAdmissionPressure intentionally ignores isolated cap hits and warns only when more than 100 notifications are capped in every rolling 15-minute window for ten minutes. Start with the two causes the limit is meant to contain:
- Check recent deploy/resource activity for event amplification or a runaway producer.
- Check webhook endpoint health and
bex_webhooks_delivery_attempts_totalfor a retry backlog that is keeping logical notifications open. - Confirm Postgres and webhook-worker capacity before raising
BEX_MAX_WEBHOOK_DELIVERIES_PER_WORKSPACE; lowering the bound sheds more webhook projections, while0removes the safety boundary entirely.
Capped events do not fail their source mutation and do not appear as attempted or failed delivery history. The source watermark advances in the same transaction as the aggregate admission result, so repeated alerting represents new pressure rather than replay of the same event.
Admission pressure (above) is about the queue's bound. These two are about the wire — WebhookDeliveryFailing and PushDeliveryStale (w3/m83 t006), the rules for the two ADR052 egress channels.
Webhooks. A tenant's own broken endpoint is theirs to fix and must never page, so the signal is not the failure count but the fleet ratio: when >90% of every attempt bex made in 30 minutes failed, the common factor is bex's egress path — cluster DNS, the bex-api NetworkPolicy egress rules, outbound TLS trust, the worker itself — not N tenants simultaneously breaking N unrelated endpoints. bex_webhooks_delivery_attempts_total carries no endpoint label by design (the cardinality rule above), so "all endpoints" cannot be counted directly and a high ratio over a meaningful volume stands in for it. On a fleet with little webhook traffic one busy broken endpoint can clear the 0.02/s (≈36 attempts per 30m) floor by itself — hence warning, and hence the first debugging step is to confirm from the deliveries surface that the failures are actually spread before treating this as platform-wide.
Push. bex_push_last_success_timestamp_seconds alone would fire on every genuinely idle night, and the queue depth alone says nothing about delivery, so the rule requires both: rows waiting in send_pending/receipt_pending and no successful provider operation for over two hours. The gauge is process-local and starts at zero, so a bex-api restart makes the age trivially huge — deliberately not guarded away, because a fresh process with queued work it cannot deliver is the failure, while a process that can deliver stamps the gauge within one worker poll, far inside the 30-minute persistence window. A phone that stops buzzing looks exactly like a quiet day (ADR048), which is why this needs a rule rather than a dashboard.
Both rules use a traffic floor rather than an or vector(0) coercion: a window with no attempts produces no series at all, which reads as "nothing happened" instead of as a fake healthy zero.
The rule pack and the tenant reads above are two of three answers to "how would we know". The third is the scheduled probe, for failures visible only from the tenant's side — a query that returns empty, a URL that 404s, an isolation invariant. ADR088 §6 carries the per-surface ledger: each tenant-facing surface classified as covered by an alert rule, covered by a scheduled probe, or waived with its reason, and no surface left implicitly uncovered.
Every rule carries one of three severities, and the Alertmanager route in prometheus.yaml gives each a different delivery contract. The tier is the routing, so a rule's severity label is a product decision, not a hint.
| tier | who owns it | delivery | timers |
|---|---|---|---|
critical |
on-call, now | its own email per alert group (alertname + namespace); resolved mail sent |
group_wait 30s; re-sent every 4h while firing |
warning |
someone, this week | one digest email listing every open warning (group_by: [severity], receiver platform-digest); no resolved mail — the next digest simply omits what cleared |
a new warning joins within 30m; re-sent at most every 24h |
info |
the tenant | none — terminates at the no-config null receiver; visible in Alertmanager/Grafana only |
— |
Why the digest exists (2026-09-11): before it every warning repeated every 4h exactly like a page, and four alerts that had been firing for days — one a false positive, three real but unowned — were 24 emails a day. That is the inbox-delete-key failure mode, where real pages get deleted with the noise. A warning is a ticket, so it is delivered like one: visible every day, never six times a day.
info is the tier for customer-actionable signals that must stay visible to the dashboard/API (and in Alertmanager) but must never wake on-call: TenantCustomDomainCertNotReady and TenantCustomDomainCertExpiringSoon, where a tenant's own custom-domain DNS isn't pointing at bex (or moved after issuance), so the platform can do nothing but surface it to the tenant. Everything the operator actually owns is warning/critical and still emails.
Inhibitions keep it to one page per broken thing: CertificateNotReady silences CertificateExpiringSoon for the same Certificate, and either tenant-cert info alert silences both platform cert rules for the same Certificate — belt-and-braces over the rules' own carve-out, so a loosened selector cannot leak a tenant cert onto on-call. equal: [namespace, name] pins the pair to one object. A generic critical-inhibits-warning rule was rejected: Alertmanager treats a label missing on both sides as equal, and most bex-group alerts carry no namespace, so one namespace-less critical would have muted every namespace-less warning.
Silences persist. Alertmanager runs on a small PVC (persistence.enabled), so a silence — the standard way to acknowledge a known alert while its .pm item is open — survives a pod restart. Before this the pod was ephemeral and every restart dropped every silence, which is why none existed while four alerts fired for weeks. A StatefulSet's volumeClaimTemplates are immutable, so the resource carries Force=true,Replace=true and the Application syncs with ApplyOutOfSyncOnly=true: Argo delete-and-recreates the StatefulSet only when the StatefulSet itself drifted, never on a rule edit. Create silences through the Alertmanager API (kubectl -n monitoring port-forward svc/prometheus-alertmanager 9093, then amtool --alertmanager.url=http://127.0.0.1:9093 silence add …) with a comment naming the .pm item.
Ownership rule. An alert firing for more than three days must have either a fix in flight or a silence whose comment names its .pm note; the daily digest is the audit that keeps an unowned warning visible without making it noise. The never-issued-certificate lesson generalizes: an alert that fires on a state nobody can act on is not "a warning we tolerate", it is a bug in the rule, fixed at the rule — with a promtool test for the exact input that fooled it — and never muted at the inbox. Two more rules were re-shaped the same day for the same reason: EgressMeterTargetMissing's expected-node count now means "nodes the meter DaemonSet can schedule on" (it named one pool taint and fired continuously once a sandbox pool and a build-only node existed), and BillingWebhookDrift no longer counts a single unsigned POST to the public webhook endpoint as drift (thirteen scanner hits in three days had each paged on-call for 15 minutes); a signature-reject burst is the new BillingWebhookSignatureRejects warning.
scripts/alerts-verify.sh preserves the null and platform-digest receivers when it swaps the email receivers for its capture webhook, so its throwaway Alertmanager still loads the committed route.
The receiver is an email on the SendGrid relay bex already runs (the w4/m12 invite / Kratos-courier relay — smtp.sendgrid.net:587 STARTTLS, username apikey, docs/ADR012-auth.md §Email), so there's no new channel credential to mint. The smtp_smarthost/smtp_from/to are non-secret and committed in prometheus.yaml; only the SendGrid API key is out-of-band — read via smtp_auth_password_file from a Secret alertmanager-smtp (key smtp-password) in the monitoring namespace, never committed (same custody rule as the etcd/openbao-backup S3 creds). Create it once per cluster (Alertmanager stays in ContainerCreating until it exists, a deliberate one-time bootstrap like unsealing OpenBao):
# imperative (the SendGrid API key already under .env / GH-secrets custody):
kubectl create secret generic alertmanager-smtp -n monitoring \
--from-literal=smtp-password="$BEX_SMTP_PASSWORD"
# or seal it into a committable SealedSecret (docs/ADR016-sealed-secrets.md):
scripts/seal-secret.sh monitoring alertmanager-smtp smtp-password="$BEX_SMTP_PASSWORD" \
> deploy/gitops/base/sealed/alertmanager-smtp.sealedsecret.yaml # then add to base kustomizationOn the local mock cluster the overlay disables Alertmanager entirely (alertmanager.enabled=false) — the disposable CAPD cluster has no SMTP cred, just as it has no backup S3 creds, so the workload is dropped rather than left pending on a missing secret (mirrors the $patch: delete of the backup CronJob Applications). The server still evaluates the rule pack (visible in the Prometheus UI), and scripts/alerts-verify.sh stands up its own throwaway Alertmanager with the email receiver swapped for an in-cluster capture webhook — so no committed secret and no real mail are needed to test the loop.
Prometheus and Alertmanager cannot report their own death, and a notification path that silently fails looks exactly like a quiet night (a no-MX recipient once swallowed 512 alert emails, w8/m30). So the pipeline watches itself in two layers (w3/m174):
- In cluster: Prometheus scrapes itself and Alertmanager.
AlertmanagerNotificationsFailing,PrometheusRuleEvaluationFailuresandPrometheusNotificationsDroppedpage on failures inside the path (they travel the same path, so they cover partial failures, not total ones). - Out of cluster:
Watchdogalways fires and is routed to thenullreceiver (it never emails).scripts/alerting-heartbeat.sh, run hourly by.github/workflows/alerting-heartbeat.ymlon the self-hosted production pool, confirms the Watchdog is active in Alertmanager, that no notifications failed in the last hour, and thatapi.bex.coanddashboard.bex.coanswer — and keeps one GitHub issue (labelalerting-heartbeat) open while any of that fails. GitHub's own notifications deliver it, independent of SendGrid. This is the "external uptime check" ADR088 §1 relies on for whole-cluster outages.
Responding: a heartbeat issue naming Watchdog means Prometheus or Alertmanager is down (kubectl -n monitoring get pods); naming notification failures means delivery is broken (check the alertmanager-smtp Secret and SendGrid activity); naming the API or dashboard means a public outage.
TenantPodUnschedulable pages when a tenant serving or datastore pod (owned by a ReplicaSet, StatefulSet or CNPG Cluster in a tea-* namespace) has been unschedulable for 10m. Tenants only see their service stuck Deploying; the cause is a tenant pool at its cluster-autoscaler maximum (infra/clusterapi/overlays/hetzner-caph/cluster.yaml) or a failed scale-up. Job pods are excluded: builds have BuildQueuedTooLong, backups their staleness alerts. First checks: kubectl -n <ns> describe pod <pod> (the scheduler's reason), cluster-autoscaler logs, and the Cluster capacity dashboard's "Unschedulable tenant pods" panel.
The static-server serves every tenant static site and the activator answers every wake, first-deploy, suspended and maintenance response, so each runs two replicas on the platform pool behind a PodDisruptionBudget (minAvailable: 1) with preferred anti-affinity (w3/m175). StaticHostingPathDown pages when either Deployment has no available replica for 2m — a platform-wide outage of that path; one of two missing is PlatformDeploymentNotReady's job. Both are scored from kube-state-metrics, so they cost no extra scrape. First checks: kubectl -n bex-system get deploy,pdb,pods -l 'app.bex.co/component in (static-server,activator)' -o wide; a static-server that exits at start logs "build the first static-site snapshot" when it cannot list Apps (it refuses to serve an empty host map).
The rule pack is the single source of truth embedded in prometheus.yaml. scripts/gitops-validate.sh (CI: .github/workflows/gitops.yml) extracts it and runs promtool check rules plus promtool test rules against deploy/gitops/base/rules/alerts_test.yml — unit tests pin the non-obvious expressions (backup-age fires at >26h not 25h; the 5xx ratio ignores tiny denominators; CrashLoop respects for: 10m). A regressed expression fails CI, not prod.
scripts/alerts-verify.sh (mock cluster) proves fire→notify→resolve without waiting for a real outage: it points the receiver at an in-cluster capture pod, then breaks two invariants and watches both alerts arrive and clear.
- Bad rollout →
kubectl -n bex-system set image deploy/bex-api api=nonexistent:bad⇒PlatformDeploymentNotReady(andBexApiDown) after thefor:window; revert ⇒ resolved notification. - Backup staleness → apply a
kube_cronjob_status_last_successful_timefixture (or temporarily shorten the rule window) ⇒BackupCronJobStale; restore ⇒ resolved.
Observed end-to-end latency ≈ evaluation interval (default 1m) + the rule's for: + group_wait (30s) — e.g. a for: 0m alert like BackupCronJobStale notifies within ~1–2 evaluation windows; the for: 5–15m alerts add their persistence window.
# deploy a sample App, then:
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8090/v1/logs?resource=<app>&type=app" | jq .
# resource metrics — with BEX_PROM_URL set these are stepped history; a ranged
# query returns one point per resolution step per instance:
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8090/v1/metrics/cpu?resource=<app>&percentage=true" | jq .
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8090/v1/metrics/memory?resource=<app>&startTime=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)&endTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)&resolutionSeconds=60" \
| jq '.[0].values | length' # ≈60 points over the hour (where data exists)
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8090/v1/metrics/instance-count?resource=<app>" | jq .