How-to: Measure Duration and Collect Metrics with Middleware
Problem
You want to collect operational metrics for Postboy, such as:
counts of operations by stage (Publish / Callback / Execute),
latency (duration) distributions,
success vs cancellation rates,
(optionally) result characteristics for synchronous Execute operations.
You want this instrumentation to be centralized and consistent, without changing every handler.
Prerequisites
Middleware is attached to the Postboy bus.
You understand:
stages and what they mean
canHandlefor scopingbefore/afterfor start/end hooksinterrupt/cancel behavior (interrupt prevents
afterfor that operation)
You have a metrics sink concept in your system:
a metrics client (Prometheus/OpenTelemetry/statsd/etc.), or
a thin adapter interface your app owns.
Steps
1) Decide which metrics you want (start minimal)
A good minimal set:
Counters
operations_total{stage, message}
operations_cancelled_total{stage, message}
Latency
operation_duration_ms{stage, message} (histogram/summary)
Optional
operations_success_total{stage, message}
operations_error_total{stage, message} (only if your middleware can observe errors in your integration)
Keep labels/tags controlled:
stage
message name/type (stable string) Avoid high-cardinality labels:
message id (do not use as a metric label)
user id, tenant id, request ids
2) Pick a stable “message label”
Choose a stable string to identify message categories in metrics, for example:
message class name
message “type” identifier used in your project
a curated mapping (“Command:CreateUser”, “Query:GetUser”)
Goal:
stable dashboards over time
predictable cardinality
3) Scope metrics collection with canHandle
Decide where metrics is useful:
all stages, or only Execute/Callback
only domain messages (exclude infrastructure/internal messages if applicable)
Then implement filtering in canHandle:
stage-first
then message subset
4) Record “attempts” in before
In before:
increment operations_total
capture start time for latency measurement
Important:
If the operation is cancelled by a later middleware, you still want to count the attempt. Logging in
beforeensures that.
5) Record “success” and latency in after
In after:
compute duration (now - start)
record operation_duration_ms
increment operations_success_total (if you maintain it)
Important constraints:
afteris only called on successful completion.afteris not called on cancel, and may not be called on failures (depending on where the failure happens).
6) Count cancellations (without relying on after)
Cancellations happen in before of some middleware. That means a pure “timing middleware” may not see the cancellation unless:
you also instrument cancellation at the point where cancellation is raised, or
your system catches the cancellation error at the boundary and increments a cancellation metric there.
Practical options:
Option A (recommended): metrics middleware + boundary instrumentation
metrics middleware measures attempts and successes
cancellation counters are incremented where cancellations are handled (caller boundary)
Option B: place metrics middleware before all guards
you still can’t get
afteron cancelled operationsbut you can at least log “attempts” and possibly “cancelled” if you also log/record at the cancellation boundary
7) Manage per-operation timing state safely
If you store start timestamps between before and after:
key by message id (and optionally stage)
keep the structure bounded to avoid leaks if
afternever runs
Examples of safe strategies:
store start time only for stages you measure (often Execute)
use a Map with periodic cleanup / TTL
avoid storing anything for Publish if you only need counters
Verification (expected outcome)
Minimal test checklist
Run an allowed Execute operation:
operations_total increments
operation_duration_ms records a value
operations_success_total increments
Trigger a cancellation (guard middleware interrupts):
operations_total increments (attempt seen)
operations_cancelled_total increments at your cancellation handling boundary
no duration is recorded via
afterfor that cancelled attempt
Run a Publish operation (if instrumented):
operations_total increments
no latency metric (unless you intentionally measure it)
Diagram: timing and metrics points
@startuml title Metrics with before/after and cancellation boundary
actor Caller participant "Postboy" as Bus participant "Metrics MW" as Metrics participant "Guard MW (optional)" as Guard participant "Handlers/Subscribers" as H participant "Caller boundary" as Boundary
Caller -> Bus: start operation Bus -> Metrics: before(context) note over Metrics: increment operations_total\nstore start time
Bus -> Guard: before(context) alt Guard interrupts note over Guard: Interrupt Bus --> Boundary: throws cancellation error Boundary -> Boundary: increment operations_cancelled_total Boundary --> Caller: error propagated/handled else Guard continues Bus -> H: run operation H --> Bus: success (result?) Bus -> Metrics: after(context, result?) note over Metrics: compute duration\nrecord operation_duration_ms\nincrement success counter Bus --> Caller: completed end
@enduml
Troubleshooting
“Latency metric is missing for callbacks”
Cause:
Callback completion is asynchronous and may not map to a simple before/after boundary depending on how your integration reports completion.
Fix:
Decide what you measure for Callback:
request accepted latency (before → immediate)
end-to-end completion (requires hooking into callback completion signals)
Document the choice and implement accordingly.
“Metrics cardinality exploded”
Cause:
using high-cardinality labels (message ids, user ids, dynamic strings).
Fix:
remove message id from labels
use stable message name/type only
bucket by role (Command/Query/Event/Executor) if needed
“Cancelled operations are not counted”
Cause:
cancellation skips
afterand your cancellation counter is only inafter.
Fix:
increment cancellation metrics at the cancellation handling boundary (caller side)
or ensure your guard middleware increments a cancellation counter before interrupting (if your design allows it)
“Metrics cause noticeable overhead”
Cause:
heavy label construction
expensive time sources
synchronous export on hot path
Fix:
precompute stable labels
keep
canHandlecheap and narrowuse an async exporter / batching if available
Best practices
Start with attempts + latency for Execute stage; expand later.
Avoid high-cardinality labels (especially message id).
Treat cancellations separately: they are not “successful completions”.
Keep timing state bounded; do not assume
afteralways runs.Make metrics behavior explicit in docs:
which stages are instrumented
what “success” means per stage
how cancellations are counted
Next steps
[How-to: logging and tracing messages with middleware][How-to: block execution with guard middleware (Interrupt)][Middleware lifecycle and contract (before/after/canHandle/dispose)][Middleware stages: Publish / Callback / Execute][Troubleshooting: unexpected cancels and missing after()]