Monitor and troubleshoot¶
Use readiness for admission, metrics for trends, and audit/authority records for accounting evidence. A green process health check alone does not mean requests can be admitted.
Health and signal channels¶
| Signal | Endpoint / transport | Use |
|---|---|---|
| Process liveness | GET :9090/healthz |
Is the gateway process serving? |
| Readiness | GET :9090/readyz |
Can this profile admit traffic under its current dependencies/gates? |
| Metrics | GET :9090/metrics, Prometheus |
Latency, requests, cost estimates, refusals, audit health |
| Traces | Opt-in otel or governed exporters, OTLP HTTP/gRPC |
Request/attempt timing and bounded metadata |
| Governed export | observability.exporters + policy, OTLP logs |
Audit-record copies and egress content per team/user (details) |
| Usage windows | Gateway to CP /v1alpha1/usage |
Control-plane analytics; not OTLP |
| Audit | Configured per-instance sinks/WAL | Request evidence and exact-byte chain verification |
Health and metrics are unauthenticated by design. Restrict the admin network path. The static console is public/data-free; its data APIs require authentication.
Start with the Grafana dashboard. Configure your Prometheus scrape or collector receiver separately from the OTLP trace receiver. The chart does not install a ServiceMonitor.
Signals worth alerting on¶
| Signal | Investigate |
|---|---|
| Sustained readiness failures / 503s | Policy sync, identity fingerprint, DB availability, authority grant/journal, audit WAL pressure |
inferplane_pricing_miss_total |
Missing route rates or incorrect pricing metadata |
inferplane_audit_write_failures_total |
Sink permissions, capacity, downstream availability |
inferplane_audit_buffer_utilization_ratio |
Retained WAL records / 1024; at 1 or above, new inference is blocked. Check /readyz for a latched WAL failure even below 1 |
inferplane_audit_anchor_failures_total |
Object storage/IAM/retention configuration |
inferplane_audit_auto_mode_unparsed_total |
Claude Code auto mode block calls or server safeguards the console could not read (ADR-057); a rise suggests a client format change |
gen_ai_server_time_to_first_token_seconds |
Upstream or request-path latency |
inferplane_fallback_total / circuit state |
Provider failure or incompatibility |
inferplane_usage_windows_dropped_total |
Analytics delivery loss; distinguish from authority accounting |
process_resident_memory_bytes, go_memstats_heap_inuse_bytes, go_goroutines |
Memory growth: the gateway holds several copies of every request body being processed (about 5.5× measured), so concurrent large bodies dominate |
inferplane_model_fallback_total |
Clients requesting unrouted model IDs (a typo or a newer alias); the log line router: model fallback names the requested ID |
inferplane_inflight_refusals_total, inferplane_inflight_request_bytes |
In-flight body budget (server.max_inflight_request_bytes) too small for the workload, a burst of large requests, or a slow or stalled request body holding its reservation until it finishes or the connection closes (size the budget at several times server.max_request_bytes; route is generation or count) |
Choose thresholds from measured traffic and the selected profile. Prometheus spend is observational; integer accounting records and authority commitments determine available balance.
Troubleshooting by symptom¶
| Symptom | First checks |
|---|---|
| 401 | Virtual key vs provider/admin token; selected database; revocation and identity bindings |
| 403 | Model/team access, privacy/region restriction, policy compatibility |
| 402 in authority profiles | Money exhausted or conservative bound exceeds available balance |
| 429 | Rate/token quota exhaustion; inspect the enforcing scope |
| 503 before first request | Required sync, shared namespace/binding, initial grant, DB/journal availability |
503 with Retry-After: 2 during a burst of large requests |
In-flight body budget (server.max_inflight_request_bytes); inferplane_inflight_refusals_total |
| Retryable grant wait | Respect Retry-After; do not lower accounting bounds to force admission |
| HTTP 200 stream ends early | Inspect terminal protocol error, observed usage and retained liability |
| Count returns zero while generation refuses | Counts remain local/200; check readiness and key-store availability |
| Missing model in Codex | Explicit tool/Responses capability, native catalog mapping, installed client version |
| Lower cache hit rate | Protocol conversion, masking, model switching, changed request bytes |
Read outcome and cost evidence¶
The gateway Overview, Usage and Logs use observed completion records (ADR-053).
They include costless failures previously absent from analytics. The denominator
is request_completed, not all received traffic or unique requests/sessions.
request_started refusals, unauthenticated 401s, admin events and client-local
failures are excluded; retries/fallbacks may have different recording granularity
across ingresses. A succeeded record describes transport, not model answer quality
or completion of the user's task.
Investigate (failed + partial) / completed with its numerator, denominator and
unknown count. Partial takes precedence even with HTTP 200; missing/invalid
outcomes remain unknown. With no completions the ratio is —. Unknown outcomes
make the observed proportion incomplete, rather than successful. Metrics traffic
and this denominator are separate observations.
Read cost state before amount: estimated is a usage/rate-based estimate (zero
is valid), uncertain is a recorded amount with incomplete evidence,
legacy_unknown lacks usage evidence, unpriced lacks pricing and unavailable
has no Cost record. Unpriced/unavailable numeric zeros are not known dollar
amounts. Estimated totals and uncertain/legacy recorded totals are separate;
the existing Cost-present financial totals remain unchanged. No state is a
supplier invoice or permission to refund retained liability.
For Responses streams the bounded reasons are context_length_exceeded,
invalid_encrypted_content, request_canceled, upstream_timeout,
stream_incomplete, response_incompatible, usage_missing,
client_write_failed and upstream_error. Known provider codes require typed
provider evidence. Cancellation/deadline and gateway reasons require explicit
runtime evidence; raw error text and native provider-owned frames are not parsed
to guess a code. Unknown nonempty analytics codes become upstream_error.
Before streaming commits, existing HTTP/retry rules apply. After commit, the
response retains HTTP 200 with partial=true and the same bounded code in audit
and a gateway-generated SSE error when writable; it is not retried. Client-write
failure records client_write_failed without forcing another SSE write. Native
success bytes remain intact. Observed usage still follows the existing exactly-once
finish/settlement path; missing usage is not fabricated as zero.
Check legacy_projection_records, /admin/analytics/health, queue diagnostics
and retained source segments together. Version 0 means source evidence has not
been replayed; version 1 can still be legacy_unknown if the original lacked
usage evidence. Zero legacy rows does not prove full historical collection.
Retention gaps, the local nonblocking 4096-entry queue's drops, collection lag,
truncated JSONL tails and requests with only request_started limit coverage.
Missing fields from old APIs require an evidence-unavailable notice.
Use the API examples and recovery procedure. This projection adds no metric labels, raw prompts/errors, key/session identifiers or financial authority. Default/local, node-local authority and shared-governance dependency contracts retain their existing profile-specific limits.
Verify records¶
bin/mayu audit verify --file /path/to/instance-audit.jsonl
bin/mayu report --file /path/to/instance-audit.jsonl --by team,model
Keep instance/restart segments distinct. A valid local chain does not prove resistance to a host rewriting its history; configure and verify external anchoring when that evidence is required.
For an issue report, include the commit/image, profile, client version, status/error class, sanitized configuration, and whether the failure occurs before egress or during streaming. Exclude keys, credentials, raw prompts, identity declarations and DSNs. Send security reports through the private security policy.
Audit durability limits¶
The default chart writes audit records to container stdout (a best-effort sink) with a WAL on the data volume and the local analytics index disabled, so nothing accumulates there during successful WAL operation; see Collect audit records. Any other deployment must configure its sinks explicitly.
audit.failure_mode defaults to buffer_then_block, the only supported mode.
A required-sink failure retains that record and all later records in the WAL.
At 1024 retained records the writer latches unready: authenticated new inference
receives a protocol-specific 503 with Retry-After, and /readyz reports 503.
WAL append/truncate failures latch unready immediately. Best-effort sink failures
do not close admission. Count APIs remain local/200 and model listing remains
available. Health checks continue to report process liveness.
The latch does not recover automatically. Already admitted requests, admin events and count requests may still append records, so 1024 is an admission threshold, not a strict record or byte bound. Startup validates and counts retained WAL records; restarting alone cannot clear a full backlog, and a malformed/truncated backlog refuses startup. No automatic sink replay is performed because a previous partial fan-out may already have delivered some records. Preserve and reconcile evidence using the audit recovery procedure before restart. Readiness and local chain verification do not prove complete historical delivery.
Collect audit records¶
examples/otel-collector/audit-filelog.yaml
is an OpenTelemetry Collector (contrib) configuration for a node DaemonSet. It tails
/var/log/pods/*/inferplane/*.log, keeps only lines that start with
{"schema_version" (mayu also prints startup lines), and exports each body
verbatim to S3 as JSONL. Checkpoints and the send queue persist in file_storage.
- Do not parse and re-serialize record bodies: each
prev_hashcovers the previous line's exact bytes, and a rewritten line failsmayu audit verify. - stdout is not a required sink, so the container log is the gateway's remaining
local copy until the collector durably enqueues the record. The collector's
persistent queue holds a separate local copy pending export. Kubelet rotation
is size-based (
containerLogMaxSize×containerLogMaxFiles); size it for the longest period when the collector cannot ingest (about 1.3 KB per request). - Verify each instance's collected records at the destination, together with external anchoring: a truncated or restarted chain still verifies without an anchor.
Verified on 2026-09-25 with otelcol-contrib 0.119.0 against a CRI-format log that
included a split (partial) line: 41 records exported byte-identically and the
collected file verified with mayu audit verify. The S3 upload itself was not
exercised.
Governed OTLP export¶
mayu can send traces, audit-record copies and egress content directly to OTLP collectors. Two layers decide what goes where:
- Local ceiling —
observability.exportersin mayu's config declares every collector a policy may select: endpoint, protocol, TLS, header credentials (env/filerefs only) and the signals it may carry. Only this file can add a destination. - Policy choice — an
observabilityrule in a GovernancePolicy (local files or the control plane) selects, per team or user, which declared exporter receives which signal.
The control plane is not on this path. It keeps receiving only consumption reports, leases and usage windows on its own channels.
"observability": {
"service_name": "inferplane",
"exporters": {
"corp": {
"endpoint": "otel-collector.internal:4318",
"signals": ["traces", "audit"],
"headers": { "authorization": { "env": "OTEL_COLLECTOR_AUTH" } }
},
"vault": {
"endpoint": "secure-collector.internal:4317",
"protocol": "grpc",
"signals": ["content"],
"data_boundary": "internal",
"max_content_bytes": 2097152
}
}
}
apiVersion: inferplane.dev/v1alpha1
kind: GovernancePolicy
metadata:
name: engineering-observability
spec:
subject:
team: engineering
rules:
- name: export
failurePolicy: FailOpen
observability:
traces: { exporter: corp, samplePercent: 20 }
audit: { exporter: corp }
content: { exporter: vault }
| Signal | What is exported | Notes |
|---|---|---|
traces |
The request span (GenAI and inferplane.* attributes) |
samplePercent 1–100, default 100 |
audit |
Each audit record as an OTLP log (event_name inferplane.audit) |
Body is the canonical chain line; inferplane.audit.hash equals the next record's prev_hash |
content |
Egress request body and, when buffered, the response (inferplane.content) |
Same bytes as body capture: after routing and masking, only for requests that reached a provider; streams carry no response |
An exported audit record is the same canonical line every audit sink receives,
so it also carries the ADR-057 evidence fields a record has: attempts,
client (including the key-scoped client.session_ref digest), auto_mode and
governance. A collector that receives audit copies can therefore group one
key's requests by session, as the audit file can. Export adds no metric label:
inferplane_otel_export_records_total keeps exporter, signal and outcome.
Rules a data plane enforces:
- An observability rule must be
FailOpen. Export is best-effort; the WAL and required audit sinks remain the evidence of record. - A rule that names an undeclared exporter, or a signal the exporter does not allow,
is refused. Local policy files fail the boot or reload. Control-plane documents
are rejected per document and reported in
Rejections. A data plane without anobservabilityblock refuses every observability rule. - Keep observability rules in their own documents: a rejected document is removed whole, including any budget rule in it. In required-identity mode one rejection keeps the previous distributed set.
- When several policies match a request, traces and audit go to the union of the
named exporters (traces at the highest
samplePercentper exporter). Content is exported only when every matching rule names the same exporter. - A content exporter must declare
data_boundary: internaland use TLS unless its endpoint is loopback. Records larger thanmax_content_bytes(default 2 MiB) are dropped, never truncated. - The gateway draws the sampling decision: a random draw per request and target, not
deterministic on the trace id, so replicas and upstream services do not sample the
same traces. A span no exporter selected is not recorded. An incoming
traceparentstill joins the client's trace id, but its sampled flag neither suppresses nor forces export. The legacyotelblock keeps working as a local baseline exporter with these same semantics (itssample_rationo longer follows a parent's sampled flag); the nameotelis reserved. - Each exporter holds at most
queue_bytes(default 64 MiB) of queued records while its collector is slow; further records are dropped. The queue's slots take about 1/16 ofqueue_bytesmore, allocated at startup. Batches are sent every second or at 512 records. HTTP 429/502/503/504 and gRPCUnavailable,ResourceExhausted,DeadlineExceededandAbortedare retried twice. Watchinferplane_otel_export_records_total{exporter,signal,outcome}.withheld_limit(a runtime limit withheld a policy selection) andsampled_out(a trace target's sampling skipped the span) are outcome values of that metric too.inferplane_otel_export_decisions_total{signal,decision}counts what governed export decided (selected,no_rule,content_conflict,withheld_limit); neither family has a team, user or key label. - Changes to
observabilityneed a restart; SIGHUP reload does not rebuild exporters. Shutdown drains the audit writer first, then flushes exporters for up to 5 seconds.
Limits: metrics stay Prometheus exposition; there is no OTLP metric export. An operator who controls mayu's host can edit the local ceiling, so export that users cannot turn off requires mayu to run on a host they do not administer.
Observability console¶
Full administrators can open Observability in the gateway console
(/admin/ui/). It shows each declared exporter (endpoint, protocol, TLS, data
boundary, header names, declared and current signals, queue bytes), records per signal since the
process started (sent, dropped, withheld by a limit, sampled out, last success,
last error), governed decisions per signal (exported, no rule,
rules disagree on content, withheld by a limit) and every observability rule with
its sampling after limits. The last error is a status code or a fixed text such
as collector unreachable or span export failed, never collector content. The
legacy otel baseline is not a policy selection, so a span only it exports
counts as no_rule in the decisions. Counters reset when mayu restarts; use
inferplane_otel_export_records_total and inferplane_otel_export_decisions_total
for history. The legacy otel exporter is listed read-only.
Edits are opt-in in the bootstrap config:
"policies": ["/etc/inferplane/policies", "/var/lib/inferplane/console-observability.yaml"],
"observability": {
"exporters": {
"corp": { "endpoint": "otel-collector.internal:4318", "signals": ["traces", "audit"] }
},
"console": {
"state_file": "/var/lib/inferplane/observability-limits.json",
"policy_file": "/var/lib/inferplane/console-observability.yaml",
"content_rules": false
}
}
- Runtime limits (
state_file): per declared exporter, pause it, keep a subset of its declared signals, lowermax_content_bytes, or cap trace sampling withmax_sample_percent. A limit only narrows: it cannot add a signal, raise a size, or change an endpoint, protocol, TLS setting or header. Limits withhold export when the decision is made and again when a queued audit or content batch is sent: records queued before a pause are dropped then and counted aswithheld_limit, while a batch already being sent, retries included, completes. Spans already handed to the trace SDK's batch processor are not rechecked: a pause stops new spans at the routing decision, and spans already batched may still be sent within the batch delay (about 5 seconds by default). Limits never reject a policy document, so a control-plane document keeps its budget rules. They stay editable with a control plane because they narrow the local ceiling. mayu writes the file (0600, atomically) and loads it at boot; a file that does not parse refuses boot instead of resuming paused export. Entries the bootstrap no longer supports are narrowed and logged. Edit the file by hand only while mayu is stopped. - Console rules (
policy_file): GovernancePolicy documents namedconsole-…, one FailOpen observability rule each, written by the console and loaded by the local policy file channel like every other policy file. The file must also be listed inpolicies, so it cannot be combined withcontrol_plane; with a control plane the console shows rules read-only. Rules in other policy files are read-only too. Selecting content needscontent_rules: true; without it the console can keep or remove a content selection, never add or redirect one. mayu creates an empty file on first boot. - Edit flow: every edit is previewed first (
dry_run), showing the changed fields, the rules whose export changes, or the decisions per subject. Apply sends the same body with the revision the preview saw (409 if anything changed in between), persists, applies (rules through a policy reload; a failed reload restores the file) and appendsadmin_observability_changedwith the administrator and the SHA-256 of the persisted state before and after. Edits are refused while the audit chain is not accepting records. - Scope: one mayu process. The state file, the console policy file and the
counters belong to the mayu process that answered; the view's header names it
(
instancein the status, the audit chain's instance id). With several replicas (the ADR-046 shared profile allowsreplicaCountabove 1), a pause or a rule edit applies only to the replica that served the request, and a later refresh may be answered by another replica. Keep one replica per edit target, or make the same edit on each replica by reaching its pod directly (for examplekubectl port-forwardto that pod's admin port) and check the header each time. The files live on the process's data volume, so a pause survives a process restart only on the same volume: with the chart's defaultemptyDirvolume a replaced pod starts without the state file, so the pause lapses, and with an empty console policy file, so console rules lapse too. Setpersistence.enabledwheneverstate_fileorpolicy_fileis set; the shared profile then gives each replica its own claim.
Runtime limits and console rules apply without a restart; changes to
observability.exporters and observability.console still need one. The
API reference lists the
request and response shapes.