Server metrics operations
Host/server metrics are a core control-plane feature — always on, disposable, and stored outside Postgres. There is no enable/disable switch. For what each console chart means, see Server metrics — including Pressure Stall Information (PSI), the five stall-time percentages on CPU, memory, and disk. For the wire contract, family catalog, backend mappings, and the dated Cloudflare cost model, see Server metrics architecture.
Configuration
| Variable | Default | Purpose |
|---|---|---|
TURBOPANEL_SERVER_METRICS_RETENTION_DAYS | 90 | Retention in days — applies to both hot DuckDB rows and sealed Parquet partitions |
TURBOPANEL_SERVER_METRICS_DUCKDB_THREADS | 2 | DuckDB SET threads cap |
TURBOPANEL_SERVER_METRICS_DUCKDB_MEMORY_LIMIT | 128 | DuckDB SET memory_limit in MiB |
TURBOPANEL_METRICS_DIR | <stateDir>/metrics | Metrics state root override (internal path — not required) |
CLOUDFLARE_ACCOUNT_ID | — | Workers only — required for Analytics Engine SQL reads |
TURBOPANEL_ANALYTICS_ENGINE_API_TOKEN | — | Workers only — Account Analytics Read token for AE SQL queries |
TURBOPANEL_SERVER_METRICS_AE_MAX_RANGE_SECONDS | 7776000 (90 d) | Workers only — optional max query window override |
The self-hosted Deno path constructs the DuckDB store directly against an internal path (resolveMetricsDir()), so it needs no operator-supplied credentials — it falls back to a temporary no-op store (with a one-time boot warning) only when the metrics directory cannot be created or opened.
Workers (hosted): add the SERVER_METRICS binding + CLOUDFLARE_ACCOUNT_ID + TURBOPANEL_ANALYTICS_ENGINE_API_TOKEN (Account Analytics Read) for chart queries. Missing either the account id or the token → soft available: false on reads, not a hard failure. Optionally tune the hosted query window with TURBOPANEL_SERVER_METRICS_AE_MAX_RANGE_SECONDS.
Deno (self-hosted): metrics persist to embedded DuckDB + Parquet automatically. Query failures return 503 metrics_backend_unavailable.
There is no analytics Ansible role — the daemon provisions nothing for metrics beyond the instance's own state directory; the instance creates and manages the DuckDB metrics directory itself on first use.
Temperature display unit
Organizations set a display unit independent of storage: GET/PUT /api/client/v1/organizations/:id/temperature-unit accepts "celsius" or "fahrenheit" (default celsius when unset). This setting only changes how temperature charts render — every temperature metric is always stored in Celsius; conversion to Fahrenheit happens at render/query-response time, never at collection or storage.
Topology & capability plan
Two backend-neutral models replace v3's hardware-profile/sensors round trip:
- Topology (daemon-side,
turbopaneld/src/metrics/topology/) assigns every NIC, GPU, filesystem, block device, and hardware signal a stable logical id and tracks generation bumps — atopologyGenerationcounter that advances only when the enumerated entity set's shape actually changes, plus abootGenerationfor daemon restarts. The control plane resolves aSlotMappingfrom a sample'stopologyGeneration(client/servers/topology-slot-mapping.ts) and threads it through packing so paged families stay identity-addressed instead of positional. MetricsCapabilityPlan(control-plane,src/daemon/metrics/capability-plan.ts) is the backend-neutral entitlement model for how much of the contract a server is allowed to report/store — slot counts and feature toggles only, no pricing-tier names. On TurboPanel High Availability the base plan resolves from the server's license → tier: thetierrow's entitlement columns (nic_slots,drive_slots,gpu_slots,filesystem_slots, plus its rank) map ontonormalNicSlots,detailedBlockDeviceSlots,gpuSlots,extraFilesystemSlots, andmanagedDockerEnabled; the entry tier (rank 1) carves extra filesystems to0, physical sensor slots to11, and Docker off. An unassigned seat falls back to the platform default (normalNicSlots: 2,detailedBlockDeviceSlots: 2,gpuSlots: 1,physicalHardwareSignalSlots: 19on physical /0on virtual, every feature toggle on). An org-wide override inorganization.options.metricsCapabilityPlanand an optional per-server override inserver.options.metricsCapabilityPlanstill layer on top of that base.POST /api/daemon/v1/metricsresolves the effective plan for the reporting server and callstruncateSampleToCapabilityPlanon the incoming sample before it reaches the store — a plan or tier change takes effect on the very next ingested sample, no daemon restart required. Self-hosted is uncapped: the branch happens before truncation, so a self-hosted instance stores whatever the daemon reports — no truncation, no decimation, and the monitored-NIC default is the ceiling (11) rather than the hosted 2. There is nocpuDetailEnabled/memoryDetailEnabledany more: diagnostics ride the always-onhost.diagnosticsfamily at every tier, hosted or self-hosted. See License tiers for what each tier entitles and how a server is placed.
machineClass ("physical" | "virtual") is required, not defaulted, specifically so a virtual server can never silently inherit the physical hardware-signal baseline. The declared server.machine_class column wins; only while it is null does the daemon's topology snapshot (any host-level sensor discovered → physical) decide.
A server whose plan grants a slot but whose daemon never reports that entity (e.g. gpuSlots: 2 on a host with no GPU) simply emits nothing for that family — this is expected, not a misconfiguration to chase down. See Server metrics architecture for the entitlement-vs-emission distinction.
Ingress, router & database-proxy adapters
Three independent, loopback-only adapter domains feed managed.ingress / managed.router / managed.database_proxy — each source resolves independently, and one being unreachable never blocks another:
- Ingress (
turbopaneld/src/metrics/collector/ingress/) is Caddy only since schema v6. The Caddy adapter (caddy.ts) scrapes thesite-caddyrole's admin listener (site_caddy_admin_addr,127.0.0.1:2039— the unprivileged per-site Caddy, not the separate hosting-ingress Caddy) via its Prometheus/metricsexposition. No-double-count rule: every handler in a request's Caddy handler chain (the site's top-levelsubrouteplus any nestedhandle/reverse_proxy/file_serverterminal) is independently instrumented, so summing every(server, handler)group would double- or n-count each request. The adapter instead picks, per server, the(server, handler)group with the largestcaddy_http_requests_total— the outermost handler every request passes through — as the sole source for every normalized field; ties (the normal shape of a single-terminal site) break deterministically by handler name. The six latency buckets come straight off Caddy's default histogram bounds; the daemon ships the raw duration sum and the bucket counts, never a percentile. - Router (
turbopaneld/src/metrics/collector/router/) reads the shared hosting Traefik (traefik.ts) — the loopback proxy behind the hosting Caddy that fronts Docker-hosted sites — from the dedicatedmetricsentrypoint the deploy configures. It is a host-wide singleton, not an ingress source: v5 forced Traefik onto the Caddy layout, which fit neither side. The collector's own scrape traffic on the metrics entrypoint is excluded from open-connection counts. - Database proxy (
turbopaneld/src/metrics/collector/database-proxy/) reads the ProxySQL adapter (proxysql.ts) against its unauthenticated REST metrics endpoint, published loopback-only (admin-restapi_enabled,127.0.0.1:6070) the same way the admin MySQL-protocol port is.
An adapter that throws or returns nothing for a tick omits that source entirely (never an all-null placeholder row) and invalidates that source's counter-baseline namespace so the next successful read re-origins instead of diffing across the gap.
Three further host-wide readings are collected on their own slow timers and read synchronously by the sample tick: storage accounting (managed.storage, collector/directory-usage.ts, a 15-minute walk of the hosting, backup, and log roots, or a plain statfs when the path is its own mount), Docker usage (managed.docker, collector/docker-usage.ts, Docker's GET /system/df every 5 minutes), and the managed-engine census (the per-engine fields of managed.storage, collector/managed-engines.ts, every 5 minutes: the managed engine containers found by their tp.managed.engine label over the Docker socket, each running instance probed for readiness and connection count through docker exec). None runs on the 60 s tick; a bounded walk that hits its limit reports null rather than a partial total, and an engine the host does not run reports null rather than zero.
Self-hosted schema
On every open the self-hosted store checks the sidecar schema-version marker against DUCKDB_SCHEMA_MARKER_VERSION (8). That number is the on-disk layout generation of the self-hosted store, not the metrics wire schema: the daemon ↔ instance contract is v6 (METRICS_SCHEMA_VERSION = 6), and v6 samples are stored in layout 8. The two counters are independent by design — the marker bumps whenever DDL adds or widens a table (the managed.storage / managed.docker tables took it from 7 to 8, which is why a v6 store is marker 8), while the wire version only moves when the sample shape changes. A missing, corrupt, or non-matching marker discards metrics.duckdb, parquet/, tmp/, and schema-version before creating the current layout. There is no in-place migration and no supported operator path for older DuckDB files. Layout 8 is the first carrying the server_storage_samples, server_docker_samples, and server_router_samples tables, the merged diagnostics columns, and the widened ingress and database-proxy tables.
Configured retention still expires old points (TURBOPANEL_SERVER_METRICS_RETENTION_DAYS, default 90). The sidecar schema-version marker is written after a successful open of the current store.
DuckDB + Parquet resources (self-hosted)
The self-hosted metrics store is embedded in the instance process — no container, no port, no separate daemon process, no credentials, and no analytics database service to install or monitor.
Everything lives under the metrics state root (TURBOPANEL_METRICS_DIR, default <stateDir>/metrics):
| Path | Role |
|---|---|
metrics.duckdb | Hot database — one typed table per family (see Server metrics architecture): the wide host table (host, I/O, and CPU diagnostics columns), server_memory_diagnostics_samples, per-entity tables for network / filesystem / block / GPU / hardware signals, the ingress and database-proxy tables, the server_router_samples / server_storage_samples / server_docker_samples singletons, metric events, and status events |
parquet/<family>/year=YYYY/month=MM/day=DD/metrics.parquet | Daily sealed archive, per family — each completed UTC day exported out of that family's hot table independently |
tmp/ | DuckDB spill plus in-flight Parquet exports (swept on restart; never mistaken for sealed partitions) |
| schema marker (sidecar) | Written after a successful open of on-disk layout 8 (DUCKDB_SCHEMA_MARKER_VERSION) — the store layout counter, distinct from the v6 wire schema it holds |
Resource caps come from the env vars above: TURBOPANEL_SERVER_METRICS_DUCKDB_THREADS (default 2) and TURBOPANEL_SERVER_METRICS_DUCKDB_MEMORY_LIMIT (default 128 MiB) bound the embedded engine's footprint.
Retention
| Layer | Config | Default |
|---|---|---|
| DuckDB hot tables + Parquet archive | TURBOPANEL_SERVER_METRICS_RETENTION_DAYS | 90 days |
| Analytics Engine (hosted) | Cloudflare platform | 3 months — UI cannot exceed this on Workers |
The same daily archive tick prunes both expired Parquet partitions and any hot/status rows past the cutoff, independently per family — change retention via env on the instance unit and it applies on the next tick.
Live samples are never stored. While a live lease (10 s cadence) is active, each sample is held in a short-lived buffer the live chart reads from and the durable write is skipped on both backends. Retention, archive sizes, and the cost figures in the architecture doc therefore describe baseline-cadence data only — a long live session adds nothing to disk or to the Analytics Engine write count.
Troubleshooting ingestion
| Symptom | Likely cause | Action |
|---|---|---|
| No samples at all | Metrics directory unusable (self-hosted) or AE not wired (Workers) | Check the instance boot log for the no-op store warning and verify the metrics directory is creatable; on Workers verify the AE binding + SQL credentials |
| API 503 on charts | DuckDB/filesystem error | Check the metrics directory is writable and the disk is not full; see instance logs for the underlying DuckDB error |
| Interrupted daily archive | Instance stopped mid-export | Nothing to do — the in-flight .tmp Parquet file for that family is ignored and swept on the next tick, and the day is re-exported; hot rows are only deleted after the sealed file validates, so samples are never lost |
| Workers charts empty | AE SQL credentials missing | Set CLOUDFLARE_ACCOUNT_ID and TURBOPANEL_ANALYTICS_ENGINE_API_TOKEN; store reports available: false when either is absent |
Workers charts 503 metrics_backend_unavailable | AE SQL API rejected the query or token | Token must be Account → Account Analytics → Read. Check Worker logs for metrics queryHostSeries failed … (includes the Cloudflare/SQL error). Confirm CLOUDFLARE_ACCOUNT_ID matches the TurboPanel account |
| Daemon connected, no metrics | Unsupported OS collector | Internal to turbopaneld: logs metrics unsupported: unsupported_os:<os>, stops periodic metrics for that attach, and does not send a metrics sample — check daemon logs |
| Gap after connect | Normal warm-up | First sample is immediate (gauges); rate metrics (CPU / disk / net) follow on the primed ~2 s snapshot |
| A whole family missing entirely | Expected — presence-gated | A family with no source entities (no GPU, no hardware signals, no managed-service sidecar) never emits rows; there is nothing to configure and no error to chase. See entitlement vs. emission |
GPU family missing despite gpuSlots entitlement | Entitlement is not presence | A capability plan granting gpuSlots only raises the cap on how many GPUs may be reported — it does not fabricate a gpu row when gpus[] is empty |
| Ingress/router/database-proxy family missing | Sidecar not running or its metrics endpoint unreachable | Each source resolves independently — check the site Caddy admin listener (127.0.0.1:2039, global metrics option set), the hosting Traefik's metrics entrypoint, and the ProxySQL REST API (admin-restapi_enabled, 127.0.0.1:6070) separately; one being down never blocks another |
| Storage usage group empty on a fresh daemon | Walker has not completed | managed.storage is absent until the daemon's first 15-minute directory walk lands; a hosting root that hits the walk bound reports null rather than a partial total |
| Docker group replaced by a notice | Entry-tier license | managed.docker is granted from S2; on S1 the console shows a one-line notice instead of the group. Self-hosted and unassigned seats keep it (platform default) |
| More devices than the tier watches | Recommended tier above the license | Extra NICs/drives/GPUs are dropped by hosted truncation, never refused; org owners get a daily note naming the unwatched devices — see License tiers |
| Chart segments unexpectedly at a point in time | Topology generation changed | A NIC/GPU/filesystem/block-device/hardware-signal set actually changed shape (added/removed/replaced) — not a bug; the UI intentionally breaks the line rather than drawing a misleading continuous series across a topology change |
| Tier or plan change not reflected | Stale expectation, not a bug | The effective plan (tier base plus any org/server override) is resolved per ingested sample at POST /api/daemon/v1/metrics time — a change takes effect on the very next sample, but nothing retroactively reshapes already-stored rows. Self-hosted has no plan to change: it is uncapped |
Writes are fire-and-forget at the ingest boundary — ingestion errors do not block daemon liveness.
Troubleshooting missing charts (UI)
| Symptom | Likely cause |
|---|---|
| "No metrics yet" | Daemon offline or still connecting (first sample is immediate; rates follow in ~2 s) |
| Storage still starting | DuckDB metrics directory not yet created (self-hosted) or AE binding/credentials (Workers) not ready yet — re-run instance-launch / converge |
| Unsupported OS | Server OS metadata from daemon hello (server.os.family !== 'linux') — not a metrics wire signal; Linux collector failures appear as no data/gaps instead |
| Empty range | Selected window beyond retention (90 d API cap; 3 mo on hosted AE) |
| Backend unavailable | DuckDB 503 on self-hosted |
| Gaps in lines | sampleCount < expectedSampleCount — missing samples, not zero usage |
| Stale appearance | Charts are not real-time below the ~60 s baseline collection interval |
| A family absent from a chart's metric picker | Expected — presence-gated or capability-gated families; see the Troubleshooting ingestion table above |
Use sampleCount, expectedSampleCount, and gapCount from the series API — do not treat null/zero the same.
Privacy
Metrics stores:
serverId(authenticated UUID index) — from server context after auth, not client-supplied identity- Low-cardinality dimensions:
topologyGenerationand the capability-plan generation — daemon version, OS name, architecture, kernel release, and resolved entity identities are not stored as free-form metrics rows; stable logical entity ids (e.g.deviceId,gpuId) are the only per-entity identifiers persisted
Metrics does not store organization names, account emails, hostnames, client IPs, license tokens, or secrets. Query endpoints require session + server read grant — never bare UUID access. (Internally, DuckDB stores missing values as plain SQL NULL columns while Analytics Engine uses a missing-value sentinel — a backend implementation detail with no user-facing impact.)
Related
- Server metrics — what each console chart means
- Server metrics architecture — contract, mappings, cost model
- License tiers — the S1–S7/SX ladder, placement rules, and what each tier watches
- Control plane deployment — instance + co-located daemon
- Daemon setup — remote node install
Last updated on