Repository navigation
Metrics
An application instance publishes the numbers an autoscaler and an operator need — queued work, its
step slots, the tree leases it holds, takeovers, hand-overs, ended executions and undo actions —
through OpenTelemetry metrics (#485). The core ships no exporter and opens no port: the
application gives it a MeterProvider, and any OpenTelemetry exporter takes the numbers to
Prometheus (and so to KEDA or an HPA), AWS CloudWatch, Google Cloud Monitoring or Azure Monitor.
How to scale on them — the Prometheus recipe, KEDA, an HPA, ECS, Cloud Run, Container Apps: Autoscaling.
createApp(App, { meterProvider }) — any MeterProvider of @opentelemetry/api: an SDK one with
the readers you want, or the global one (metrics.getMeterProvider()). The instance takes one meter
from it, named @agboss/core at the framework's version. No meterProvider → no metrics: nothing
is loaded and no store is read for them.
import { createApp } from "@agboss/core";
import { MeterProvider, PeriodicExportingMetricReader, ConsoleMetricExporter } from "@opentelemetry/sdk-metrics";
const meterProvider = new MeterProvider({
readers: [new PeriodicExportingMetricReader({ exporter: new ConsoleMetricExporter() })],
});
const app = await createApp(DeskApplication, { meterProvider });At app.close() the instance removes its callbacks before its stores close (onDestroy): a scrape
of a stopping pod never reads a closed store. (The SDK may repeat a gauge's last value.)
The names, kinds, units and label keys are a contract (src/metrics/metric-catalog.ts, checked by
tests/metrics/metric-catalog.test.ts); a change is announced in the Changelog. The
Prometheus name is the official exporter's: . → _, a counter gets _total, a {…} unit is
dropped.
| Name (Prometheus) | Kind | Unit | Value |
|---|---|---|---|
agboss.cluster.children.queued (agboss_cluster_children_queued) |
gauge | {execution} |
children launched with ChildPlacement.AnyInstance waiting to be claimed, every tenant and tree |
agboss.cluster.children.running (agboss_cluster_children_running) |
gauge | {execution} |
those claimed by an instance |
agboss.cluster.children.oldest_queued_seconds (agboss_cluster_children_oldest_queued_seconds) |
gauge | s |
how long the longest-waiting queued child has waited, on the app's Clock; 0 when none |
All three come from one call of ChildExecutionQueueRepository.findQueueDepth() per
collection — an aggregate, never a read per tree (the SQLite store: one statement over its claims
index). A read that fails reports none of the three for that collection — never a stale or zero
value — and the log warns metrics.child-queue-unreadable once per failure streak.
| Name (Prometheus) | Kind | Unit | Labels | Value |
|---|---|---|---|---|
agboss.instance.step_slots.limit |
gauge | {slot} |
— | maxConcurrentSteps |
agboss.instance.step_slots.busy |
gauge | {slot} |
— | steps at work now |
agboss.instance.step_slots.free |
gauge | {slot} |
— | limit − busy |
agboss.instance.steps.waiting |
gauge | {step} |
— | steps waiting for a slot now |
agboss.instance.leases.held |
gauge | {lease} |
— | tree leases it holds: roots it moves, children it claimed, a parent waiting for children (its lease yielded, still its own); a released or lost lease is not held |
agboss.instance.lease.takeovers (…_total) |
counter | {takeover} |
reason |
takeovers it made — recoveries are reason="recovered" (and "unwinding") |
agboss.instance.steps.handed_over (…_total) |
counter | {step} |
reason |
hand-overs of its steps |
agboss.instance.executions.ended (…_total) |
counter | {execution} |
workflow, outcome
|
executions that ended on it, children included: finished (onExecutionEnd), failed or cancelled (onExecutionError) |
agboss.instance.compensations (…_total) |
counter | {action} |
workflow, status
|
automatic undo actions that ended on it: compensated or failed
|
The four slot gauges exist only when the application sets maxConcurrentSteps. Counters count
since the instance started.
Labels are bounded by the code: reason is a LeaseTakeoverReason or a HandOverReason,
outcome one of three, status a CompensationStatus, workflow one of the application's
workflow names. Never a tenant, an execution, a thread, an instance id or an error message — the pod
is the scrape target's own label (pod, instance) or the OTel resource's service.instance.id.
The counters come from a built-in observer the instance calls before the project's
observers, on the event as it is (it reads enums and names only, so no PII mask
runs for it).
Every instance reports the cluster gauges, each the value it read from the one shared store: with
three pods and twelve queued children, every pod reports 12. Aggregate cluster gauges with
max and per-instance ones with sum — in a PromQL query, a KEDA trigger, a CloudWatch
statistic (Maximum), a Cloud Monitoring aligner (ALIGN_MAX / reducer REDUCE_MAX):
max(agboss_cluster_children_queued) # 12 — never sum(...), which is 36
sum(agboss_instance_step_slots_busy) # the steps at work over every pod
Why not one reporting pod: a "reporter" lease is one more lease to get right, a gap in the signal at each failover, and a scrape of the wrong pod reads nothing. One indexed count per pod per scrape interval is cheap.
| Signal | Expression | Meaning |
|---|---|---|
| Demand (scale out) | max(agboss_cluster_children_queued) + sum(agboss_instance_steps_waiting) + sum(agboss_instance_step_slots_busy) |
the steps that want a slot now; target per pod = its maxConcurrentSteps
|
| Idle (safe to scale in to the minimum) | max(agboss_cluster_children_queued) + max(agboss_cluster_children_running) + sum(agboss_instance_leases_held) + sum(agboss_instance_step_slots_busy) + sum(agboss_instance_steps_waiting) == 0 |
nothing queued, running, held or at work anywhere |
A parent waiting for its children holds its lease, and a claimed child between two steps holds its
lease: neither reads idle (tests/metrics/scale-signals.test.ts looks at every turn of a tree and
never sees idle before its end). Roots arrive through your own endpoints, not a queue: they count
through their slots and leases once they run. A scale-in still removes a pod that may hold work —
its graceful stop (app.close({ timeout }) within the pod's termination grace) and the
recovery of what it hands over are what make that safe —
Autoscaling → Graceful termination.
testWith(App, { meterProviders: [a, b] }) gives each instance its own provider, by instance (a
missing one = no metrics there); read it with an in-memory reader:
import { expect } from "vitest";
import { testWith } from "@agboss/core/testing";
import { MeterProvider, MetricReader } from "@opentelemetry/sdk-metrics";
class InMemoryReader extends MetricReader {
protected onForceFlush(): Promise<void> {
return Promise.resolve();
}
protected onShutdown(): Promise<void> {
return Promise.resolve();
}
}
const reader = new InMemoryReader();
const test = testWith(DeskApplication, {
meterProviders: [new MeterProvider({ readers: [reader] })],
});
test("an idle instance holds no lease", async ({ app }) => {
await app.ready();
const { resourceMetrics } = await reader.collect();
const held = resourceMetrics.scopeMetrics
.flatMap((scope) => scope.metrics)
.find((metric) => metric.descriptor.name === "agboss.instance.leases.held");
expect(held?.dataPoints[0]?.value).toBe(0);
});Every ChildExecutionQueueRepository answers findQueueDepth(): Promise<ChildExecutionQueueDepth>
— { queued, running, oldestQueuedAt? } over every tenant and tree, in one round trip. The queue
contract (childExecutionQueueContract, and so storeConformance) checks it: empty, rows of
several tenants and trees by status, the oldest still queued (a claim of the oldest moves it),
both instances read the same, a tree's rows gone with its root, a lost claim queued again, forty
rows claimed by two instances at once counted once. Your own store.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Running on PostgreSQL
- Your own store
- Guarantees
- Guarantees across processes
- Payments
- Execution history
- Mesh Hub — agboss hub
- Health checks
- Metrics
- Autoscaling
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Eval suites — @EvalSuite
- Components
- Naming
- Standard DTOs
- Examples: 01–08, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- A2A — agent to agent
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)