Telemetry Reference
The OpenTelemetry identifiers BackWave emits across all three pillars: the source names, every metric instrument, the send/receive/process spans, the log event-id catalog, and every tag key.
BackWave emits all three OpenTelemetry pillars, traces, metrics, and logs, on the OpenTelemetry semantic conventions. It builds on the base class library only, so spans, measurements, and log calls cost nothing until you subscribe a tracer, meter, or logging provider to the source. The job lifecycle follows the OpenTelemetry messaging conventions and each storage adapter's store round-trips follow the db conventions. This page lists every identifier BackWave emits, byte-for-byte, so you can wire it into your collector by name. For the wiring itself and the semantics behind these tables, see Wire Up OpenTelemetry.
The source names#
Job-lifecycle traces and metrics publish under a single name, "BackWave". Each storage adapter
publishes its store spans and store-fault meter under its own name, and only when you opt that
adapter in.
| Source name | Emits | Constant |
|---|---|---|
BackWave | Job-lifecycle spans, the Core metric instruments, the log catalog | BackWaveDiagnostics.SourceName |
BackWave.Postgres | Postgres store spans and store-fault meter | - |
BackWave.SqlServer | SQL Server store spans and store-fault meter | - |
BackWave.Sqlite | SQLite store spans and store-fault meter | - |
BackWave.Oracle | Oracle store spans and store-fault meter | - |
The BackWave value is exactly that, with a capital B and a capital W, and is exposed as the
public constant BackWaveDiagnostics.SourceName so you can subscribe by constant rather than by
literal. The scope version stamped on the source is the package's informational version. The
BackWave.OpenTelemetry package wraps each name in a named subscription method; the by-name path
(AddSource/AddMeter with these strings) is equivalent. For Oracle that method is
AddBackWaveOracleInstrumentation(), on both TracerProviderBuilder and MeterProviderBuilder.
Metrics#
BackWave publishes sixteen Core instruments on the meter named "BackWave", plus one store-fault
counter per adapter on that adapter's own meter. Units are curly-brace annotation units and appear
verbatim on the instrument. The two throughput counters and the process-duration histogram carry
messaging-convention names; the rest are backwave.*-owned.
Core instruments#
| Instrument | Type | Unit | Meaning |
|---|---|---|---|
messaging.client.sent.messages | Counter<long> | {message} | Jobs accepted by enqueue. |
messaging.client.consumed.messages | Counter<long> | {message} | Executions that succeeded. |
backwave.jobs.failed | Counter<long> | {job} | Failed Attempts, retried and Dead-Lettered alike. Carries error.type. |
backwave.job.attempts | Counter<long> | {attempt} | Attempts started, one per claimed job. |
backwave.jobs.dead_lettered | Counter<long> | {job} | Jobs that exhausted their Attempt ceiling and were Dead-Lettered. |
backwave.observer.deliveries.attempted | Counter<long> | {delivery} | Observer callback invocations started. |
backwave.observer.deliveries.succeeded | Counter<long> | {delivery} | Observer callbacks that returned without throwing. |
backwave.observer.deliveries.dead_lettered | Counter<long> | {delivery} | Observer deliveries that exhausted their ceiling and were dead-lettered. |
messaging.process.duration | Histogram<double> | s | Handler execution time, on success and failure. |
backwave.schedule.delay | Histogram<double> | s | How late execution started against the job's Due Time. |
backwave.job.queue.wait | Histogram<double> | s | Due Time to claim, clamped at zero. |
backwave.observer.dispatch.duration | Histogram<double> | s | Observer callback dispatch time. |
backwave.worker.slots.active | UpDownCounter<long> | {slot} | Slots currently occupied by running jobs. |
backwave.queue.depth | ObservableGauge<long> | {job} | Point-in-time job counts by Queue and state. |
backwave.worker.slots.capacity | ObservableGauge<long> | {slot} | Configured slot capacity per consumer group. |
backwave.invariant.violations | Counter<long> | {violation} | Impossible states BackWave detected, tagged by trigger and by whether it halted or degraded. |
The segment is singular in backwave.job.attempts and backwave.job.queue.wait but plural in
backwave.jobs.failed and backwave.jobs.dead_lettered; both spellings are stable identifiers.
Histogram buckets#
BackWave advises explicit bucket boundaries on its four histograms. Two schemes are in play, and they do not share a ceiling.
| Histograms | Bucket boundaries (seconds) | Ceiling |
|---|---|---|
messaging.process.duration, backwave.observer.dispatch.duration | 0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30 | 30 seconds |
backwave.schedule.delay, backwave.job.queue.wait | 0.001, 0.01, 0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 300, 3600 | 1 hour |
The wider scheme reaches an hour because scheduling delay and queue wait routinely run to minutes or longer, whereas an execution or an observer dispatch measured beyond thirty seconds is already an outlier. These bucket boundaries take effect on .NET 9 and later only; on .NET 8 all four histograms fall back to the SDK default buckets.
Counter semantics#
The job counters relate in a way that trips up dashboards if you assume the wrong invariant. The
correct one is attempts ≥ consumed + failed, and the gap is in-flight and cancelled work. A
handler-thrown OperationCanceledException increments backwave.jobs.failed, but a
Shell-requested cancellation, where BackWave asks a running job to stop, increments neither the
consumed nor the failed counter and records no messaging.process.duration.
backwave.jobs.failed carries error.type and lumps together failures that will be retried and
failures that were terminally Dead-Lettered. backwave.jobs.dead_lettered counts only the
retry-exhausted terminal subset, so it is a subset of the failure count, not a parallel total. To
read how many jobs are Dead-Lettered right now, filter the backwave.queue.depth gauge to
backwave.state = DeadLettered rather than reaching for a counter. Note that
backwave.observer.deliveries.dead_lettered is about Transition Observer
deliveries, not jobs; do not conflate the two.
Queue-depth gauge#
backwave.queue.depth reports current job counts as a point-in-time gauge, one measurement per
Queue-and-state pair. It keeps the pre-rename tag keys: each measurement carries backwave.queue,
the Queue name, and backwave.state, the job state as a string.
backwave.state value | Meaning |
|---|---|
Scheduled | Waiting for its Due Time. |
AwaitingParent | Held until a parent job completes. |
Leased | Claimed by a Worker and executing. |
Succeeded | Completed successfully. |
Cancelled | Cancelled before or during execution. |
DeadLettered | Exhausted its Attempt ceiling after running and failing. |
Quarantined | Could not be routed or decoded. |
Under the hosting package this gauge is fed for you by a background service that snapshots the store on a cadence, so it updates at roughly that granularity rather than in real time, and a failed depth query retains the last good snapshot rather than halting anything.
Registering a depth source#
The gauge is not fed automatically outside the hosting package. RegisterQueueDepthSource takes a
callback that returns the current per-Queue, per-state counts and returns an IDisposable;
disposing that handle removes the source so it stops contributing to the gauge.
// public static IDisposable RegisterQueueDepthSource(Func<IReadOnlyList<QueueStateCount>> source)
IDisposable registration = BackWaveDiagnostics.RegisterQueueDepthSource(
() => currentQueueStateCounts);
// Later, stop contributing to backwave.queue.depth.
registration.Dispose();The callback runs each time the gauge is observed and returns an IReadOnlyList<QueueStateCount>.
QueueStateCount is a record with three positional members.
| Member | Type | Meaning |
|---|---|---|
Queue | string | The Queue the count is for. |
State | JobState | The state the count is for. |
Count | int | Number of jobs in that Queue and state. |
Register one source per store. Registering two Worker Groups that share a store would double-count that store's depths, and BackWave does not deduplicate for you.
Saturation gauges#
backwave.worker.slots.active (an UpDownCounter) and backwave.worker.slots.capacity (a gauge)
both carry messaging.consumer.group.name, so you join them by consumer group for headroom.
worker.slots.capacity accumulates per consumer group across pumps: a group running four pumps of
twenty slots each reports eighty, not twenty. A headroom panel that assumes per-pump capacity will
read saturated at a fraction of true capacity. worker.slots.active additionally carries the
destination tags.
Observer counters#
The three observer counters and the dispatch histogram always carry backwave.observer_id, the
attribution for which observer a delivery belongs to. The attempted and succeeded series may
additionally carry backwave.wire_name and backwave.queue when both are cheaply available at the
delivery edge; the dead_lettered series carries backwave.observer_id only. Do not write queries
that expect the Wire Name or Queue on the dead-letter series.
Invariant violations#
backwave.invariant.violations increments once per always-on check that fails. It carries
backwave.invariant.trigger, the InvariantTrigger member name, and backwave.invariant.action,
which is Halt when the check threw and Degrade when it logged and continued. A Halt
raised inside a Worker Group pairs with log event 2001. One raised on the enqueue path or in a
dependency read propagates to the caller instead. A 2001 whose trigger reads unclassified has
no counter measurement. A Degrade measurement pairs with 2003 inside a Worker Group or 1601
elsewhere. The trigger set is listed on
Always-On Assertions & Fail-Stop.
Store-fault meter (per adapter)#
This instrument is not a Core instrument. Each storage adapter defines its own copy on its own
meter, and it arrives only when you opt that adapter in with its AddBackWave{Postgres|SqlServer|Sqlite|Oracle}Instrumentation()
method. Until then you get no store-fault measurements at all.
| Instrument | Type | Unit | Tag |
|---|---|---|---|
backwave.store.faults | Counter<long> | {fault} | backwave.store.fault_kind = transient or terminal |
Traces#
BackWave starts three job-lifecycle activities on the activity source named "BackWave", following
the OpenTelemetry messaging conventions. Each storage adapter starts its own db-convention span, on
its own source, covered under Store spans below.
| Operation name | Kind | Display name | When it fires |
|---|---|---|---|
send | Producer | send {queue} | A job is accepted by enqueue. Its context becomes the job's trace correlation. |
send (workflow root) | Producer | send {workflow-name} (or send workflow) | A Workflow is enqueued. Parents the per-member send spans and closes immediately. |
send (per member) | Producer | send {queue} | Each workflow member is enqueued. Child of the workflow root. |
receive | Client | receive | One claim round-trip against the store. Its display name is the literal receive, with no interpolation. |
process | Consumer | process {queue} | One Handler execution. |
Correlation#
When BackWave accepts a job it captures the current W3C trace context and stores it with the job.
At execution the process span is started as a forced root and correlated back to its
originating send context with an ActivityLink, not a parent edge. A process span can hold
more than one link: a workflow member links to its own send context and to one context per fan-in
ancestor, so a fan-in step links to every upstream step it depends on. A job enqueued with no
ambient trace still starts its process span as a clean root and is never parented to whatever
Activity happens to be current on the Worker. Trace queries must follow the links rather than walk
a parent chain from enqueue to execution.
The stored context has two parts, and both parts survive the hop. BackWave stores the traceparent
and the tracestate, and the link on the process span restores both. Vendor routing and sampling
state therefore reach the Worker. If a captured tracestate is too large for the store column that
holds it, BackWave drops the tracestate and keeps the traceparent. An enqueue never fails
because of the size of a tracestate.
The enqueue call site#
The send span records where the enqueue call came from. Three tags carry it:
code.function.name, code.file.path, and code.line.number.
The compiler supplies these values at the call site, so BackWave reads no stack trace. The tags cost nothing at run time and stay correct under Native AOT. The testing harness passes the call site through, so a span from a test names the test rather than the harness.
Span status#
The process span records the outcome of the handler.
| Handler outcome | Span status | Extras | Counter |
|---|---|---|---|
| Returned normally | Ok | - | messaging.client.consumed.messages |
Threw OperationCanceledException | Error | error.type, exception event | backwave.jobs.failed |
| Threw any other exception | Error | error.type, exception event | backwave.jobs.failed |
| Halted the Worker Group on an invariant violation | Error | error.type, exception event | Neither consumed nor failed |
| Shell-requested cancellation | Error, description cancelled | - | Neither consumed nor failed |
A handler-thrown OperationCanceledException is a failure, exactly like any other thrown
exception. Only a Shell-requested cancellation, where BackWave asks the running job to stop, is
neither consumed nor failed. The send and receive spans set no explicit status; they simply end.
A process span settles when the batched outcome write that carries its row returns, on the
row as the Worker Group applied it. It settles even when that write faulted. An outcome the
Worker Group rewrote as a dead-letter (an over-cap output or an over-long tag) settles its span
on that rewritten row.
Store spans#
Each adapter emits one Client-kind span per store round-trip, on the adapter's own source, and
only when you opt the adapter in. These spans are not force-rooted: they inherit the ambient Core
span (a send or receive) when there is one, or root under the background sweep otherwise.
| Adapter | Source name | db.system |
|---|---|---|
| Postgres | BackWave.Postgres | postgresql |
| SQL Server | BackWave.SqlServer | mssql |
| SQLite | BackWave.Sqlite | sqlite |
| Oracle | BackWave.Oracle | oracle |
All four share the same shape: ActivityKind.Client, a display name of {operation} {collection},
and tags db.system, db.operation.name (one of claim, enqueue, report_outcomes, expire_leases,
or relinquish_leases), and db.collection.name (the effective jobs table, honoring a custom schema or
table prefix). On a store fault the span records error.type and an Error status.
Structured logs#
BackWave's lifecycle events are emitted through ILogger with stable event ids, which the
OpenTelemetry SDK auto-bridges into OTel Logs. Each event is emitted inside a log scope that
stamps exactly four keys: job_id, wire_name, attempt, and queue. Turn on IncludeScopes in
the logging pipeline to carry them through to your backend.
The event ids group by concern: 10xx covers enqueue and claim, 11xx execution, 12xx
settlement, 13xx store and schema, 14xx observers, 15xx Wake-Up Hints, and 16xx
invariants, all in the Core catalog. The Hosting
package adds an operational catalog at 20xx and 21xx, and BackWave Pro adds a workflow catalog
at 30xx.
Core catalog#
| Event id | Event | Level | Meaning |
|---|---|---|---|
| 1001 | JobEnqueued | Debug | A job was accepted by enqueue. |
| 1101 | LeaseAcquired | Trace | A Worker claimed a lease on a job. |
| 1102 | ExecutionStarted | Debug | Handler execution began. |
| 1103 | ExecutionCompleted | Debug | Handler execution completed. |
| 1201 | RetryScheduled | Information | A failed Attempt was scheduled for retry. |
| 1202 | LeaseLost | Warning | A lease was lost mid-execution. |
| 1203 | DeadLettered | Error | A job exhausted its Attempt ceiling and was Dead-Lettered. |
| 1204 | LeasesReclaimed | Information | Expired leases were reclaimed for re-execution. |
| 1205 | LeasesRelinquished | Information | Worker group '{worker_group}' relinquished {relinquished_count} lease(s) on shutdown. |
| 1206 | ShutdownHandBackFailed | Warning | Worker group '{worker_group}' could not give its work back on shutdown; its leases will lapse instead. |
| 1207 | ShutdownDrainIncomplete | Warning | Worker group '{worker_group}' still had {running_count} execution(s) running when its shutdown budget ran out; relinquishing their leases anyway. Emitted only when the count is above zero. |
| 1208 | OutcomeFencedOut | Debug | Outcome for job {job_id} (attempt {attempt}) was fenced out by the store; its lease was no longer this worker's to report on. |
| 1301 | StoreFaultTransientRetry | Warning | A transient store fault was retried. |
| 1302 | MigrationApplied | Information | A schema migration was applied. Opt-in; off by default. |
| 1303 | SchemaNewerThanAdapter | Warning | BackWave schema version {deployed_version} is newer than the {expected_version} this adapter requires; continuing on the additive schema until this node is upgraded. |
| 1401 | ObserverDeliveryDeadLettered | Warning | An observer delivery exhausted its ceiling. |
| 1501 | WakeHintChannelUnavailable | Warning | Wake-Up Hint channel for {db_system} is unavailable; falling back to polling until it recovers. Emitted once per outage. |
| 1601 | InvariantDegraded | Warning | BackWave invariant '{invariant_trigger}' tripped: {detail}. The operation continues degraded. |
Hosting operational catalog#
| Event id | Event | Level | Meaning |
|---|---|---|---|
| 2001 | WorkerGroupFailStopped | Critical | BackWave Worker Group '{worker_group}' fail-stopped on an invariant violation ({invariant_trigger}); the group is halted. The trigger reads unclassified when no named check raised it. |
| 2002 | JobOutputRejected | Error | BackWave Worker Group '{worker_group}' job {job_id} produced a Job Output of {actual_bytes} bytes, which exceeds the store's {max_output_bytes}-byte bound; the job is dead-lettered and the group keeps running. Store a reference (id, blob key) instead of the data itself. |
| 2003 | WorkerGroupDegradedByInvariant | Warning | BackWave Worker Group '{worker_group}' tripped invariant '{invariant_trigger}': {detail}. The group keeps running, degraded. |
| 2004 | JobTagRejected | Error | BackWave Worker Group '{worker_group}' job {job_id} added a Job Tag with a {key_length}-character key and a {value_length}-character value, which exceeds the store's MaxTagKeyLength {max_tag_key_length} / MaxTagValueLength {max_tag_value_length} bound; the job is dead-lettered and the group keeps running. Tags are rejected, never truncated. |
| 2101 | ObserverPumpFaulted | Error | The observer pump faulted. |
| 2102 | ObserverClaimFaulted | Warning | An observer claim faulted. |
| 2103 | ObserverReportFaulted | Warning | An observer report faulted. |
| 2104 | ObserverCallbackFaulted | Warning | An observer callback faulted. |
| 2105 | ObserverCallbackTimedOut | Error | An observer callback timed out. |
| 2106 | ObserverLeakedCallbackFaulted | Error | A leaked observer callback faulted after its delivery returned. |
Pro workflow catalog#
| Event id | Event | Level | Meaning |
|---|---|---|---|
| 3001 | GateDecided | Information | A workflow gate reached a decision. |
The MigrationApplied event (1302) is the one exception to "it just works": it emits only when the
store's options carry a LoggerFactory and AutoMigrate is on. LoggerFactory defaults to null,
so a default host never emits it. See Wire Up OpenTelemetry
for the wiring.
Attribute keys#
The job-lifecycle spans and metrics use the messaging-convention keys; the observer instruments,
the queue-depth gauge, and the store spans keep or add their own backwave.* and db.* keys. The
lifecycle metrics carry messaging.system, messaging.destination.name, and
messaging.destination.template (not messaging.message.id, to hold cardinality down), with
error.type added on the failure paths.
| Key | Value | Appears on |
|---|---|---|
messaging.system | backwave | Job-lifecycle spans and metrics. |
messaging.destination.name | The job's Queue | Job-lifecycle spans and metrics. |
messaging.destination.template | The job's Wire Name | Job-lifecycle spans and metrics. |
messaging.message.id | The job's id | send and process spans (not the metrics). |
messaging.operation.name | The operation string | Job-lifecycle spans and metrics. |
messaging.operation.type | The operation string (currently the same value as .name) | Job-lifecycle spans and metrics. |
messaging.consumer.group.name | The Worker Group | process span; the slot gauges. |
code.function.name | The method that called enqueue | send span. |
code.file.path | The source file of that call | send span. |
code.line.number | The line of that call | send span. |
backwave.worker_id | The Worker id | receive span. |
backwave.claimed_count | Number of jobs claimed | receive span, on completion. |
backwave.attempt | The job's Attempt number | process span. |
backwave.workflow.name | The Workflow name | Workflow-root send span. |
backwave.workflow.member_count | Member count | Workflow-root send span. |
backwave.workflow.append | Whether the enqueue appends to an existing Workflow | Workflow-root send span. |
backwave.workflow.after | The member's upstream dependencies | Member process span. |
error.type | The exception type | Failed counter, failed process-duration, process span, store-fault span. |
backwave.queue | The Queue name | Queue-depth gauge; observer counters when available. |
backwave.state | The job state name | Queue-depth gauge. |
backwave.wire_name | The Wire Name | Observer counters when available. |
backwave.observer_id | The observer id | Observer counters and dispatch histogram, always. |
backwave.store.fault_kind | transient or terminal | backwave.store.faults (per adapter). |
backwave.invariant.trigger | The InvariantTrigger member name | backwave.invariant.violations. |
backwave.invariant.action | Halt or Degrade | backwave.invariant.violations. |
db.system | postgresql / mssql / sqlite / oracle | Store spans. |
db.operation.name | claim / enqueue / report_outcomes / expire_leases / relinquish_leases | Store spans. |
db.collection.name | The effective jobs table | Store spans. |
The backwave.wire_name and backwave.queue keys survive only on the observer instruments and the
queue-depth gauge. On the job-lifecycle spans and metrics they were renamed to
messaging.destination.template and messaging.destination.name, so do not treat either old key as
present everywhere.
Convention stability#
The job-lifecycle spans and metrics borrow the OpenTelemetry messaging conventions, which are
still at Development stability upstream, so a future semantic-conventions release may rename some
messaging.* attributes. Only the backwave.*-owned names carry BackWave's stability promise; treat
the messaging.* keys as tracking an upstream spec rather than as forever-stable. The three code.*
keys on the send span come from the OpenTelemetry code conventions and carry the same caveat.
Where to go next#
- Wire Up OpenTelemetry: the wiring, the exemplars tip, and the Aspire path.
- Observers API: Transition Observers, the source of the observer delivery counters.
- Job States & Transitions: the states that appear as
backwave.stateon the queue-depth gauge. - Execution Model: claiming, leasing, and Attempts, which back the
receivespan and the Attempt counter. - Queues: the Queue every span and metric is tagged by.
Found a problem on this page? Report an issue