better/compute

Observability

Prometheus metrics with bounded labels, a bearer-protected /metrics route, ComputeSpan observers, trace context, health endpoints and doctor.

@better-compute/observability holds the pieces a host uses to watch a deployment: a Prometheus registry that refuses tenant labels and caps its series, the standard better_compute_* metric set of PLAN.md section 14, a GET /metrics handler behind a bearer token, wrappers that meter IAM calls, gateway services, attestors and providers, ComputeSpan observers and W3C traceparent helpers. It is browser-safe (no Node.js imports), so every package can depend on it. The CLI host mounts it for you when the configuration's host export has a registry.

Defined is not the same as recorded

standardMetrics(registry) fixes the names, labels and buckets of every section 14 series, so all packages record the same thing. The built-in pieces record: the gateway series sampled by gatewayCollector, the gateway service calls, outbox events by family, the deployment-wide series databaseCollector samples from the database (below), placements through placementObserver, and whatever you wrap (meteredIamPort, meteredAttestor, meteredProvider, spans). gateway_sessions, gateway_handshakes_total, commands_swept_total, reconcile_duration_seconds, usage_dead_letters_total and security_events_total are defined but no package sets them yet; they expose their HELP and TYPE lines with no samples until your host records them.

Wire metrics into a deployment

One registry per process. The configuration creates it, registers the standard series, passes it to what it meters, and hands it to the host through host.metrics:

better-compute.config.ts
import { defineHost } from 'better-compute/cli';
import { createMetrics, eventCounter, meteredIamPort, standardMetrics } from 'better-compute/observability';
import { eventsModule } from 'better-compute/events';
import { defineConfig } from 'better-compute/server';

export const metrics = createMetrics(); // maxSeries 2000 per metric
const standard = standardMetrics(metrics);

export default defineConfig(({ env = process.env } = {}) => ({
  // …database, secret, baseURL
  iam: meteredIamPort(bridge.embedded(iam, { rootTenantId }), standard), // iam_calls_total, iam_call_duration_seconds
  modules: [eventsModule({ secret: env.BETTER_COMPUTE_SECRET!, countEvent: eventCounter(metrics) })],
}));

export const host = defineHost(async ({ env }) => ({
  metrics: { registry: metrics, bearerToken: env.BETTER_COMPUTE_METRICS_TOKEN }, // GET /metrics
}));

