The Monitor API
The programmatic operational read-state surface for custom views, alerting, and automation — the read-only pull twin of the dashboard.
Every screen the dashboard renders is a projection of operational state, and every one of those projections comes from a single place: the public BackWaveMonitor. The Monitor is the read-only pull twin of the dashboard. Where the dashboard paints that state for a human, the Monitor hands you the same shapes in code, so you can build a custom view, drive an alert, feed a metrics exporter, or assert on state in a test. Every read returns a stable public shape, storage internals never leak through, and nothing the Monitor does mutates the store. The write side — requeue, cancel, pause, trigger — is its audited counterpart, the BackWaveOperator, covered under operator actions.
Getting the Monitor#
BackWaveMonitor (namespace BackWave.Monitor) is registered as a singleton by AddBackWave(...), so you inject it from DI directly. There is no extra registration call and you never construct it yourself in a hosted app. It is the read-only peer of the enqueue client and the operator.
using BackWave.Monitor;
public sealed class QueueAlertService(BackWaveMonitor monitor)
{
public async Task<bool> AnyQueuePausedAsync(CancellationToken ct)
{
var settings = await monitor.GetQueueSettingsAsync(ct);
return settings.Any(q => q.Paused);
}
}In tests, the testing harness exposes the same instance as harness.Monitor, so an assertion reads exactly the state a worker just produced.
Almost every read is asynchronous, returns a ValueTask, and takes a trailing CancellationToken cancellationToken = default. The two exceptions are synchronous because they read in-memory state: the JobHistoryPolicy and IsHistoryRecordingDisabled properties, and GetKnownWireNames().
The read methods at a glance#
| Method | Returns | Answers |
|---|---|---|
GetJobAsync(jobId) | JobSnapshot? | One job's current snapshot, or null if no such job. |
GetJobHistoryAsync(jobId) | IReadOnlyList<JobTransition> | Its transition timeline, oldest first. |
ListJobsAsync(query) | IReadOnlyList<JobSnapshot> | A filtered, sorted, paginated page of jobs. |
GetJobPayloadAsync(jobId) | JobPayloadView? | The job's payload rendered for display. |
GetJobOutputAsync(jobId) | ReadOnlyMemory<byte>? | The raw Job Output bytes for a programmatic consumer. |
GetJobOutputViewAsync(jobId) | JobPayloadView? | The Job Output rendered for display. |
GetQueueDepthsAsync() | IReadOnlyList<QueueStateCount> | Job counts grouped by queue and state. |
GetQueueSettingsAsync() | IReadOnlyList<QueueSettings> | Paused flag and concurrency cap per queue. |
GetTagFacetAsync(key, baseQuery) | IReadOnlyList<TagFacet> | Distinct-job counts grouped by one tag dimension. |
GetDependencyEdgesAsync(jobId) | DependencyEdges | The gating parents and waiting children around a job. |
ListSchedulesAsync() | IReadOnlyList<ScheduleStatus> | Every Recurring Schedule with its current health. |
GetObserverCursorAsync(observerId) | long | One observer's delivery cursor (-1 when nothing delivered). |
ListObserverDeadLettersAsync(observerId) | IReadOnlyList<ObserverDeadLetterRecord> | Its dead-lettered deliveries, oldest first. |
GetKnownWireNames() | IReadOnlyList<string> | Every registered Wire Name, alphabetical. |
There is no single GetHealthAsync. The dashboard's overview "health rollup" is not one method; it is a small composition of GetQueueDepthsAsync, GetQueueSettingsAsync, and ListSchedulesAsync, which you assemble yourself. See Composing a health rollup.
Inspecting a single job#
GetJobAsync returns the current snapshot of one job, or null when no job carries that id. The snapshot is the displayable facts only; it never carries the payload bytes, which keeps the read cheap.
var job = await monitor.GetJobAsync(jobId);
if (job is null) return NotFound();
// While job.State == JobState.Leased, LeaseOwner and LeaseExpiry are set:
// that pairing is the "executing now" signal.A JobSnapshot carries:
| Field | Type | Meaning |
|---|---|---|
JobId | Guid | Unique id. |
WireName | string | The stable identity the job type is registered under. |
Queue | string | The queue it runs on. |
State | JobState | Current lifecycle state (see below). |
Attempt | int | Execution tries so far; claiming to run starts an attempt. |
DueTime | DateTimeOffset | When it became, or becomes, eligible to run. |
LeaseOwner | string? | The worker holding it; set only while Leased. |
LeaseExpiry | DateTimeOffset? | When the lease lapses without a heartbeat; set only while Leased. |
CancelRequested | bool | Whether cancellation has been requested; the running attempt observes it cooperatively. |
TerminalAt | DateTimeOffset? | When it reached a terminal state; null while active. |
TerminalCause | string? | Short reason for the terminal outcome; null while active. |
ScheduleId | string? | The Recurring Schedule that minted it; null for a directly enqueued job. |
Sequence | long | Monotonic ordering value, used as the pagination cursor. |
WorkflowId | Guid? | The Workflow it belongs to; null if none. |
Tags | IReadOnlyList<JobTag> | Its tag set; empty when untagged. |
The job states themselves are:
| State | Meaning |
|---|---|
Scheduled | Enqueued and waiting for its Due Time; claimable once due. |
AwaitingParent | Held until its parents reach terminal states; becomes Scheduled when the last one resolves. |
Leased | Claimed by a worker and running under a heartbeat-renewed lease — the executing-now state. |
Succeeded | Terminal; completed successfully. |
Cancelled | Terminal; cancelled by an operator, or by an on-success parent that failed. |
DeadLettered | Terminal; ran and kept failing until it exhausted its attempt ceiling, then set aside for inspection. |
Quarantined | Terminal; could not be routed to a handler or its payload no longer decodes, then set aside. |
DeadLettered and Quarantined are different classes of problem, not different severities: one ran and kept failing, the other never got a clean start. The public helper JobStates.IsTerminal(state) is true for the four terminal states.
The transition timeline#
GetJobHistoryAsync returns the Transition Log for one job — the append-only history of state changes, oldest first. Each JobTransition is an Ordinal (its 0-based per-job position, preserved even when older entries age out), a Timestamp, the resulting State, the Attempt at that point, and an optional FailureDetail.
An empty list is ambiguous on its own: it means either the job is unknown, or history recording is switched off. Two synchronous properties disambiguate it. JobHistoryPolicy reports how much the store records, straight from the store so it always reflects reality, and IsHistoryRecordingDisabled is true exactly when that policy is Off. Render an explicit "history disabled" state rather than a blank timeline that looks broken.
JobHistoryPolicy | What is recorded |
|---|---|
Off | Nothing. The timeline is always empty and IsHistoryRecordingDisabled is true. |
Transitions | The state changes, with no failure diagnostics. |
TransitionsAndFailureDetail | State changes plus Failure Detail on failing transitions. The default. |
Failure Detail can be absent even at the top rung. The environment variable
BACKWAVE_DISABLE_FAILURE_DETAIL(truthy values1,true,yes,on, case-insensitive) suppresses it at recording time for PII-sensitive hosts, soJobTransition.FailureDetailreadsnull. This is a recording switch and is distinct from the dashboard'sBACKWAVE_DASHBOARD_DISABLE_SENSITIVE_DATA, which gates viewing, not recording.
Payload and output#
The Monitor exposes three sensitive reads. GetJobPayloadAsync renders a job's payload for display. GetJobOutputAsync returns the raw Job Output bytes a successful handler emitted, so an in-process dependent can deserialize them to the producer's own shape, while GetJobOutputViewAsync renders those same output bytes for a human. Each returns null when there is no such job (and the output reads return null when the job set no output).
A JobPayloadView is the rendered form returned by both display reads: a ByteCount (the raw length before rendering), an Encoding, and the rendered Text. The render is best-effort and the payload is opaque: it is serialized by your own serializer and never parsed here, so BackWave does not assume JSON. The bytes are decoded as strict UTF-8; if they are not valid UTF-8, Encoding is Hex and Text holds an uppercase hex dump (otherwise Encoding is Utf8). ByteCount is the raw length either way.
These three reads are sensitive: a payload or output may carry secrets or personal data. The Monitor does not gate them — it returns the bytes to any caller. In the dashboard, the
ViewSensitiveDatapermission gates the equivalent screens; in your own consumer, that gate is your responsibility. Authorize before you call.
if (callerHasViewSensitiveData) // your authorization check, not BackWave's
{
var view = await monitor.GetJobPayloadAsync(jobId);
// view.Encoding == PayloadEncoding.Hex when the bytes are not UTF-8;
// view.Text then holds the hex dump. view.ByteCount is the raw length.
}Listing and filtering jobs#
ListJobsAsync is the workhorse: it takes an optional JobQuery and returns the jobs matching that filter, in the requested sort order, capped at one page.
Passing null uses an empty JobQuery, which matches every job, so it is easy to call by accident and dump the whole store, though it is still capped to a single page. Every field on a JobQuery that is left null or empty simply adds no constraint, and the scalar filters AND together with each other and with the tag predicates.
| Field | Type | Default | Meaning |
|---|---|---|---|
State | JobState? | null | Match only this state. |
Queue | string? | null | Match only this queue. |
WireName | string? | null | Match only this Wire Name. |
ScheduleId | string? | null | Match only jobs minted by this schedule. |
TagPredicates | IReadOnlyList<JobTagPredicate> | [] | Tag predicates, AND-ed together. |
AfterSequence | long? | null | Pagination cursor; null is the first page. |
SortDirection | JobSortDirection | OldestFirst | Sort by Sequence: OldestFirst or NewestFirst. |
MaxResults | int | int.MaxValue | Requested page size; the store clamps it down. |
Tag predicates are built through three static factories on JobTagPredicate:
| Factory | Matches a job that |
|---|---|
JobTagPredicate.HasLabel(value) | Carries the bare label with this value. |
JobTagPredicate.HasKeyValue(key, value) | Carries this exact keyed tag. |
JobTagPredicate.HasKey(key) | Carries any tag under this key. |
Predicates AND together; there is no OR, so a query that needs OR is two separate calls.
var succeeded = await monitor.ListJobsAsync(new JobQuery { State = JobState.Succeeded });
var reportsQueued = await monitor.ListJobsAsync(new JobQuery { State = JobState.Scheduled, Queue = "reports" });
var byType = await monitor.ListJobsAsync(new JobQuery { WireName = "inventory-sync" });
var acmeDeadLetters = await monitor.ListJobsAsync(new JobQuery
{
State = JobState.DeadLettered,
TagPredicates = [ JobTagPredicate.HasKeyValue("tenant", "acme") ],
});To populate a job-type filter dropdown, GetKnownWireNames() returns every registered Wire Name alphabetically. In a DI-hosted app it is always populated; on a hand-built Monitor with no registry it returns an empty list.
Pagination#
Paging is cursor-based, never offset-based. The cursor is JobQuery.AfterSequence, and it refers to JobSnapshot.Sequence. A page returns only rows strictly beyond the cursor in the sort direction: for OldestFirst, a Sequence greater than the cursor; for NewestFirst, a Sequence less than it. To fetch the next page, take the last row of the current page, read its Sequence, and pass it as the next query's AfterSequence.
MaxResults is a request, not a guarantee. The store clamps it down to its maximum monitor page size, which defaults to 200 and is a host-configurable store bound. You cannot exceed that cap no matter what you ask for. There is no total-count or "has next page" flag in the result; you detect the end by receiving fewer rows than the cap, or an empty page.
async IAsyncEnumerable<JobSnapshot> AllDeadLetteredAsync(BackWaveMonitor monitor)
{
long? after = null;
while (true)
{
var page = await monitor.ListJobsAsync(new JobQuery
{
State = JobState.DeadLettered,
SortDirection = JobSortDirection.OldestFirst,
AfterSequence = after,
MaxResults = 200,
});
if (page.Count == 0) yield break;
foreach (var job in page) yield return job;
after = page[^1].Sequence; // last row's Sequence -> next page cursor
}
}Faceting by a tag dimension#
GetTagFacetAsync groups jobs by one tag dimension and counts the distinct jobs per value, ordered by count descending with value ascending as a stable tiebreak. A non-empty key facets a keyed tag, so "tenant" gives per-tenant counts; the empty string "" facets bare labels. A job that carries several values under the same key contributes once to each value.
// Break down quarantined jobs on the "lab" queue by tenant.
var byTenant = await monitor.GetTagFacetAsync("tenant", new JobQuery
{
State = JobState.Quarantined,
Queue = "lab",
});
foreach (var bucket in byTenant)
Console.WriteLine($"{bucket.Value}: {bucket.Count}");The optional baseQuery scopes which jobs are counted using the same filters as ListJobsAsync; null counts all jobs. Its pagination and sort fields are ignored — a facet always counts the whole matching population, never one page. Each TagFacet is a (Value, Count) pair. One dimension per call; cross-tabs across keys are out of scope.
Queue depth and settings#
GetQueueDepthsAsync returns the backlog: one QueueStateCount, a (Queue, State, Count) triple, per queue-and-state pair that currently has jobs. Pairs with zero jobs are absent. GetQueueSettingsAsync returns each queue's operational settings as a (Queue, Paused, ConcurrencyLimit) triple, one per queue that has settings on record; a null ConcurrencyLimit means no cap.
In-use concurrency slots are not reported directly. Derive them by combining the two reads: the Leased count from the depths is the slots in use, and the ConcurrencyLimit from the settings is the capacity.
var depths = await monitor.GetQueueDepthsAsync(); // counts per (queue, state)
var settings = await monitor.GetQueueSettingsAsync(); // paused flag + concurrency cap
int InUse(string queue) =>
depths.Where(d => d.Queue == queue && d.State == JobState.Leased).Sum(d => d.Count);
int? Capacity(string queue) =>
settings.FirstOrDefault(s => s.Queue == queue)?.ConcurrencyLimit;Composing a health rollup#
Because there is no single health method, you build the overview the same way the dashboard does: from depths, settings, and schedules, plus a small preview query for the newest failures.
var depths = await monitor.GetQueueDepthsAsync();
var settings = await monitor.GetQueueSettingsAsync();
var schedules = await monitor.ListSchedulesAsync();
var pausedQueues = settings.Where(s => s.Paused).Select(s => s.Queue);
var brokenSchedules = schedules.Where(s => s.Error is not null);
var newestFailures = await monitor.ListJobsAsync(new JobQuery
{
State = JobState.DeadLettered,
SortDirection = JobSortDirection.NewestFirst,
MaxResults = 5,
});The Dashboard renders these numbers live; see The Dashboard.
Recurring Schedules#
ListSchedulesAsync returns every Recurring Schedule with its current health as a ScheduleStatus, and an empty list when none are configured. A schedule and the jobs it mints are distinct things with distinct lifecycles: the schedule mints jobs, it is not itself a job.
| Field | Type | Meaning |
|---|---|---|
ScheduleId | string | Unique id. |
Cron | string | The cron expression in canonical six-field form. |
WireName | string | The Wire Name of the type it mints. |
Queue | string | The queue the minted jobs run on. |
TimeZoneId | string? | IANA zone id; null means UTC. |
CatchUp | CatchUpPolicy | Skip (mint nothing for missed ticks, the default) or Coalesce (one make-up run). |
NoOverlap | bool | When true, skip a new tick while a previous instance is still running. |
Cursor | DateTimeOffset | The instant up to which due ticks are resolved, inclusive. |
NextDue | DateTimeOffset? | The next tick that will mint; null if none, or if the schedule is unresolvable. |
HasLiveInstance | bool | Whether a minted instance is currently non-terminal — what No-Overlap watches. |
SkippedTicks | IReadOnlyList<DateTimeOffset> | Recently skipped ticks, newest last, bounded. |
Error | string? | Non-null when the schedule cannot be resolved on this host. |
The Error field is the one to read carefully when you build schedule alerts. A schedule whose cron no longer parses, or whose time-zone id is absent on this host, is returned with Error set, not dropped and not thrown. Check Error, do not rely on exceptions.
foreach (var s in await monitor.ListSchedulesAsync())
{
if (s.Error is not null)
Alert($"Schedule {s.ScheduleId} unresolvable: {s.Error}");
// else inspect s.NextDue, s.Cursor, s.HasLiveInstance, s.SkippedTicks
}The cron grammar, time zones, Catch-Up, and No-Overlap are covered on Scheduling.
Observer delivery health#
Two reads report the health of a Transition Observer — host-supplied, egress-only code BackWave invokes when a job reaches a declared state, the push twin of the Monitor's pull.
GetObserverCursorAsync returns one observer's durable delivery cursor: the global timeline position up to and including which every matching transition has been delivered or dead-lettered. Use it to gauge how far behind an observer is. It returns -1 when the observer has no cursor yet; a brand-new observer and an unknown observer id are indistinguishable, both reading -1.
ListObserverDeadLettersAsync returns the observer's dead-lettered deliveries — notifications that exhausted their own retry ceiling — oldest first, and empty for both a healthy observer and an unknown id. Each ObserverDeadLetterRecord is metadata only: a (Position, JobId, Ordinal, State, Attempt, DeliveryAttempts, DeadLetteredAt) tuple, never payload or Failure Detail. DeliveryAttempts is the delivery's own retry count, distinct from the job's Attempt.
var cursor = await monitor.GetObserverCursorAsync("billing-observer"); // -1 = none yet / unknown
var deadLet = await monitor.ListObserverDeadLettersAsync("billing-observer");A dead-lettered delivery is a different object from a DeadLettered job; they share only the "exhausted its ceiling, recorded loudly" shape.
Dependency edges#
GetDependencyEdgesAsync returns the dependency edges around one job as a DependencyEdges of (GatingParents, Children). GatingParents is the set still gating the job, and Children is the jobs waiting on it. Both sides are empty for a job with no dependencies and for an unknown job; there is no null or throw.
The parent side is the set still gating the job, not its full original parent list — a parent drops off the moment it completes or terminates. So an empty GatingParents can mean "this job never had parents" or "all of its parents are done." Read it as the remaining blockers, not the history.
Workflows (Pro)#
The free Monitor exposes no Workflow surface. With the BackWave Pro package referenced, two reads light up as extension methods on the same BackWaveMonitor instance you already inject: ListWorkflowsAsync returns every Workflow oldest-first with member count and a derived status, and GetWorkflowAsync returns one Workflow's full graph — its members as JobSnapshots, its structural edges, and its derived status — or null if no such Workflow. Referencing the package is the entire boundary; the license state never changes whether they run. The Workflows surface these power is covered under The Dashboard.
Where to go next#
- The Dashboard: the human-facing twin these reads render, plus the operator actions that write, the permission gates, and the
ViewSensitiveDatasensitive-data gate a custom consumer must honor itself. - Tags: Labels, Keyed Tags, and the predicate and faceting semantics behind
ListJobsAsyncandGetTagFacetAsync. - Scheduling: cron, time zones, Catch-Up, and No-Overlap behind
ScheduleStatus.