Observability
crucible is a telemetry source, not a pipeline. It emits open standards —
Prometheus /metrics today, OTLP (metrics, logs, traces) as it lands — and
delegates routing and fan-out to the ecosystem (an OpenTelemetry Collector,
Vector, or Grafana Alloy). One OTLP export reaches Grafana/Tempo/Loki, SigNoz,
Datadog, Honeycomb, and the rest without any vendor-specific code in the daemon.
Metrics — GET /metrics
The daemon exposes a Prometheus endpoint (default on the API listener). Point a scrape at it:
scrape_configs:
- job_name: crucible
static_configs:
- targets: ["<daemon-host>:7878"]Per-app series
Labels are kept to a fixed, low-cardinality set — app, code (HTTP status
class: 2xx…5xx), never a raw path or client IP. A request for an unknown
host is not counted, so an attacker can't inflate labels.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
app_requests_total |
counter | app, code |
requests the ingress proxy routed to an app, by status class |
app_request_duration_seconds |
histogram | app |
request latency (accept → response written) |
app_replicas |
gauge | app |
desired instances |
app_ready_replicas |
gauge | app |
ready (serving) instances |
app_up |
gauge | app |
1 if the app has a running instance |
app_asleep |
gauge | app |
1 if scaled to zero (asleep) — the scale-to-zero density signal |
app_sleep_total |
counter | app |
sleep cycles the app has been through |
app_last_wake_latency_ms |
gauge | app |
most recent wake latency |
Plus the existing global series: sandboxes_active, sandboxes_created_total,
snapshots_active, fork_duration_seconds, snapshot_restore_duration_seconds,
app_wake_latency_seconds (aggregate histogram), app_internal_requests_total.
Disk usage
Scale-to-zero trades RAM for disk: every sleeping app owns a snapshot set. Three global gauges make that visible, so density never silently becomes disk bloat:
| Metric | Meaning |
|---|---|
snapshot_disk_bytes |
allocated bytes of all registered snapshots (state + memory + rootfs) |
volume_disk_bytes |
allocated bytes of volume backing files |
backup_disk_bytes |
allocated bytes of volume backups |
All three are sparse-aware (allocated blocks, not logical file size): a
lazily-faulted memory file or a mostly-empty volume counts what it actually
occupies. Reflink-shared blocks are counted per file, so on btrfs/XFS the
gauges report logical allocation, an upper bound on unique physical usage.
The volume/backup series exist only when volumes are enabled (--volume-dir).
Retention contract behind snapshot_disk_bytes: a sleeping app owns exactly
one snapshot set — each sleep supersedes and deletes the previous one — plus
at most one golden template per scaled-out app generation, and deleting an app
releases its snapshots. Growth in this gauge tracks the number of slept apps,
not their sleep history.
Persistent usage metrics
Per-app usage counters that survive a daemon restart: unlike the Prometheus series above (which reset when the daemon does), these are a durable, cumulative ledger persisted alongside the app records. Read them to see how much an app has used over any window — take two readings and subtract.
Four dimensions accrue per app:
| Dimension | Accrues while | Unit (API / metric) |
|---|---|---|
| compute | the app is awake (a slept app burns none) | vCPU-seconds |
| memory | the app is awake | MiB-seconds |
| storage | its volume exists (awake or asleep — a slept app still holds its disk) | GiB-seconds |
| requests | always (per ingress-proxy request, split by status class) | count |
| egress | the app sends data to the external network (outbound only; downloads and intra-host DNS / app→app traffic are excluded) | bytes |
Read them three ways, all from one durable source:
-
crucible app usage [<name>]— a table (or-o json), including retained records for deleted apps (a deleted app's final usage is kept so you can still read it). -
GET /usageandGET /apps/{name}/usage— the same, as JSON. Cumulative values plus asnapshot_unix_nano, so a reader reconciles readings across restarts and scrape gaps. Gated by thereadscoped-token op. -
Prometheus (and OTLP, via the bridge below):
Metric Type Labels app_usage_compute_vcpu_seconds_totalcounter appapp_usage_memory_mib_seconds_totalcounter appapp_usage_storage_gib_seconds_totalcounter appapp_usage_egress_bytes_totalcounter appapp_usage_requests_totalcounter app,code
Accrual is checkpointed on a tick (--usage-interval, default 60s) and at each
lifecycle transition, so an unclean crash loses at most one interval. A daemon
restart does not back-fill the downtime — the app wasn't serving, so it
accrues nothing for that gap.
TLS certificate status
For a TLS-terminating app, per-domain certificate state is both a metric and an API field — so a domain whose DNS isn't pointed at the host (issuance failing) is visible, not silent:
| Metric | Type | Labels |
|---|---|---|
app_cert_state |
gauge (1 for the current state) | app, domain, state |
app_cert_not_after_seconds |
gauge | app, domain |
state is one of passthrough, pending, active, expiring, failed,
manual. The same detail is on GET /apps/{name}/domains?detail=1 and
crucible app domain ls — see tls.md.
Reference dashboard
Import docs/observability/grafana-dashboard.json
into Grafana (Dashboards → Import → upload JSON, pick your Prometheus source). It
charts RPS and 5xx ratio per app, request-latency percentiles, replicas
(desired vs ready), the fraction of the fleet asleep, wake-latency p95, and
disk usage (snapshots / volumes / backups).
Guest metrics — scrape an app's own /metrics
The metrics above are the daemon's host/VM view of a workload. To also surface
a workload's own metrics — a database's pg_stat_* / Redis INFO, or any
app's Prometheus endpoint — point the daemon at a /metrics port inside the guest:
crucible app create db --image my-postgres-with-exporter \
-p 5432:5432 --min-scale 0 --idle-timeout 30s \
--metrics-port 9187 # a postgres_exporter listening on :9187 in the guestThe daemon scrapes that endpoint on its interval (--guest-scrape-interval, 15s
by default) and folds the series straight into this /metrics (and OTLP), so a
postgres_exporter's pg_stat_database_blks_hit shows up right next to the
daemon's own metrics — with app and instance labels added:
pg_stat_database_blks_hit{app="db",instance="sbx_…",datname="app"} 1.2345e6
crucible_guest_scrape_up{app="db",instance="sbx_…"} 1The exporter is a process inside the guest (a postgres_exporter,
redis_exporter, or the app itself) — the daemon is DB-agnostic, it only
federates whatever Prometheus text the guest exposes. The port is not
published; the daemon reaches it host-side over the guest's private link (the
same reach as health checks), so nothing is exposed externally.
Two properties matter for scale-to-zero:
- A slept app is never scraped — and a scrape never wakes it. The daemon only
scrapes awake, routable instances and dials the guest directly (not the
wake-on-connect forwarder).
crucible_guest_scrape_upgoes to0while the app sleeps, and the scraped series drop out — so you only pay for insights while the workload is actually running. - A guest can't flood the daemon. Each scrape is capped
(
--guest-scrape-max-body,--guest-scrape-max-series,--guest-scrape-timeout); over a cap the scrape is dropped,crucible_guest_scrape_upreports0, andcrucible_guest_scrape_samplesshows how many series the last scrape ingested.
This is the raw signal; building dashboards or a query/wait-event analysis on top of it is a downstream job for your metrics stack.
Profiling — --pprof-listen
For profiling the daemon itself (CPU, heap, goroutines):
crucible daemon … --pprof-listen 127.0.0.1:6060
go tool pprof http://127.0.0.1:6060/debug/pprof/heapOff by default. pprof exposes process memory, so bind loopback (or protect the port) — the daemon warns on a non-loopback bind.
OTLP metric export
The daemon can push the same /metrics series over OTLP to any OTLP backend
or your own Collector/Vector/Alloy — no metric is redefined; an OpenTelemetry
Prometheus bridge pulls the registry and exports it. One flag turns it on:
crucible daemon … --otlp-endpoint http://collector:4317 # gRPC (default)
crucible daemon … --otlp-endpoint http://collector:4318 --otlp-protocol http--otlp-protocol grpc|http,--otlp-headers k=v,k=v(auth/routing),--otlp-insecure(plaintext).- Standard
OTEL_EXPORTER_OTLP_*andOTEL_RESOURCE_ATTRIBUTES/OTEL_SERVICE_NAMEenv vars are honored natively (flags override them), so if you already run OpenTelemetry it just works. - The export carries a resource of
service.name(defaultcrucible),service.version, andhost.name, plus yourOTEL_RESOURCE_ATTRIBUTES. - Off by default. Setup failures are logged and skipped —
/metricskeeps serving regardless.
/metrics and OTLP are two views of one registry; use either, or both.
OTLP log export
When --otlp-endpoint is set (and --log-dir is on), the daemon also streams
app logs over OTLP — every durable log line becomes an OTel log record with:
service.*resource +crucible.app.instance(the instance id),log.source(service|exec) andlog.stream(stdout|stderr|event),- the original timestamp;
stderrmaps to severityWARN, elseINFO.
It taps the log store's best-effort fanout — a slow OTLP backend can never
back-pressure the app (records drop rather than block). Disable with
--otlp-logs=false (metrics-only). Logs remain locally readable via crucible logs / crucible app logs -f regardless.
App lifecycle events — GET /events
A stream of app lifecycle transitions — created, phase_changed
(booted / slept / woke / crashed), health_changed, domain_added /
domain_removed, updated, deleted. Where /metrics is a numeric snapshot,
this is the timeline: what happened, in order, with exact timestamps. A control
plane renders an activity feed from it and — because phase_changed carries the
exact sleep/wake moments — computes precise awake-intervals for usage accounting
that a 60s metrics poll can only approximate.
crucible events -f # tail all apps
crucible app events web -f # tail one app2026-07-15T20:52:01Z web created
2026-07-15T20:52:04Z web phase pending→running (reconcile)
2026-07-15T21:07:36Z web phase running→asleep (sleep)
2026-07-15T21:09:12Z web phase asleep→running (wake)GET /events?since=<seq>&app=<name>returns a batch of events after the cursor plus the current max cursor; poll with that cursor to follow (client side, likelogs).read-gated.app=filters to one app.- OTLP: each event is also pushed as a structured OTel log record
(
event.type,crucible.app,phase.from/phase.to,event.seq) when OTLP is configured — so an OTel-native consumer gets them without polling.
The stream is an in-memory ring (--events-buffer, default 1024): a consumer
offline longer than the ring loses old events, but usage totals stay correct
(reconcile against /usage) — the ring is a
best-effort activity signal, not an event-sourcing log. phase_changed de-dups:
only an actual phase change emits, so a steady app is quiet.
Traces over OTLP (coming)
Trace export is the next milestone (deploy / sleep / wake / proxy spans).