Prometheus
Prometheus exporter — scrapes the unified Metrics-API at GET /metrics in text exposition format.
@voltro/plugin-prometheus exposes the framework's metrics to Prometheus / Grafana at a scrape endpoint. It only READS — the framework emits the metrics automatically; the plugin formats them.
The Metrics-API (single source of truth)
The framework records metrics into Effect's global MetricRegistry. One snapshot sees everything:
- Core RPC —
voltro_rpc_requests_total{tag,status},voltro_rpc_errors_total{tag},voltro_rpc_duration_seconds{tag}(histogram). Emitted at the mutation / action handler boundary. - HTTP routes —
voltro_http_requests_total{route,status},voltro_http_duration_seconds{route}. - Plugin interceptors —
voltro_plugin_hook_duration_seconds{hook},voltro_plugin_hook_errors_total{hook}. - Subscriptions —
voltro_subscriptions_active{tag}(gauge of currently-open subscriptions),voltro_subscription_deliveries_total{tag,kind}+voltro_subscription_delivery_seconds{tag,kind}(per-delivery produce→push latency;kind=snapshot|delta). Backpressure & resume:voltro_subscription_buffered_bytes{tag}(gauge of pending bytes per blocked subscription),voltro_subscription_coalesced_total{tag}(updates collapsed onto the newest state while a consumer was blocked),voltro_subscription_overrun_total{tag}(streams closed withSubscriptionOverrun),voltro_subscription_oversized_total{tag}(events overreactive.socket.oversizedEventBytes— telemetry, not a cap),voltro_subscription_stalled_total{tag}(deliveries the transport did not accept withinreactive.socket.stallWarnAfterMs— a consumer that is not acknowledging chunks). - Schedules (crons) —
voltro_schedule_runs_total{schedule,status}(firings by name + outcome —status=succeeded|failed),voltro_schedule_duration_seconds{schedule}(histogram), andvoltro_schedule_last_success_timestamp_seconds{schedule}(a gauge holding the UNIX time of the last SUCCESS). Emitted by the framework scheduler, so every cron gets them with no per-handler wiring. A cron fires unattended — the failure mode is silent — so this is the series to alert on:time() - voltro_schedule_last_success_timestamp_seconds{schedule="…"} > <interval × N>fires when a job stops succeeding (a failure counter alone can't catch a job that stopped firing at all, but the last-success gauge going stale does). A failure moves the counter but deliberately NOT the gauge. TheonOverlap: 'queue'backlog has its own three:voltro_schedule_queued{schedule}(a gauge of firings waiting for their turn, reported PER REPLICA so a fleet's depth is the sum at query time),voltro_schedule_queue_wait_seconds{schedule}(how long a firing waited before it started — invisible in the duration histogram, which starts atfiredAt), andvoltro_schedule_queue_recoveries_total{schedule,outcome}(a firing whose replica stopped reporting:adoptedmeans the firing behind it ran the work,abandonedmeans it was silent past the adoption bound and the work will NOT run — that one is the alert). Externally driven schedules add two more:voltro_schedule_external_triggers_total{schedule,outcome}(every arrival, by what the app decided — a lowduplicaterate is healthy and means a platform redelivery was absorbed, but a rate near 1 means your cluster is firing each occurrence twice and only the claim gate is keeping the work correct;noOccurrenceat ANY rate means the cadence deployed to your scheduler is not the one the code declares) andvoltro_schedule_external_missed_total{schedule}(occurrences nobody triggered — inexternalmode the app arms no timer, so a scheduler that stops firing is otherwise SILENT, and this is the series that tells that apart from a schedule with nothing to do). - Workflows (durable execution) —
voltro_workflow_runs_total{workflow,status}(terminal outcomes —status=succeeded|failed),voltro_workflow_duration_seconds{workflow}(histogram), andvoltro_workflow_last_success_timestamp_seconds{workflow}(last-success gauge). Emitted by the workflow run-recording seam. Because the framework applies no retry of its own, afailedrun is terminal — it is the dead-letter state — sovoltro_workflow_runs_total{status="failed"}is the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (same shape as the schedule alert). A failure moves the counter but not the gauge. - Partial prerendering (web) —
voltro_ppr_shell_serves_total{page},voltro_ppr_hole_passes_total{page},voltro_ppr_hole_settles_total{page},voltro_ppr_hole_errors_total{page}andvoltro_ppr_hole_pass_seconds{page}(histogram).pageis the DECLARED route pattern (/blog/[slug]), never a resolved URL — a label that grows with visitors is how a scrape target falls over.voltro devandvoltro startemit the same set.voltro_ppr_hole_errors_totalis the alert: the shell already went out with a200, so a failed hole pass leaves every<Await>boundary on its fallback and nothing else says so. - Queues —
voltro_queue_consumed_total{topic,outcome}(outcome=ok|dead-lettered; the two together are every message the runner finished with, so the dead-letter RATE is a division with no join),voltro_queue_retries_total{topic},voltro_queue_produced_total{topic}, andvoltro_queue_lag_messages{topic,partition}— a gauge of the backlog behind the message just picked up, read out of the fetch response rather than an admin round trip. A message abandoned by a rebalance is deliberately in no outcome: its new owner redelivers and counts it there. @voltro/cachecounters, Effect's owneffect_fiber_*runtime metrics, and any custom metric you or another plugin defines.
Two consumers read the SAME snapshot, so they never disagree:
GET /metrics(this plugin) → Prometheus text exposition format, consumed by Prometheus / Grafana.GET /_voltro/inspect/metrics→ JSONMetricSample[], consumed by the dashboard's Metrics panel (quantiles derived from the histogram buckets,histogram_quantile-style).
Custom metrics
Define your own counter / gauge / histogram and it shows up in both consumers automatically:
import { counter, gauge, histogramMetric } from '@voltro/runtime'
import { Effect, Metric } from 'effect'
const signups = counter('app_signups_total', 'User sign-ups.')
export default (input, _ctx) =>
Effect.gen(function* () {
// … create the user …
yield* Metric.increment(signups)
return { ok: true }
})Wiring
// app.config.ts
import { prometheusPlugin } from '@voltro/plugin-prometheus'
export default {
type: 'api' as const,
name: 'api',
plugins: [prometheusPlugin()],
}Scrape config — one target per replica, never a single load-balanced hostname:
scrape_configs:
- job_name: voltro
metrics_path: /metrics
static_configs:
# one target per replica — NOT one LB-fronted hostname
- targets: ['api-0.internal:4000', 'api-1.internal:4000']Multi-replica scraping
The registry is per-process: each replica serves its own counters, and Prometheus keeps the series apart via the per-target instance label — exactly the pull model. But that only works when every replica is scraped directly. Behind a load-balancer, successive scrapes hit different processes and the per-process counters interleave into one incoherent series. On Kubernetes, use pod-level discovery so every pod becomes its own target:
scrape_configs:
- job_name: voltro
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: 'true'The plugin's name option disambiguates multiple plugin instances within one process (it suffixes the plugin name) — it does not identify replicas; replica identity is the scrape target's instance label.
Options
prometheusPlugin({
path: '/metrics', // scrape path (default /metrics)
token: process.env.PROMETHEUS_TOKEN, // require `Authorization: Bearer <token>`
processMetrics: true, // include node process_* metrics (default true)
version: '1.4.2', // sets voltro_build_info{version} = 1
})token— defaults to thePROMETHEUS_TOKENenv. Unset → the endpoint is open; gate it at the network layer (Prometheus usesbearer_tokento send it). A path guard there can name/metricsas written — the route answers its exact spelling only,/Metricsand/metrics/are404.
Every activation reports the effective gate: metrics: locked at info level,
or metrics: open — configure PROMETHEUS_TOKEN or restrict network access at
warning level. Both include path and tokenGated, never the token. An empty
explicit token also leaves the endpoint open. This warning does not infer that
a network firewall exists: an ingress forwarding / may expose the scrape
endpoint publicly. Set PROMETHEUS_TOKEN (and the matching scraper bearer
credential), or verify the network restriction yourself. Check an unauthenticated
request to the actual configured scrape path: a bearer-locked endpoint returns
401, not metrics.
processMetrics— emitsprocess_resident_memory_bytes,process_heap_bytes,process_cpu_seconds_total.- The response is
text/plain; version=0.0.4; charset=utf-8withcache-control: no-store.
Output shape
Counters / gauges render as name{labels} value. Histograms render as cumulative name_bucket{labels,le="…"} series plus name_sum + name_count + the +Inf bucket — exactly what histogram_quantile() expects in Grafana.
# HELP voltro_rpc_requests_total Total RPC handler invocations.
# TYPE voltro_rpc_requests_total counter
voltro_rpc_requests_total{status="ok",tag="mutation.todos.create"} 42
# TYPE voltro_rpc_duration_seconds histogram
voltro_rpc_duration_seconds_bucket{tag="mutation.todos.create",le="0.001"} 3
voltro_rpc_duration_seconds_bucket{tag="mutation.todos.create",le="+Inf"} 42
voltro_rpc_duration_seconds_sum{tag="mutation.todos.create"} 0.21
voltro_rpc_duration_seconds_count{tag="mutation.todos.create"} 42
Scope
Mutation / action / HTTP / plugin metrics, subscription metrics (active gauge + per-delivery latency histogram, split snapshot vs delta), schedule/cron metrics AND workflow metrics (run counter + duration + last-success gauge each) are all covered. OTLP metrics export reads the same registry: the OTEL_EXPORTER_OTLP_ENDPOINT that enables trace export also exports metrics (@effect/opentelemetry bridges the registry into the OTel MeterProvider) — that is the path for the Datadog Agent / any OTLP collector; agentless Datadog is @voltro/plugin-datadog. This plugin is specifically the Prometheus pull (scrape) surface.