Metrics and Observability¶
ShinyHub exposes Prometheus metrics for the server process itself (the control plane), emits a structured access log for every request, and - when tracing is enabled - records control-plane spans correlated with that access log. This is separate from the per-app CPU/RAM sampling shown in the dashboard and from the per-app proxy trace buffer documented in tracing.md.
The /metrics endpoint¶
Metrics are opt-in and served on their own listener, separate from the main application port so server internals are never exposed on a routable interface by accident:
Environment overrides (last-wins over YAML):
| YAML field | Environment variable |
|---|---|
enabled |
SHINYHUB_METRICS_ENABLED |
addr |
SHINYHUB_METRICS_ADDR |
The endpoint defaults to loopback. Operators scraping from another host set
addr to a private interface behind their own network controls (the
conventional Prometheus pattern). When enabled: false no handler and no
listener are created.
Scrape it like any Prometheus target:
Exposed series¶
Process and build¶
| Metric | Type | Description |
|---|---|---|
shinyhub_build_info{version} |
gauge | Always 1; the build version is a label. |
shinyhub_uptime_seconds |
gauge | Seconds since the server started serving. |
go_*, process_* |
various | Standard Go runtime + process collectors (heap, goroutines, server RSS/CPU/FDs). |
Control-plane HTTP¶
Labeled by the matched chi route pattern (not the raw path), so high-cardinality path parameters and unmatched 404 scans cannot explode the series count.
| Metric | Type | Labels | Description |
|---|---|---|---|
shinyhub_http_requests_total |
counter | method, route, status |
Control-plane HTTP requests. |
shinyhub_http_request_duration_seconds |
histogram | method, route, status |
Control-plane request latency. |
Data-plane admission¶
| Metric | Type | Labels | Description |
|---|---|---|---|
shinyhub_admission_rejects_total |
counter | slug, reason |
Proxy admission rejections. slug is __unknown__ for requests to slugs that are not registered apps. |
shinyhub_app_sessions |
gauge | slug |
Active proxied sessions for an app, summed across live replicas (evaluated at scrape time). |
shinyhub_app_sessions_limit |
gauge | slug |
Admission ceiling for an app: the number of replicas that admit new sessions (live, not draining) times the per-replica session cap. Absent for uncapped apps, so shinyhub_app_sessions / shinyhub_app_sessions_limit is the saturation fraction wherever a cap applies. |
The reason label is a closed vocabulary. The same value is returned on the
X-Shinyhub-Reject response header, so a rejected request can be traced from the
client back to this counter. Reasons differ in what they mean you should do,
which is why they are not collapsed into one:
reason |
What happened | Remedy |
|---|---|---|
unknown-slug |
No app with this slug is registered (404). | Nothing, unless you expected the app to exist. A rising rate is usually scanning. |
pool-saturated |
Every replica is live and at its per-replica session cap. | Raise --max-sessions-per-replica and/or --replicas. This is the only scale-up signal here. |
pool-degraded |
Fewer replicas are registered than configured, and the survivors are at cap. | Check replica health first. Adding capacity on top of a crash loop hides it. |
app-not-ready |
The app has no replica that has completed a WebSocket handshake yet. | Nothing during a normal cold start. Sustained means the app is failing to come up. |
memory-pressure |
The host is below server.min_available_memory_mb, so no new elastic worker may start. |
Free host memory, lower per-app ceilings, or add hardware. |
render-paced |
A new session was deferred because the app's render-admission bucket was empty, then shed after the park window. | Raise the app's render_seconds accuracy or add cores. More replicas do not help: they do not add CPU. |
cpu-saturation |
The host CPU watermark is breached, so a new session was shed to protect connected ones. | Add cores or move apps off this host. |
render-deferred |
A page load was shown the "Waiting for capacity" page because the app had no render capacity at that instant. | Same as render-paced. See the caveat below before alerting on it. |
render-deferred counts page loads deferred, not sessions refused, and it is
inflated by design: one waiting browser re-polls roughly every 1.75 s until
capacity frees, so a single user can contribute dozens of increments. Use it to
see that users are waiting; use render-paced to count sessions actually
turned away. Alerting on render-deferred as if it were a refusal rate will
page you for one patient user.
Both session gauges are exported per control-plane instance, like every metric
here. On a single-node deployment they are exact. In a clustered deployment,
scrape every instance and aggregate in PromQL (sum by (slug) (...)) rather than
reading one instance in isolation - the example alert below already does this.
Fleet and lifecycle¶
| Metric | Type | Labels | Description |
|---|---|---|---|
shinyhub_apps_running |
gauge | - | Apps currently in the running state (evaluated at scrape time). |
shinyhub_replicas_running |
gauge | - | App replicas currently running (evaluated at scrape time). |
shinyhub_deploys_total |
counter | result |
Deployments by outcome (success / failure). Alert on a rising failure rate. |
shinyhub_app_state_transitions_total |
counter | event |
App lifecycle transitions (hibernate, wake). |
shinyhub_replica_restarts_total |
counter | - | Replica crash-restarts performed by the watchdog. A flapping app shows up as a rising restart rate. |
Application-log durability and delivery¶
These process-local signals cover the shared-log path used by clustered
Postgres deployments. They deliberately carry no app, replica, or run labels:
those identities are unbounded and belong in the log viewer, not in Prometheus
series. Scrape every control-plane instance and aggregate with sum(...) in HA.
| Metric | Type | Labels | Description |
|---|---|---|---|
shinyhub_app_log_flush_attempts_total |
counter | result |
Shared-log database flush attempts (ok / error). |
shinyhub_app_log_flush_duration_seconds |
histogram | - | Database-call duration for every shared-log flush attempt. |
shinyhub_app_log_persistence_lag_seconds |
histogram | - | Time from entering the retry buffer to a successful database flush. Failed attempts do not produce a lag sample. |
shinyhub_app_log_pending_bytes |
gauge | - | Bytes currently queued for persistence by this process, excluding the one chunk in flight. A brief non-zero value is normal because writes are batched. |
shinyhub_app_log_buffer_dropped_bytes_total |
counter | - | Bytes evicted from the bounded retry buffer, including bytes abandoned when the final shutdown flush fails. Any increase means shared history may be incomplete. |
shinyhub_app_log_runs_pruned_total |
counter | - | Immutable run records removed from database retention by the owner. |
shinyhub_app_log_files_pruned_total |
counter | - | Orphaned immutable files removed from this node's private disk. |
shinyhub_app_log_followers |
gauge | - | Active per-run shared database followers on this process. Compare with shinyhub_app_log_viewers to confirm concurrent viewers are sharing followers. |
shinyhub_app_log_viewers |
gauge | - | Active retained-log viewer subscriptions on this process. |
shinyhub_app_log_follow_errors_total |
counter | - | Failed database reads by shared retained-log followers. A rise means live delivery is degraded; durable history remains available for catch-up once reads recover. |
The flush and buffer metrics remain present at zero on single-node SQLite deployments, where the viewer reads its local files directly. Retention cleanup counters can still rise there. Like all Prometheus counters, cleanup and loss totals reset when a ShinyHub process restarts.
After a follower read fails, its polling interval backs off exponentially with jitter to a five-second ceiling. A successful read restores the normal 200 ms cadence, while a locally committed log chunk wakes the follower immediately. This bounds database pressure during an outage without delaying healthy local delivery.
Example alerts¶
groups:
- name: shinyhub
rules:
- alert: ShinyHubDeployFailures
expr: increase(shinyhub_deploys_total{result="failure"}[15m]) > 0
annotations:
summary: "A ShinyHub deploy failed in the last 15m"
- alert: ShinyHubReplicaFlapping
expr: increase(shinyhub_replica_restarts_total[10m]) > 5
annotations:
summary: "A ShinyHub replica is crash-restarting repeatedly"
- alert: ShinyHubSessionsNearCap
# sum by (slug) aggregates across control-plane instances; on a single
# node it is simply the one series.
expr: sum by (slug) (shinyhub_app_sessions) / sum by (slug) (shinyhub_app_sessions_limit) > 0.9
for: 5m
annotations:
summary: "{{ $labels.slug }} is above 90% of its admission ceiling"
- alert: ShinyHubAppLogPersistenceErrors
expr: sum(increase(shinyhub_app_log_flush_attempts_total{result="error"}[10m])) > 0
annotations:
summary: "Shared app-log persistence has failed"
- alert: ShinyHubAppLogBacklogStuck
expr: sum(shinyhub_app_log_pending_bytes) > 0
for: 5m
annotations:
summary: "Shared app-log bytes have remained queued for 5m"
- alert: ShinyHubAppLogDataDropped
expr: sum(increase(shinyhub_app_log_buffer_dropped_bytes_total[5m])) > 0
annotations:
summary: "Shared app-log history may be incomplete"
- alert: ShinyHubAppLogFollowErrors
expr: sum(increase(shinyhub_app_log_follow_errors_total[10m])) > 0
annotations:
summary: "Shared app-log live delivery has encountered database errors"
Access log¶
Every request emits one structured api_access record through log/slog
(replacing chi's unstructured stock logger), so the control plane has a single
structured log stream a log aggregator can ingest. Fields:
request_id- per-request correlation ID (see below)method,path,route(matched pattern),statusbytes,duration_msclient_ip- the trusted-proxy-aware client IP (honest even when ShinyHub sits behind an edge proxy; seeserver.trusted_proxies)trace_id- present when tracing is enabled and a span is active
Request-ID correlation¶
Each request is assigned a correlation ID echoed on the response as the
X-Request-Id header and threaded through the request context so downstream
handlers tag their own logs with the same ID. A well-formed inbound
X-Request-Id (e.g. minted by a trusted edge proxy) is honored so a request
stays correlated across tiers; a malformed or oversized value is rejected and
replaced, closing a log- and header-injection vector.
Log <-> trace correlation¶
When server tracing is enabled (see tracing.md), the access log and the trace are linked in both directions:
- the
api_accessrecord carries the active span'strace_id, so you can pivot from a log line to the trace, and - the server span carries the
request_idattribute, so you can pivot from a trace back to the log line.
Server (control-plane) tracing¶
Enabling tracing also instruments the control-plane API: ShinyHub emits one
server span per request and spans for background lifecycle operations
(lifecycle.wake, lifecycle.restart, lifecycle.hibernate, tagged with
shinyhub.app.slug), exported to the same OTLP endpoint the managed apps use.
Spans use OpenTelemetry HTTP semantic-convention attribute names
(http.request.method, http.route, http.response.status_code) and carry a
resource identifying the instance (service.name, service.version,
service.instance.id). An inbound traceparent is adopted as the parent, so a
client/edge trace links through ShinyHub to the app it proxies.
This reuses the existing tracing config block; there is no separate
server-tracing switch. See tracing.md for the configuration
fields and the per-app proxy trace buffer.