Skip to main content

Metrics aggregator

WIP

In-process allow/deny/fail-open counters and p50/p95/p99 latency over the engine's onMetrics hook, with sink wiring and overhead figures

iamCreateMetricsAggregator is a fixed-size ring buffer and four counters sitting behind the engine's onMetrics hook. It turns the per-check event stream into a small snapshot object you can serve from a /metrics route, push into Prometheus, or read from a debug console - without a histogram library and without allocating on the hot path.

What the engine emits

The aggregator consumes IamEngineTypes.IMetricsEvent. The engine fires it once per evaluation, in both development and production mode, carrying only primitives - production callers skip the full IDecision allocation but still get telemetry.

interface IMetricsEvent<TAction extends string = string, TResource extends string = string> {
  readonly subjectId: string
  readonly action: TAction
  readonly resource: TResource
  readonly allowed: boolean
  readonly durationMs: number
  readonly mode: AccessControl.Mode
  readonly failOpen: boolean
}
FieldTypeMeaning
subjectIdstringSubject the check ran against
actionTActionAction checked
resourceTResourceResource type checked, not the instance id
allowedbooleanFinal verdict
durationMsnumberWall-clock evaluation time from performance.now(), sub-millisecond precision
mode'development' | 'production'Engine mode in effect
failOpenbooleantrue only when the verdict was allow purely because defaultEffect: 'allow' fired with no applicable policy

The aggregator reads three of these - allowed, durationMs, failOpen - and ignores subjectId, action, resource, and mode. If you want per-action or per-resource breakdowns, read them off the event yourself in the hook; see Multiple aggregators.

Every metric in a snapshot

snapshot() returns a fresh IamMetrics.ISnapshot. Nine numbers, four of them monotonic counters and five of them gauges over the rolling window.

MetricTypeKindMeaning
totalnumbercounterEvents recorded since construction or the last reset(). Not capped by sampleSize
allownumbercounterAllow verdicts. allow + deny === total always
denynumbercounterDeny verdicts, including fail-closed denies from evaluation errors
failOpennumbercounterAllows attributable solely to defaultEffect: 'allow'. A strict subset of allow
p50number (ms)gaugeMedian durationMs over the rolling window
p95number (ms)gauge95th percentile over the rolling window
p99number (ms)gauge99th percentile over the rolling window
maxnumber (ms)gaugeLargest durationMs in the window, not all-time
samplesnumbergaugeCurrent window size: usable latency samples since reset, capped at sampleSize

An empty window returns every field as 0 - no NaN, no null, so a scrape never has to special-case a cold process.

Three of these have edges worth stating explicitly.

samples can be lower than min(total, sampleSize). The decision is always counted; only the latency sample can be refused. record drops a durationMs that is NaN, ±Infinity, or negative, because one such value in the Float64Array makes the percentile sort's comparator inconsistent and returns NaN for every quantile - which a /metrics route then serialises as null - until the ring rolls over. Zero is kept: it is a legitimate duration for a cache hit. A samples count that lags total therefore means something upstream handed the hook a non-finite duration, usually a clock that went backwards or a caller forwarding a hand-built event.

max is a window maximum. With sampleSize: 3 and durations 100, 1, 2, 3, the snapshot reports max: 3 and samples: 3 while total is 4. The 100 ms outlier has been evicted. If you need all-time worst case, keep it yourself in the hook or scrape frequently enough that no window is ever fully replaced between scrapes.

deny counts fail-closed errors. When an evaluation throws, the engine calls onError, emits a metrics event with allowed: false and failOpen: false, then returns a deny. Those denies are indistinguishable from policy denies in the snapshot. Use the onError hook, not deny, to alarm on adapter failures.

Percentiles

Percentiles are nearest-rank over a sorted copy of the live window - index clamp(ceil(q * n) - 1, 0, n - 1). No interpolation. For 100 samples of 1..100 that gives p50 = 50, p95 = 95, p99 = 99, which is what metrics.test.ts pins. With sampleSize: 1 all three percentiles and max collapse to the single most recent duration.

