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 nameEmitsConstant
BackWaveJob-lifecycle spans, the Core metric instruments, the log catalogBackWaveDiagnostics.SourceName
BackWave.PostgresPostgres store spans and store-fault meter-
BackWave.SqlServerSQL Server store spans and store-fault meter-
BackWave.SqliteSQLite 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.

Metrics#

BackWave publishes fifteen 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#

InstrumentTypeUnitMeaning
messaging.client.sent.messagesCounter<long>{message}Jobs accepted by enqueue.
messaging.client.consumed.messagesCounter<long>{message}Executions that succeeded.
backwave.jobs.failedCounter<long>{job}Failed Attempts, retried and Dead-Lettered alike. Carries error.type.
backwave.job.attemptsCounter<long>{attempt}Attempts started, one per claimed job.
backwave.jobs.dead_letteredCounter<long>{job}Jobs that exhausted their Attempt ceiling and were Dead-Lettered.
backwave.observer.deliveries.attemptedCounter<long>{delivery}Observer callback invocations started.
backwave.observer.deliveries.succeededCounter<long>{delivery}Observer callbacks that returned without throwing.
backwave.observer.deliveries.dead_letteredCounter<long>{delivery}Observer deliveries that exhausted their ceiling and were dead-lettered.
messaging.process.durationHistogram<double>sHandler execution time, on success and failure.
backwave.schedule.delayHistogram<double>sHow late execution started against the job's Due Time.
backwave.job.queue.waitHistogram<double>sDue Time to claim, clamped at zero.
backwave.observer.dispatch.durationHistogram<double>sObserver callback dispatch time.
backwave.worker.slots.activeUpDownCounter<long>{slot}Slots currently occupied by running jobs.
backwave.queue.depthObservableGauge<long>{job}Point-in-time job counts by Queue and state.
backwave.worker.slots.capacityObservableGauge<long>{slot}Configured slot capacity per consumer group.

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.

HistogramsBucket boundaries (seconds)Ceiling
messaging.process.duration, backwave.observer.dispatch.duration0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 3030 seconds
backwave.schedule.delay, backwave.job.queue.wait0.001, 0.01, 0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 300, 36001 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 valueMeaning
ScheduledWaiting for its Due Time.
AwaitingParentHeld until a parent job completes.
LeasedClaimed by a Worker and executing.
SucceededCompleted successfully.
CancelledCancelled before or during execution.
DeadLetteredExhausted its Attempt ceiling after running and failing.
QuarantinedCould 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.

RegisterDepth.cs
// 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.

MemberTypeMeaning
QueuestringThe Queue the count is for.
StateJobStateThe state the count is for.
CountintNumber 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.

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}Instrumentation() method. Until then you get no store-fault measurements at all.

InstrumentTypeUnitTag
backwave.store.faultsCounter<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 nameKindDisplay nameWhen it fires
sendProducersend {queue}A job is accepted by enqueue. Its context becomes the job's trace correlation.
send (workflow root)Producersend {workflow-name} (or send workflow)A Workflow is enqueued. Parents the per-member send spans and closes immediately.
send (per member)Producersend {queue}Each workflow member is enqueued. Child of the workflow root.
receiveClientreceiveOne claim round-trip against the store. Its display name is the literal receive, with no interpolation.
processConsumerprocess {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 outcomeSpan statusExtrasCounter
Returned normallyOk-messaging.client.consumed.messages
Threw OperationCanceledExceptionErrorerror.type, exception eventbackwave.jobs.failed
Threw any other exceptionErrorerror.type, exception eventbackwave.jobs.failed
Shell-requested cancellation(span ends without an error status)-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.

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.

AdapterSource namedb.system
PostgresBackWave.Postgrespostgresql
SQL ServerBackWave.SqlServermssql
SQLiteBackWave.Sqlitesqlite

All three share the same shape: ActivityKind.Client, a display name of {operation} {collection}, and tags db.system, db.operation.name (one of claim, enqueue, complete, fail, or expire_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, and 14xx observers, 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 idEventLevelMeaning
1001JobEnqueuedDebugA job was accepted by enqueue.
1101LeaseAcquiredTraceA Worker claimed a lease on a job.
1102ExecutionStartedDebugHandler execution began.
1103ExecutionCompletedDebugHandler execution completed.
1201RetryScheduledInformationA failed Attempt was scheduled for retry.
1202LeaseLostWarningA lease was lost mid-execution.
1203DeadLetteredErrorA job exhausted its Attempt ceiling and was Dead-Lettered.
1204LeasesReclaimedInformationExpired leases were reclaimed for re-execution.
1301StoreFaultTransientRetryWarningA transient store fault was retried.
1302MigrationAppliedInformationA schema migration was applied. Opt-in; off by default.
1401ObserverDeliveryDeadLetteredWarningAn observer delivery exhausted its ceiling.

Hosting operational catalog#

Event idEventLevelMeaning
2001WorkerGroupFailStoppedCriticalA Worker Group fail-stopped.
2101ObserverPumpFaultedErrorThe observer pump faulted.
2102ObserverClaimFaultedWarningAn observer claim faulted.
2103ObserverReportFaultedWarningAn observer report faulted.
2104ObserverCallbackFaultedWarningAn observer callback faulted.
2105ObserverCallbackTimedOutErrorAn observer callback timed out.
2106ObserverLeakedCallbackFaultedErrorA leaked observer callback faulted after its delivery returned.

Pro workflow catalog#

Event idEventLevelMeaning
3001GateDecidedInformationA 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.

KeyValueAppears on
messaging.systembackwaveJob-lifecycle spans and metrics.
messaging.destination.nameThe job's QueueJob-lifecycle spans and metrics.
messaging.destination.templateThe job's Wire NameJob-lifecycle spans and metrics.
messaging.message.idThe job's idsend and process spans (not the metrics).
messaging.operation.nameThe operation stringJob-lifecycle spans and metrics.
messaging.operation.typeThe operation string (currently the same value as .name)Job-lifecycle spans and metrics.
messaging.consumer.group.nameThe Worker Groupprocess span; the slot gauges.
code.function.nameThe method that called enqueuesend span.
code.file.pathThe source file of that callsend span.
code.line.numberThe line of that callsend span.
backwave.worker_idThe Worker idreceive span.
backwave.claimed_countNumber of jobs claimedreceive span, on completion.
backwave.attemptThe job's Attempt numberprocess span.
backwave.workflow.nameThe Workflow nameWorkflow-root send span.
backwave.workflow.member_countMember countWorkflow-root send span.
backwave.workflow.appendWhether the enqueue appends to an existing WorkflowWorkflow-root send span.
backwave.workflow.afterThe member's upstream dependenciesMember process span.
error.typeThe exception typeFailed counter, failed process-duration, process span, store-fault span.
backwave.queueThe Queue nameQueue-depth gauge; observer counters when available.
backwave.stateThe job state nameQueue-depth gauge.
backwave.wire_nameThe Wire NameObserver counters when available.
backwave.observer_idThe observer idObserver counters and dispatch histogram, always.
backwave.store.fault_kindtransient or terminalbackwave.store.faults (per adapter).
db.systempostgresql / mssql / sqliteStore spans.
db.operation.nameclaim / enqueue / complete / fail / expire_leasesStore spans.
db.collection.nameThe effective jobs tableStore 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.state on the queue-depth gauge.
  • Execution Model: claiming, leasing, and Attempts, which back the receive span and the Attempt counter.
  • Queues: the Queue every span and metric is tagged by.

Found a problem on this page? Report an issue