better-compute serve then:

  • mounts GET /metrics (or host.metrics.path) on the API listener when bearerToken is set; without a token there is no route;
  • when it runs the gateway role, meters compute.gatewayServices with meteredGatewayServices and samples the gateway at every scrape with gatewayCollector;
  • when it runs any worker role, starts databaseCollector into the same registry: a first reading at start, then every 15 s, stopped with the server. It takes each command kind's lane from the registry and watches the host's own certificates as cert_expiry_seconds{kind}: node-ca, command-signing and serving (the gateway's TLS certificate).

better-compute dev mounts the configuration's host.metrics too. The examples read the token from BETTER_COMPUTE_METRICS_TOKEN.

Scrape it with the token as a bearer credential:

prometheus.yml
scrape_configs:
  - job_name: better-compute
    authorization:
      type: Bearer
      credentials_file: /etc/prometheus/better-compute.token
    static_configs:
      - targets: ['compute-api-1:3000', 'compute-api-2:3000']

The registry

createMetrics({ prefix?, maxSeries?, forbiddenLabels?, now? }) returns a registry of counters, gauges and histograms.

  • Prefix. Every name gets better_compute_ unless it already has it. Names and labels must match [a-z_][a-z0-9_]*; le is reserved.
  • No tenant labels. A label named tenant, tenant_id, tenantid, org, organization, identity or user is refused with INVALID_CONFIG when the metric is defined. Per-tenant numbers belong in usage and audit, not in your TSDB.
  • Bounded series. Each metric holds at most maxSeries label sets (default 2000). New label sets past the cap fold into one series whose labels all read (other), and better_compute_metrics_collapsed_total counts every folded recording.
  • Caller-controlled values. A label value that is not a string or is longer than 256 characters reads (invalid); an empty one reads (unknown).
  • Collectors. registry.collect(fn) runs fn at the start of every expose(), for gauges sampled at scrape time. A collector that throws is skipped for that scrape.
  • Exposition. expose() renders text format 0.0.4. value(name, labels) and stats() (series, dropped) serve tests and health checks.

metricsHandler({ registry, bearerToken, timingSafeEqual }) answers GET only (405 otherwise), requires a token of at least 16 characters, compares the SHA-256 digests of the presented and expected tokens with the constant-time function you pass (crypto.timingSafeEqual in Node), answers 401 with WWW-Authenticate: Bearer on a mismatch, and serves the text with Cache-Control: no-store.

The standard series

standardMetrics(registry) defines the section 14 series: sessions and handshakes, command latency and sweeps, the work queue and reconcile durations, placements and booking conflicts, pool GPUs, node connectivity, backlogs, IAM calls and resolver lookups, certificate expiry, clock offset, and security events. None carries a tenant label. The metrics reference lists every family with its type, labels, help text and buckets, generated from metricCatalog(), so it always matches the code. This page covers who records them.

Two IAM series have hooks in the IAM bridge you can point at them: bridge.plugin({ onSpill }) is called for every event the plugin spills, and bridge.resolveResource(fallback, { onLookup }) reports each lookup as hit, miss, timeout, unavailable, scope or fallback. Hook failures never change a decision.

const plugin = bridge.plugin({ onSpill: () => standard.iamInboxSpill.inc() });
const resolveResource = bridge.resolveResource(undefined, {
  onLookup: (result) => standard.resolverLookups.inc({ result }),
});

The database collector

Some section 14 series describe the whole deployment, not one process. databaseCollector samples them from compute's own tables with read-only, bounded statements:

import { databaseCollector, standardMetrics } from 'better-compute/observability';

const collector = databaseCollector(standardMetrics(registry), {
  sql: compute.context.sql,
  laneOf: (kind) => compute.context.registry.commandKind(kind)?.lane, // default label (unknown)
  certificates: () => [{ kind: 'serving', notAfter }], // host certificates besides node certificates
});
collector.start(); // every intervalMs (default 15 s); refresh() polls once; stop() ends it
SeriesSampled from
work_queue_depth{kind}Due, unclaimed work_queue rows by kind
backlog{queue}Pending commands, unprocessed iam_inbox rows, unrecorded usage_windows, pending webhook_deliveries, events a sink gave up on (outbox_dead_letters), and each outbox_cursors reader's lag as outbox:<name>; each capped at 100,000
node_connectivity{state}Nodes by connectivity
generation_lag_nodesNodes whose observed generation is behind
pool_gpus{state}pool_capacity_rollup GPUs: total, allocatable, allocated, reserved, provisioning, unhealthy, maintenance, unreachable
cert_expiry_seconds{kind}Each node's newest live certificate, soonest per role as node:<role>, plus the certificates you pass
clock_offset_secondsThis process's clock minus compute_now()
command_latency_seconds{kind,lane}Commands settled since the last poll, updated_at - created_at (needs commands.created_at, core/0020)

A table an optional module did not create is skipped, and a label set that disappears reads 0 instead of going stale. Latency starts at the first poll, so a restart does not observe history twice, and at most 5,000 settled commands are observed per poll. Poll failures go to onError and never throw.

Any number of processes may run the collector, and serve starts it in every process with a worker role. Each one reports the same deployment-wide gauges, so query them with max rather than sum across instances. The latency histogram counts each command once. Only the holder of the leases row metrics:command-latency observes command_latency_seconds: the row is taken or renewed on each poll with a TTL of three polls, and released on stop(). A stopped collector never takes it again. Pass latencyLease to name another row, or false to observe in every process.

Series the instrumentation adds

Beyond the standard set, these come from the collectors and wrappers that record them:

SeriesFromLabels
better_compute_gateway_open_sessionsgatewayCollectorgateway, role
better_compute_gateway_stream_headroom, _draining, _lease_held, _commands_in_flight, _sessions_upgrade_requiredgatewayCollectorgateway
better_compute_gateway_loop_consecutive_failures, _loop_failures, _loop_last_ok_age_secondsgatewayCollectorgateway, loop (dispatch, lease, liveness, specs, status)
better_compute_gateway_service_calls_total, _service_secondsmeteredGatewayServicesmethod (join, renew, secret), outcome
better_compute_attestor_verifications_total, _verification_secondsmeteredAttestorattestor, outcome (admitted, deferred-<kind>, refused or an error code)
better_compute_provider_calls_total, provider_call_secondsmeteredProviderprovider, method, outcome
better_compute_revocation_feed_consecutive_failuresrevocationFeedCollectorfeed
better_compute_events_totaleventCounter via eventsModule({ countEvent })family, module
better_compute_placements_total, booking_conflicts_total, placement_secondsplacementObserver via capacityModule({ onPlacement })outcome; none; placer (inline, pool-lease, batch)
better_compute_spans_total, span_duration_secondscreateObserver({ metrics })kind, name, outcome, code; kind

gatewayCollector and meteredGatewayServices are wired by serve for the gateway role; the others are yours to wrap around what you register. Placements, for example, are observed by passing capacity's hook the standard series (booking_conflicts_total counts replans forced by a booking conflict):

capacityModule({ onPlacement: placementObserver(standardMetrics(registry), registry) });

Outcome labels are bounded: ids, method names, error codes and catalog families. Help text and buckets for each of these are in the metrics reference.

Spans and traces

createObserver({ onSpan?, metrics?, now? }) times units of work as a ComputeSpan, shaped like better-iam's IamSpan:

interface ComputeSpan {
  kind: 'operation' | 'decision' | 'http' | 'command' | 'reconcile' | 'placement' | 'iam' | 'sink';
  name: string; // method, route, command kind or reconciler
  tenantId?: string; // for hooks only, never a label
  outcome: 'ok' | 'denied' | 'error';
  code?: string;
  status?: number;
  requestId?: string;
  durationMs: number;
}

observer.span(kind, name, fn, extra?) runs fn, classifies the outcome (401, 403 and 429, or the codes UNAUTHENTICATED, ACCESS_DENIED, CONFIRMATION_REQUIRED, RATE_LIMITED, CSRF_REJECTED and UNTRUSTED_ORIGIN, are denied; anything else that throws is error), fans the span out to onSpan hooks and feeds the span metrics. Hooks and metrics can never affect the work: their failures are swallowed. A name the caller controls (an unknown route, NOT_FOUND, INVALID_INPUT, METHOD_NOT_ALLOWED) is recorded as (invalid) or (unknown), so it cannot grow the series. The server does not create an observer itself; hosts use it to export spans to their tracing system.

Trace context. parseTraceparent, formatTraceparent and childTraceparent handle W3C Trace Context version 00 (00-<32 hex trace id>-<16 hex parent id>-<2 hex flags>), refusing all-zero ids and version ff. The protocol carries traceparent on every CommandBody and DesiredStateBody, and the driver SDK passes it to drivers, so a daemon's work can join the request's trace. A valid traceparent header on an API request (version 00, non-zero trace and parent ids) reaches the method as MethodContext.traceparent, and ctx.commands.enqueue in that method defaults CommandBody.traceparent to it, so the commands a call enqueues carry the caller's trace. An invalid header is dropped, not refused. Commands enqueued by reconcilers, and NodeSpecs, go out with it empty.

Request ids. The API takes the caller's X-Request-Id when it is 1 to 128 characters of letters, digits and ._:-, else it mints a req_ id, and returns it in the response's X-Request-Id. IAM receives the same id in the forwarded headers, and the admission context of a mutation's audit row records it, so a support ticket with the header finds the call.

Logs. betterCompute({ log }) takes a Logger; the default writes warnings and errors as JSON to standard error. GPU telemetry does not go through the control plane (principle P8); the nodes send it straight to your collector.

Node metrics from bcd

bcd exports its own metrics over OTLP/HTTP, in the JSON encoding, to the collector its NodeSpec's telemetry section names (27c7ee2). Nothing reaches the gateway, the session or PostgreSQL.

Where to. nodeTelemetryModule(options) (53aa356) gives every node that section: options are the platform's collector, and a pool's policy.telemetry wins field by field, with resourceAttributes merged by key. A node whose result has no endpoint gets no section, and exports nothing. The module is not registered by default; add it to your modules:

compute.ts
import { nodeTelemetryModule } from 'better-compute/observability';

betterCompute({
  // …
  modules: [
    nodeTelemetryModule({
      otlpEndpoint: 'https://otel.example.internal:4318',
      intervalSeconds: 30,
      headersRef: 'secret://root/sec_01k…', // the collector's auth headers
      resourceAttributes: { 'deployment.environment': 'prod' },
    }),
  ],
});
SettingRules
otlpEndpointhttps://, or http:// to localhost, 127.0.0.1 or [::1]; no credentials. bcd appends /v1/metrics unless the path already ends with it
intervalSeconds5 to 3600; bcd's default is 60 seconds and its minimum 5
headersRefA secret reference to the headers: a JSON object, or Name: value lines
resourceAttributesAt most 32, single-line values of at most 256 characters; keys under bcd. and service. are bcd's own
rolesModule option only: the node roles that export (default all, site agents included)

The module checks its options when it is built (INVALID_INPUT). A pool whose policy.telemetry fails the same checks is skipped, and its nodes keep the default; better-compute doctor (and the support bundle) reports such pools as pool-telemetry-invalid, from nodeTelemetryFindings(sql).

What. Every interval, bcd sends:

MetricUnitFrom
bcd.gpu.memory.used, bcd.gpu.memory.total, bcd.gpu.memory.reservedbytesnvidia-smi, per GPU
bcd.gpu.utilization, bcd.gpu.memory.utilizationratio (0 to 1)nvidia-smi
bcd.gpu.temperatureCelsiusnvidia-smi
bcd.gpu.power.draw, bcd.gpu.power.limitwattsnvidia-smi
bcd.gpu.clock.sm, bcd.gpu.clock.memoryMHznvidia-smi
bcd.gpu.processesprocessesnvidia-smi
bcd.gpu.mig.memory.used, bcd.gpu.mig.memory.totalbytesnvidia-smi, per MIG instance (mig.id)
bcd.gpu.ecc.errorserrors, cumulative sumthe inventory's aggregate ECC counters, type correctable or uncorrectable
bcd.gpu.rows.remappedrowsthe inventory's row remapping, by type
bcd.gpu.xid.activeconditionsbcd's active XID conditions per GPU
bcd.host.pressure.cpu, bcd.host.pressure.memory, bcd.host.pressure.iopercentPSI some avg10
bcd.host.cpu.stealpercentCPU steal
bcd.host.memory.total, bcd.host.memory.availablebytes (By)/proc/meminfo's MemTotal and MemAvailable (3111284)
bcd.host.hugepages.total, bcd.host.hugepages.freepages ({page})/sys/kernel/mm/hugepages, each pool's nr_hugepages and free_hugepages, by page.size (the page size in bytes)
bcd.instancesinstances ({instance})every instance runtime's list of domains, managed or not, counted by runtime and state (running, stopped, paused, error)
bcd.instance.vcpuvCPUs ({cpu})each domain's configured vCPUs, by instance.id, runtime and managed
bcd.instance.memorybytes (By)each domain's configured memory, by the same attributes and hugepages (the backing page size in bytes, 0 for normal pages)
bcd.instance.memory.balloon.reclaimedbytes (By)the memory a ballooned guest lends the host now: its configured memory less what its balloon leaves it, by instance.id and runtime (9bdd43a; bcd.instance.memory.balloon before). Only for running, managed guests whose spec gives them a balloon (placement.balloon); a guest without a balloon reading counts as fully deflated (0). See memory ballooning
bcd.instance.cpu.timeseconds (s), cumulative sumlive usage (0b21b4b): CPU time since the domain started, by instance.id and runtime
bcd.instance.memory.usedbytes (By)live usage: the memory the domain uses now
bcd.instance.io.read, bcd.instance.io.writebytes (By), cumulative sumlive usage: disk bytes read and written
bcd.instance.network.receive, bcd.instance.network.transmitbytes (By), cumulative sumlive usage: bytes the guest received and sent

Instances. The host and instance series come every interval from a node that has them: the memory and huge page series from any Linux host, the bcd.instance* series only from a host that runs an instance runtime (VPS and sandbox hosts). The configured series (bcd.instances, bcd.instance.vcpu, bcd.instance.memory) cover every runtime, plugin runtimes included. The live usage series come only from runtimes that can read it (runtime.UsageReader), for their running domains, and count from the guest's side:

RuntimeWhere the usage comes from
cloud-hypervisorThe VMM unit's cgroup v2, /sys/fs/cgroup/bcd-instances.slice/bcd-ch-<id>.service: cpu.stat usage_usec, memory.current, and io.stat's rbytes and wbytes over every device; plus the counters of the instance's taps, where a tap's transmitted bytes are what the guest received. Managed domains that are running or paused only
libvirt-kvmvirsh domstats --raw --state-running --cpu-total --balloon --interface --block: cpu.time, balloon.rss (the QEMU process's resident memory) and the summed net.* and block.* byte counters

A domain whose usage cannot be read is left out of the live series. Plugin runtimes report none, and a fakehw build's file-backed runtime reports synthetic usage (half the configured memory, a second of CPU per boot). Under bcd helper libvirt-kvm reads domstats on libvirt's read-only socket (since 12e9ced; before it the read was refused there), and cloud-hypervisor, offered there since 7fe74a1, reads the same cgroup and tap counters as the node's own user, with no ch.* operation: since 1c9684a TestCloudHypervisorUnderTheHelper (internal/runtime) reads a helper-started domain's usage from a fake cgroup tree and tap statistics and checks that root was asked nothing. The series are tested against a fake /proc, sysfs, cgroup tree and captured domstats output (internal/agent telemetry_host_test.go, internal/runtime usage_test.go). On the control-plane side, tests/e2e/helper-vps.test.ts runs sandboxes on FakeDaemon VPS hosts under the helper and checks that their bcd.instance.* usage reaches the collector nodeTelemetryModule names, and leaves the next export once they are deleted (see testing).

GPU points carry gpu.uuid, gpu.index and gpu.model. The resource carries service.name=bcd, service.version, host.name, bcd.node.id and bcd.site.id, then the section's own attributes (which cannot replace those). bcd fetches the headers with a SecretRequest that names workflow host:telemetry and step headersRef; the secret broker releases the reference only while the node's current spec names it there (binding rule 3; see sealed secrets).

Failures. An endpoint that is not an http(s) URL, or a malformed resource attribute, fails the telemetry section terminally; an empty endpoint stops the export. A collector's 4xx answer other than 429 drops that batch; after anything else the export runs again on the next interval, with that interval's fresh readings (a failed batch is never re-sent). bcd logs a failure once, not every interval, and logs when the export resumes. The exporter restarts whenever the section changes. bcd status prints the endpoint, the last export and the last error (ad8791f). It is tested against a fake collector, and since efdff1d end to end: the real fakehw bcd in tests/e2e/bcd.test.ts posts its GPU metrics to a local OTLP receiver the test runs, named in NodeSpec.telemetry (see testing). No collector product has received it yet.

Health and readiness

The CLI host answers two routes beside the API on every api listener:

RouteAnswers
GET /healthz200 { "ok": true } while the process serves requests
GET /readyz200 { "ok": true } when SELECT 1 succeeds on the store, else 503 { "ok": false }

Point the load balancer's health check at /readyz. A gateway-only or worker-only process has no HTTP listener; watch it through the gateway series and leases instead.

doctor

better-compute doctor runs compute.selfCheck() and adds the CLI's own findings. It only reads. --strict exits non-zero with DOCTOR_FINDINGS when any error or warning is found; --pki PATH names the PKI bundle to check.

better-compute doctor --strict --query "findings[].check"
CheckSeverityFinds
migrationserrorA database never migrated, or migrations of this release and its modules not applied
btree-gisterrorThe extension the booking exclusion constraints need is missing
clockerror or infocompute_now() is not the database clock (info when it reads the sim clock)
clock.skewwarningThis process's clock differs from compute_now() by more than 1 s
catalog.*error or warningMethods whose actions are uncatalogued, mismatched or undeclared
databaseerrorThe check itself could not run
workers.queuewarningWork items more than 5 minutes overdue and unclaimed, noting when no worker has heartbeated in the last minute
workers.sweeperwarningPending commands more than 2 minutes past not_after and not yet expired: no worker holds the CommandSweeper's lease
usage.dead-letterswarningUnresolved rows in usage_dead_letters
usage.backlogwarningClosed usage windows unrecorded for more than 2 hours
fleet.gateway-headroomwarningLive, non-draining gateways holding at least 80% of their max_streams
fleet.heartbeatswarningConnected nodes whose session sent nothing for more than 2 minutes
fleet.daemon-skewwarningConnected daemons whose newest protocol is below the control plane's minimum
fleet.cert-expirywarningConnected nodes with no unrevoked certificate valid for another hour: renewals are failing
iam.not-configuredwarningNo IAM is configured, so only public methods answer
iam.projectionerrorThe authz projection is not loaded, so every decision is denied
iam.inbox-spillwarning or errorIAM events spilled in memory, or dropped
iam.inbox-backlogwarningMore than 10,000 IAM events wait in iam_inbox
iam.usage-dead-letterswarningUsage events that could not be recorded in IAM billing
iam.usage-backlogwarning or errorUsage windows over 2 hours unrecorded (warning), or windows of a past month that IAM's closePeriod will refuse (error)
events.webhook-dead-letterswarningWebhook deliveries that failed every attempt
events.webhook-backlogwarningMore than 1,000 deliveries overdue by 5 minutes
capacity.reservations.stalewarningReservations more than 10 minutes past their end and not released
capacity.mig.stuckwarningNodes that have waited more than 24 hours for a MIG layout change
capacity.dedicated.unofferedwarningNodes dedicated to a tenant their pool is not offered to, so nobody can use them
capacity.device_counts.drifterrorThe trigger-kept per-node device counts the placement shortlist ranks on disagree with devices (a TRUNCATE bypasses the trigger)
vps.pools.runtimeerrorA vps-host pool runs a runtime this deployment does not list in vps({ runtimes })
vps.pools.single-tenanterrorA container-runtime pool without policy.singleTenant: true, which cannot run instances
vps.pools.ipamwarningA vps-host pool without an IP range, so its instances get no address
vps.hosts.capacitywarningAdopted VPS hosts without vCPU, memory and disk capacity rows: no inventory yet, or no worker runs vps.hosts
vps.sandboxes.expiryerrorSandboxes more than two reconciler passes (30 s) past expiresAt and not deleting: no worker runs the vps.sandboxes reconciler, or it is failing
vps.sandboxes.deletingwarningSandboxes deleting for more than 10 minutes: their instance is stuck (its host must report it gone, or be declared lost)
vps.sandboxes.warmwarningTemplates below warm.min settled warm instances for over 10 minutes with no refill in that time (quota, capacity, a vanished flavor or image), and warm instances not settled within 10 minutes
vps.sandboxes.networkerrorOpen sandboxes with a private or none network on a host without the bc.dev/cap.instance.network-policy label (placed before the capability constraint, or its bcd since downgraded): their guests have full egress. Upgrade bcd there, or delete the sandboxes
workflows-stuckwarningprovision: active workflows with an in-flight step more than 5 minutes past its deadline. The engine fails an overdue step whenever it looks, so nobody runs their workflow reconciler
workflows-failedinfoprovision: workflows that failed in the last day; inspect with workflows.get, retry with workflows.retry
metal-failedwarningmetal: machines failed with maintenance on, waiting for metal.recover or metal.retire, listed with their failure codes
metal-stalledwarningmetal: machines more than 10 minutes in a workflow-driven state without an active workflow, so no worker runs the metal reconciler (a rescued machine resting after its rescue workflow is not stalled)
pool-telemetry-invalidwarningPools whose policy.telemetry fails nodeTelemetryModule's checks; their nodes export to the default collector (or none) until it is fixed
audit.checkpointswarningAudit rows older than 3 hours not sealed by a checkpoint
history-partitionswarningretentionModule(): this or next month's partition of a history table is missing, so the retention pass is not running
history-default-partitionwarningretentionModule(): rows of the last 13 months sit in a history table's default partition
retention-cursorswarningretentionModule(): a durable outbox_cursors row has not moved for a day, so its stream is not pruned behind it
pki.*error or warningThe PKI bundle unreadable or not unsealable, or a CA expired (error) or expiring (warning)
host.*error or warningThe host export: routes, tunnels, IAM, and whether the gateway role can authenticate daemons, reach the API's pokes, tunnels and revocations

system.selfCheck serves the server's part of the report over the API (action compute:fleet:admin on compute-fleet); the pki.*, host.* and iam.not-configured findings come from the CLI only.

The workers.*, usage.* and fleet.* checks are one read-only statement the server runs once the schema is fully migrated (they are skipped while a migrations finding stands). Each is a warning with a count and a fix, and each maps to an operations check of PLAN.md section 14: queues, the sweeper, usage, gateway headroom, heartbeats, daemon version skew and certificate expiry.

What to alert on

These follow from the series and findings above, and from PLAN.md section 8.6 on what breaks first.

  • The gateway is fenced or stuck. better_compute_gateway_lease_held at 0, or better_compute_gateway_loop_consecutive_failures rising for any loop. A gateway that loses its lease drops its streams; nodes re-home to the others.
  • Stream headroom. better_compute_gateway_stream_headroom near 0 (default maxStreams 5,000 per gateway): add a gateway before handshakes are refused.
  • The IAM lane. better_compute_iam_call_duration_seconds p99 rising, or iam_calls_total{outcome="error"}. Every IAM call takes at least one global lock acquisition in embedded mode.
  • Backlogs. workers.queue, workers.sweeper, iam.inbox-backlog, usage.backlog, events.webhook-backlog and audit.checkpoints findings: a worker role is not running or cannot keep up. Run doctor --strict on a schedule and alert on a non-zero exit.
  • Clock. clock.skew: run NTP on every host. Business time stays anchored to the database, but ids and logs use the host clock.
  • Stale reservations. capacity.reservations.stale: the controllers role is not running reconcilers.
  • Revocations. better_compute_revocation_feed_consecutive_failures above 0: revoked certificates keep their sessions while the feed fails.
  • Series cap. better_compute_metrics_collapsed_total rising means some label is not bounded.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page