Charting fail-open

failOpen is the canary for a silently broken policy set - a rule dropped by the ReDoS guard, a mass deletion, an adapter that returned zero policies. Under defaultEffect: 'allow' the boolean verdict hides all of that; every request still says "allowed". failOpen is the only field that separates "a policy said yes" from "nothing had an opinion".

// Page when more than 1% of allows in a one-minute window were fail-open.
setInterval(() => {
  const s = metrics.snapshot()
  if (s.allow > 0 && s.failOpen / s.allow > 0.01) {
    pager.fire('iam.fail_open_rate', { failOpen: s.failOpen, allow: s.allow })
  }
  metrics.reset()
}, 60_000)

Under the default defaultEffect: 'deny' this counter stays at zero, because the fallback path denies rather than allows. A non-zero failOpen on a deny-default engine means someone changed the default.

Wiring a sink

Create the aggregator

One per engine is the normal case. It holds no reference to the engine, so construction order does not matter.

Bind .record to hooks.onMetrics

record is a closure over the buffer, not a prototype method, so passing the bare function reference is safe - there is no this to lose.

Read .snapshot() from wherever your pipeline scrapes

Snapshots are cheap to take but not free; see Overhead before calling one per request.

import { IamEngine } from '@gentleduck/iam'
import { iamCreateMetricsAggregator } from '@gentleduck/iam/observability/metrics'

const metrics = iamCreateMetricsAggregator({ sampleSize: 1000 })

const engine = new IamEngine({
  adapter,
  hooks: { onMetrics: metrics.record },
})

app.get('/metrics', (_req, res) => res.json(metrics.snapshot()))

Loading diagram...

The diagram shows the only two live paths. record is on the request path and touches nothing but the counters and one ring slot. snapshot is on the scrape path, reads both, and never mutates them - reset is the only thing that clears state, and it is a separate explicit call.

Prometheus

The aggregator is a complement to a real metrics client, not a replacement. Record into both from one hook: prom-client owns the exported time series, the aggregator gives you a snapshot readable from inside the process.

import { Counter, Histogram } from 'prom-client'

const iamTotal = new Counter({
  name: 'iam_evaluations_total',
  help: 'duck-iam evaluations',
  labelNames: ['outcome', 'action', 'resource'],
})
const iamLatency = new Histogram({
  name: 'iam_evaluation_duration_ms',
  help: 'duck-iam evaluation latency',
  buckets: [0.1, 0.5, 1, 5, 10, 50],
})
const iamFailOpen = new Counter({ name: 'iam_fail_open_total', help: 'allows from defaultEffect' })

const metrics = iamCreateMetricsAggregator()

const engine = new IamEngine({
  adapter,
  hooks: {
    onMetrics: (event) => {
      metrics.record(event)
      iamTotal.inc({ outcome: event.allowed ? 'allow' : 'deny', action: event.action, resource: event.resource })
      iamLatency.observe(event.durationMs)
      if (event.failOpen) iamFailOpen.inc()
    },
  },
})

Keep label cardinality in mind. action and resource come from the caller's declared unions, so their joint cardinality is |actions| x |resources| - fine at tens, expensive at thousands. subjectId is unbounded and is a customer identifier heading for a store with long retention; never label with it. The event carries resource.type and never resource.id for the same reason.

The one other label operators reach for is the Redis invalidator's onMessageDropped reason. Every inbound reason is a fixed string from the module itself, but the outbound publish failed (<message>) reason interpolates the driver's own error text and is therefore unbounded. Collapse that row to a constant before it becomes a label:

onMessageDropped: (reason, _channel, suppressed) => {
  const label = reason.startsWith('publish failed') ? 'publish failed' : reason
  drops.inc({ reason: label }, 1 + suppressed)
}

Windowed export

For a pull-based sink that wants per-interval numbers rather than since-boot totals, snapshot and reset on the interval. Read before you reset - reset() returns nothing.

