Skip to content

Tracing & metrics

effect-mq is built on Effect's tracer and Metric primitives, so observability is wiring: attach any Effect-compatible tracer/exporter and spans and instruments flow into your existing stack. Without one, the overhead is negligible.

Tracing across processes

Producer → handler traces connect across processes and storage. When you enqueue inside a span, the store persists that span's context (traceId, spanId, sampled) on the job record. When a worker later claims the job (possibly hours later, on another machine), it wraps the handler run in a span attached to that external context via Tracer.externalSpan. Your invite handler appears in the same trace as the HTTP request that scheduled it.

The run span is named `${name}.run` by default and carries three attributes:

AttributeValue
effectMqJobIdthe job's id
effectMqQueuethe queue it ran on
effectMqAttemptthe attempt number (1-based)

Rename it per worker when your backend has naming conventions:

ts
import { Worker } from "effect-mq"

const WorkerLive = Worker.layer({
  handlerSpanName: (ctx) => `job ${ctx.name} #${ctx.attempt}`
})

Producer verbs (enqueue, poll, cancel, schedule, ...) already run in their own spans. The worker's claim-loop iterations carry no spans: the handler run is the meaningful trace unit, and per-claim spans would flood your backend.

How the handler span attaches

A five-day-delayed job as a child of its producer span makes a five-day-wide trace: it renders badly and defeats tail sampling. The attachment mode follows the delay instead, controlled by Worker.layer({ traceLinking }):

ModeBehavior
"auto" (default)immediate enqueues continue the producer trace (parent-child); explicitly delayed/at-scheduled jobs start their own trace with a causal span link back
"parent"always parent-child
"link"always a span link
"none"spans and attributes only, no cross-trace edge

Two properties make "auto" deterministic:

  • The delayed/immediate split keys off scheduling intent captured at enqueue (did the producer pass delay/at), not off when the job ran. Queue backlog never changes your trace shapes.
  • The store persists the flag on the record, so every retry attempt keeps its mode: attempt 3 of an immediate job is still a child of the producer span.

Failure logs

Failed runs also reach your logs: the worker emits logWarning for attempts that will retry and logError for terminal failures, annotated with effectMqJobId, effectMqQueue, and effectMqAttempt. Alert on the error level and you catch every terminally failed job, stall exhaustion included. For structured reporting beyond logs, see onJobFailure.

Metrics

Workers and producers emit Effect Metric instruments, exported as the Metrics module from effect-mq. They are process-local operational signal, not persisted state: they live in the emitting process's metric registry, and you export them with whatever your app already runs (the Otlp modules from effect/unstable/observability, @effect/opentelemetry, a Prometheus scraper). Retention lives in that backend.

The durable analogues stay in the store: store.counts() for live depth and the attempts ledger for per-run history, both queryable forever.

InstrumentMetric nameTypeTags
jobsEnqueuedeffect_mq_jobs_enqueuedcountername, queue, duplicate
jobRunseffect_mq_job_runscountername, queue, outcome (completed | retried | failed | cancelled | released | fanned-out)
jobRunDurationeffect_mq_job_run_duration_mshistogramname, queue, outcome
jobWaitDurationeffect_mq_job_wait_duration_mshistogramname, queue
claimseffect_mq_claimscounterqueue, result (claimed | empty)
jobsInFlighteffect_mq_jobs_in_flightgaugequeue
queueDeptheffect_mq_queue_depthgaugequeue, state
locksLosteffect_mq_locks_lostcounternone
cancelInterruptseffect_mq_cancel_interruptscounternone
stalledRecoveredeffect_mq_stalled_recoveredcounteroutcome (requeued | failed)
scheduleTickseffect_mq_schedule_tickscountername
flowFanOutseffect_mq_flow_fanoutscounterflow
flowChildReportseffect_mq_flow_child_reportscounterflow, outcome, source (report | reconcile)
flowCascadeseffect_mq_flow_cascadescounternone
flowOutboxSkippedeffect_mq_flow_outbox_skippedcounternone

All tags are low-cardinality attributes: job name, queue, outcome. Ids and keys never appear as tags.

A few of these deserve reading notes:

  • job_wait_duration_ms is the queue-latency headline: time between a job becoming runnable (past its runAt) and its claim.
  • job_run_duration_ms measures claim to ack for a single run, so it includes payload decode and the ack round trip on top of handler time.
  • job_runs{outcome="released"} counts jobs handed back on graceful shutdown; they consumed no attempt.
  • locks_lost counts locks found gone at heartbeat renewal; each one means a job may run twice, so a nonzero rate is worth an alert.
  • A high claims{result="empty"} ratio means takers outnumber work: lower concurrency or consolidate workers.

Queue depth is opt-in

queue_depth samples store.counts() per registered queue, which costs one store query per queue per tick. Turn it on with a cadence:

ts
const WorkerLive = Worker.layer({ queueMetricsInterval: "15 seconds" })

Reading instruments directly

Because the module is exported, dashboards and tests can read the same instruments the runtime writes:

ts
import { Metrics } from "effect-mq"
import { Effect, Metric } from "effect"

const failedRuns = Effect.gen(function*() {
  const state = yield* Metric.value(Metrics.jobRuns.pipe(
    Metric.withAttributes({ name: "SendEmail", queue: "email", outcome: "failed" })
  ))
  return state.count
})

TIP

Metric names are stable, dashboard-ready strings. Build your Grafana/Prometheus queries against the effect_mq_* names in the table above.

Where to next