# The Monitor API

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](/docs/dashboard-operations/overview#what-operators-can-do).

## 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.

```csharp title="QueueAlertService.cs"
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](#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.

```csharp title="InspectJob.cs"
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 values `1`, `true`, `yes`, `on`, case-insensitive) suppresses it at recording time for PII-sensitive hosts, so `JobTransition.FailureDetail` reads `null`. This is a recording switch and is distinct from the dashboard's `BACKWAVE_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 `ViewSensitiveData` permission gates the equivalent screens; in your own consumer, that gate is your responsibility. Authorize before you call.

```csharp title="RenderPayload.cs"
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.

```csharp title="FilterJobs.cs"
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.

```csharp title="PageAllDeadLettered.cs"
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.

```csharp title="TagFacet.cs"
// 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.

```csharp title="QueueHealth.cs"
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.

```csharp title="HealthRollup.cs"
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](/docs/dashboard-operations/overview).

## 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.

```csharp title="ScheduleAlerts.cs"
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](/docs/core-concepts/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`.

```csharp title="ObserverHealth.cs"
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 `JobSnapshot`s, 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](/docs/dashboard-operations/overview#pro-workflows-on-the-dashboard).

## Where to go next

- [The Dashboard](/docs/dashboard-operations/overview): the human-facing twin these reads render, plus the operator actions that write, the permission gates, and the `ViewSensitiveData` sensitive-data gate a custom consumer must honor itself.
- [Tags](/docs/core-concepts/tags): Labels, Keyed Tags, and the predicate and faceting semantics behind `ListJobsAsync` and `GetTagFacetAsync`.
- [Scheduling](/docs/core-concepts/scheduling): cron, time zones, Catch-Up, and No-Overlap behind `ScheduleStatus`.
