The Loom server exposes a Prometheus scrape endpoint at GET /metrics on a dedicated monitoring
port. All Loom meters are prefixed loom_. For Cortex worker metrics see
Cortex Metrics; for health and readiness endpoints see
Monitoring API.
Endpoint
| Property | Value | Notes |
|---|---|---|
Path |
|
Standard Prometheus text exposition format |
Port |
|
Env |
Auth |
none |
Internal monitoring surface — restrict at the network layer |
The metrics server is separate from the REST API server. A scrape of the REST port (8092) returns
404 by design; the same applies to the gRPC (8091) and MCP (4041) ports.
curl http://loom.internal:8989/metrics
Changing the port
LOOM_SERVER_MON_PORT=9091
Reading the metrics without Prometheus
The monitoring port is deliberately unauthenticated, so it is not something you want a browser or a script outside your network to reach. For those callers the same figures are available as JSON on the ordinary API port:
curl -H "Authorization: Bearer $TOKEN" http://loom.internal:8092/api/v1/metrics
This is what the built-in monitoring screen reads. It requires the READ_METRIC permission, and it
serves the loom_ meters only — the JVM and framework families stay on the scrape endpoint, where a
Prometheus reads them.
{
"timestamp": "2026-08-09T11:24:07Z",
"metrics": [
{ "name": "loom_node_tasks_inflight", "type": "GAUGE", "tags": {}, "value": 6 },
{ "name": "loom_node_tasks_dispatched_total", "type": "COUNTER",
"tags": { "kind": "sha512" }, "value": 1290 },
{ "name": "loom_node_task_latency_seconds", "type": "TIMER",
"tags": { "kind": "sha512", "state": "completed" },
"count": 1284, "sumSeconds": 412.6, "maxSeconds": 3.91, "meanSeconds": 0.321 }
]
}
Series are named exactly as in a scrape, suffixes included, so a name from the tables below works in
both places. Narrow the response with ?prefix=loom_node_tasks_.
|
Important
|
This is a single instant, not a history — Loom stores no time series. To chart a rate,
call it twice and divide the counter difference by the gap between the two timestamp values. For
anything longer than a live view, scrape the monitoring port with Prometheus.
|
Pipeline Runs
| Metric | Type | Labels | Meaning |
|---|---|---|---|
|
counter |
— |
Pipeline runs dispatched by the engine. |
|
counter |
|
Runs that reached a terminal state. |
|
timer |
|
Wall-clock duration of a complete run. |
|
counter |
|
Runs refused up-front (no capable worker, or a graph that does not validate). |
|
counter |
— |
Runs re-adopted after a server restart. |
A rising loom_pipeline_runs_rejected_total{reason="no_processor"} is the clearest signal that your
Cortex fleet does not advertise a node kind the pipeline needs.
Node Tasks
| Metric | Type | Labels | Meaning |
|---|---|---|---|
|
counter |
|
Node tasks pushed to a worker, by node kind. |
|
counter |
|
Dispatch attempts that found no live worker. |
|
timer |
|
Time from dispatch to result, per node kind. Tasks no worker accepted are not timed — they are counted by |
|
counter |
|
Tasks handed back for another attempt after a failure or a lost lease. |
|
counter |
|
Tasks that exhausted their attempt budget and were failed permanently. |
|
counter |
|
Results reported back over the processor WebSocket. |
|
counter |
— |
Items enumerated by a source node and reported to Loom. |
|
counter |
|
Durable |
loom_node_tasks_dispatched_total rises just as fast whether the fleet is quick or wedged, because
it counts work leaving Loom. loom_node_task_latency_seconds is what closes that loop: it is the
first meter that only moves when work comes back.
Retries and dead-letters are best read together. Retries without dead-letters is a flaky fleet that is recovering on its own; dead-letters climbing alongside them means the retries are not helping and media is being dropped.
Queue Depth
| Metric | Type | Labels | Meaning |
|---|---|---|---|
|
gauge |
— |
Node tasks currently outstanding, summed across all live runs. |
|
gauge |
— |
The summed per-run ceilings those tasks are dispatched against. Runs configured as unlimited contribute |
|
gauge |
— |
Runs currently executing. |
Depth on its own cannot tell you whether the fleet is busy or backed up — 200 outstanding tasks is comfortable against a ceiling of 512 and a queue against a ceiling of 200. Compare the two:
loom_node_tasks_inflight / loom_node_tasks_inflight_ceiling
Sustained near 1 means runs are being throttled by their own in-flight limit, and adding workers
will help. Well below 1 with poor throughput means the bottleneck is elsewhere — a parked node
kind, a slow source, or workers that are not accepting the kinds the graph needs.
|
Note
|
These gauges are fleet-wide sums, not per run. Run identifiers are UUIDs and are deliberately never used as label values. |
Circuit Breakers
Loom stops dispatching a node kind that is failing on every worker — a missing model file, a bad GPU driver, an expired API key. A parked kind produces no dispatches, no failures and no errors, so without these two meters the only symptom is throughput quietly going flat.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
|
gauge |
|
|
|
counter |
|
Times the breaker opened for that kind. A failed recovery probe counts again. |
The gauge says whether a kind is parked right now; the counter distinguishes one bad deployment from a kind that has been flapping all afternoon.
# Any node kind currently parked
max by (kind) (loom_node_circuit_breaker_state) > 0
Workers (Processors)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
|
gauge |
— |
Currently connected Cortex workers. |
|
gauge |
|
Connected workers broken down by the state they report. |
|
counter |
— |
Successful worker registrations. |
|
counter |
— |
Worker disconnects. |
|
counter |
— |
Heartbeats received. |
Connected is not the same as usable: only online workers receive new work. A rolling restart that
leaves the fleet in terminating looks perfectly healthy on loom_processors_connected while
nothing is being dispatched, so alert on the breakdown rather than the total:
loom_processors_by_state{state="online"} == 0
Per-worker CPU and memory are reported by the workers themselves — scrape those from each Cortex
instance (cortex_cpu_load, cortex_memory_used_bytes, see Cortex Metrics)
rather than from Loom.
Leases and Events
| Metric | Type | Labels | Meaning |
|---|---|---|---|
|
counter |
— |
Task leases reclaimed by the reaper after a worker went away. |
|
counter |
— |
Orphaned tasks moved to the dead-letter path. |
|
counter |
|
Tasks handed back by a worker that was shutting down, and placed again immediately. |
|
gauge |
— |
UI clients subscribed to the pipeline event stream. |
|
counter |
— |
Tracking events fanned out to subscribers. |
|
counter |
— |
Events dropped because a subscriber could not keep up. |
|
counter |
|
Rejected authentication / authorization attempts. |
Runtime Metrics
Vert.x built-in metrics and the Micrometer JVM binders share the same registry, so the scrape also carries:
-
vertx_http_server_*,vertx_eventbus_*,vertx_pool_*— HTTP server, event bus and pool metrics. -
jvm_memory_used_bytes,jvm_gc_*,jvm_threads_*,process_cpu_usage— JVM and process metrics.
|
Note
|
The jOOQ database connection pool is a c3p0 pool and is not covered by the Vert.x pool
metrics. Use the JVM and query-level signals rather than assuming vertx_pool_* reflects database
connections.
|
Prometheus Configuration
scrape_configs:
- job_name: metaloom-loom
static_configs:
- targets: ["loom.internal:8989"]
In Kubernetes, expose the monitoring port on the Loom Service and annotate the pod:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8989"
prometheus.io/path: "/metrics"
Useful Queries
# Pipeline run failure ratio over 5 minutes
sum(rate(loom_pipeline_runs_completed_total{status="failed"}[5m]))
/ sum(rate(loom_pipeline_runs_completed_total[5m]))
# Node task throughput by kind
sum by (kind) (rate(loom_node_tasks_dispatched_total[5m]))
# Mean dispatch-to-result time by node kind (see Naming Conventions: no histogram)
sum by (kind) (rate(loom_node_task_latency_seconds_sum[5m]))
/ sum by (kind) (rate(loom_node_task_latency_seconds_count[5m]))
# Runs are saturating their own in-flight ceiling — more workers would help
loom_node_tasks_inflight / loom_node_tasks_inflight_ceiling > 0.9
# A node kind is parked by its circuit breaker
max by (kind) (loom_node_circuit_breaker_state) > 0
# Media being dropped: dead-letters that retries did not rescue
sum by (kind) (rate(loom_node_tasks_deadlettered_total[5m])) > 0
# Alert when no worker can take work (not merely when none is attached)
loom_processors_by_state{state="online"} == 0
# Dropped UI events (subscriber backpressure)
rate(loom_pipeline_events_dropped_total[5m]) > 0
Naming Conventions
Metric names follow Prometheus conventions: counters carry a _total suffix, timers are exported in
base units as _seconds, and labels are deliberately low-cardinality — node kind, status, message
type or a stable worker id. Asset UUIDs, file paths, run UUIDs and user ids are never used as
label values.
Timers are exported without a percentile histogram, so each one yields _seconds_count,
_seconds_sum and _seconds_max series. Derive averages from sum / count; histogram_quantile()
is not available for these meters.