setInterval(() => {
  const s = metrics.snapshot()
  statsd.gauge('iam.p95', s.p95)
  statsd.gauge('iam.p99', s.p99)
  statsd.increment('iam.allow', s.allow)
  statsd.increment('iam.deny', s.deny)
  statsd.increment('iam.fail_open', s.failOpen)
  metrics.reset()
}, 60_000)

reset() zeroes all four counters and empties the window, but keeps the Float64Array allocated - the next record reuses the same memory. Percentiles after a reset are computed only from post-reset samples.

Multiple aggregators

The hook is one function, so any split you want is a branch inside it:

const allowMetrics = iamCreateMetricsAggregator()
const denyMetrics = iamCreateMetricsAggregator()

const engine = new IamEngine({
  adapter,
  hooks: {
    onMetrics: (event) => {
      ;(event.allowed ? allowMetrics : denyMetrics).record(event)
    },
  },
})

The same shape gives per-resource or per-action aggregators keyed out of a Map. Budget memory accordingly - each aggregator carries its own buffer.

Batch checks opt out

engine.permissions(subjectId, checks) emits one metrics event per check in the batch. A permission map built for a UI can be dozens of checks in one call, so permissions() accepts { telemetry: false } to skip onMetrics entirely for hot gates - roughly double the throughput. Those checks then contribute nothing to total, which is worth knowing before you compare total against your request count.

const map = await engine.permissions(userId, checks, undefined, { telemetry: false })

Hook errors are swallowed

If your onMetrics hook throws, the engine catches it, logs onMetrics hook threw - swallowed to preserve decision via console.error, and returns the decision unchanged. A broken sink degrades observability, never authorization. The aggregator's own record has no throwing path once construction succeeded.

The rolling window and sampleSize

Durations live in a Float64Array of exactly sampleSize slots, written at a moving head that wraps modulo the capacity. Once full, each write overwrites the oldest sample. There is no growth and no allocation after construction.

Loading diagram...

metrics.test.ts pins the wrap behaviour precisely: with sampleSize: 4 and durations 40, 30, 20, 10, max is 40; recording a fifth duration of 5 overwrites slot 0, the window becomes 5, 30, 20, 10, and max drops to 30. Snapshotting in between does not disturb the ring - the sort runs on a copy.

sampleSize validation

const cap = config.sampleSize ?? 1000
if (!Number.isInteger(cap) || cap < 1) {
  throwIamError('IAM_METRICS_SAMPLE_SIZE_INVALID', { got: cap })
}

The factory rejects any sampleSize that is not a positive integer, throwing the coded IamError IAM_METRICS_SAMPLE_SIZE_INVALID (meta: { got }) naming the offending value. metrics-sample-size.test.ts pins 0, -1, 1.5, NaN, and Infinity as rejected, and 1 as accepted.

Choosing a value:

sampleSizeWindow memoryWhen
100800 BLow-traffic service; tail percentiles will be coarse
1000 (default)8 KBSensible default; snapshot() sorts 1000 numbers
10_00080 KBHigh QPS, stable p99 wanted; scrape on an interval, not per request
100_000800 KBOnly with an infrequent scrape - the sort dominates

A larger window buys tail accuracy and costs snapshot time, not record time. record is O(1) at every size.

Overhead

Per event. One counter increment, one conditional increment for failOpen, one Float64Array store, one modulo. No allocation, no closure creation, no object churn. The event object itself is built by the engine whether or not you aggregate it.

Memory. Eight bytes per sample, allocated once at construction and never resized: 8 KB at the default sampleSize: 1000. Counters are four numbers. There is no per-subject or per-action state, so nothing scales with cardinality.

Per snapshot. Array.from(buf.subarray(0, samples)) allocates a JS array of samples numbers, then sorts it - O(n log n). At the default cap that is a 1000-element numeric sort, sub-millisecond, but it is not free and it allocates. Call it on a scrape interval. A /metrics route hit once every 15 seconds is fine; calling snapshot() inside a request handler is not.

Reset. Constant time. Counters go to zero and the head and count reset; the buffer allocation survives.

API reference

iamCreateMetricsAggregator

function iamCreateMetricsAggregator(config?: IamMetrics.IConfig): IamMetrics.IAggregator

Returns a new aggregator with zeroed counters and an empty window. Throws the coded IamError IAM_METRICS_SAMPLE_SIZE_INVALID when config.sampleSize is present and is not a positive integer.

Options

OptionTypeDefaultMeaning
sampleSizenumber1000Durations kept in the rolling window. Must be a positive integer; anything else throws IAM_METRICS_SAMPLE_SIZE_INVALID (meta: { got })

IamMetrics.IAggregator

interface IAggregator {
  record(event: IamEngineTypes.IMetricsEvent): void
  snapshot(): ISnapshot
  reset(): void
}
MethodReturnsNotes
record(event)voidBind directly to hooks.onMetrics. Safe to pass unbound
snapshot()IamMetrics.ISnapshotA fresh object each call. Never mutates aggregator state
reset()voidZeroes counters and empties the window; keeps the buffer allocation

IamMetrics.ISnapshot

interface ISnapshot {
  readonly total: number
  readonly allow: number
  readonly deny: number
  readonly failOpen: number
  readonly p50: number
  readonly p95: number
  readonly p99: number
  readonly max: number
  readonly samples: number
}

Field meanings are tabulated in Every metric in a snapshot.

Types

All three live in the type-only IamMetrics namespace, exported from @gentleduck/iam/observability/metrics. Type-only means zero bundle cost.

import type { IamMetrics } from '@gentleduck/iam/observability/metrics'
import { iamCreateMetricsAggregator } from '@gentleduck/iam/observability/metrics'

const config: IamMetrics.IConfig = { sampleSize: 1000 }
const aggregator: IamMetrics.IAggregator = iamCreateMetricsAggregator(config)
const snap: IamMetrics.ISnapshot = aggregator.snapshot()

Upgrade note: the bare aliases IMetricsAggregator, IMetricsSnapshot, and IMetricsAggregatorConfig were deprecated when the namespace landed in 2.0.0 and are gone from 3.0.0 onward. The namespace and factory both gained the Iam prefix in 5.0.0 - Metrics became IamMetrics, and createMetricsAggregator became iamCreateMetricsAggregator.

Gotchas

  • total is not your request count. Batch checks through permissions() contribute one event per check, and { telemetry: false } contributes none. A permissions() call that fails to load the subject or the policy set returns an all-deny map through onError and emits no events at all, so an adapter outage depresses the batch event rate instead of showing up as denies. The single-check path does emit on its error path, with allowed: false, failOpen: false.
  • samples below total is a signal, not rounding. Non-finite or negative durations are dropped from the window; see Every metric in a snapshot.
  • max decays. It is the window maximum, so a latency spike disappears once sampleSize newer samples have landed. Scrape faster than your window turns over, or track the peak in your sink.
  • snapshot() does not reset. Two consecutive snapshots with no reset in between report overlapping totals. Pick one convention - cumulative or windowed - and stick to it across dashboards.
  • deny includes fail-closed errors. An adapter outage shows up as a deny spike, not an error metric. Wire hooks.onError for that signal.
  • failOpen stays zero under defaultEffect: 'deny'. That is correct, not a broken counter.
  • The aggregator is per process. In a multi-pod deployment each pod has its own window; percentiles are per pod and are not meaningfully averaged across them. Export to a real time-series database if you need fleet-wide tails.

See also

  • Hooks - onMetrics alongside beforeEvaluate, onDeny, onError, and onPolicyError
  • Engine modes - why production mode still emits metrics without building a decision
  • Caching - engine.stats.get() for cache hit rates, the other half of the observability picture
  • Redis invalidator - cross-process cache invalidation, and the onPublishError signal worth charting next to these
  • Production guide - the full hardening checklist