Observability¶
OpenTelemetry helpers and structlog setup.
obs ¶
Vendor-neutral observability bootstrap.
The library never imports vendor SDKs (Sentry, Datadog, PostHog, App Insights).
Instead, it emits OpenTelemetry spans and metrics and lets operators wire any
OTLP-compatible backend by configuring environment variables (or by passing an
already-configured TracerProvider / MeterProvider).
Logs are NOT an OTel signal here: the library attaches no LoggingHandler
and emits no log records through a LoggerProvider. :func:setup_logging
configures structlog over the stdlib logging root logger, so log lines
reach a telemetry backend only if the operator has attached a handler to that
root logger themselves -- which is what configure_azure_monitor() does.
That is incidental wiring, not an emission path this library owns, and it is
why exception text has to be scrubbed inside the processor chain rather than
at an exporter (see _redact_exc). (:func:configure_exporters lets the
SDK's configurator install a LoggerProvider alongside the tracer and
meter providers, as opentelemetry-instrument would; nothing in the
library writes to it.)
Under the taskq worker CLI the standard OTEL_* exporter variables are
enough: :func:configure_exporters installs SDK providers from them at
startup when the [otel] extra is installed and no provider is set yet
(see _exporter). Embedding applications call it themselves or configure
the SDK directly.
Common deployment shapes:
- Datadog Agent, accepts OTLP on
localhost:4317. SetOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317. - Sentry, Sentry Spotlight / Sentry OTel ingest, ditto.
- App Insights, set
OTEL_EXPORTER_OTLP_ENDPOINTto the Azure Monitor connection string-derived OTLP URL (typically via the App Insights agent). - PostHog, currently via PostHog Cloud OTLP endpoint, same env var.
For error reporting that doesn't fit OTel exception events (e.g., DLQ routing
to Sentry), users implement the ErrorReporter Protocol
as a DI provider, vendor-neutral and added with the observability surface.
The library depends only on opentelemetry-api at runtime. Operators
who want to configure providers programmatically (or use the in-process
testing utilities in taskq.testing) install the [otel] extra::
pip install "taskq-py[otel]"
which pulls in opentelemetry-sdk and opentelemetry-exporter-otlp.
For Prometheus scrapes, use the [prometheus] extra instead.
Semconv compliance: the library uses spec-compliant messaging semconv
attribute names (messaging.operation.type=publish,
messaging.operation.type=process, messaging.consumer.group.name, etc.)
so that operators who set OTEL_SEMCONV_STABILITY_OPT_IN=messaging get
consistent behavior. No runtime conditional branching on this env var is
needed, the attribute values are correct by construction.
__all__
module-attribute
¶
__all__ = [
"INSTRUMENTATION_NAME",
"ConsumedOutcome",
"ErrorReporter",
"ErrorReporterType",
"ExceptionText",
"ExporterWiring",
"NullErrorReporter",
"OtelExporterConfigurationError",
"ScrubbedText",
"StrandedReason",
"TimeoutKind",
"bind_job_context",
"claim_health_snapshot",
"configure_exporters",
"get_logger",
"get_meter",
"get_tracer",
"invoke_error_reporter",
"log_cancel_phase_change",
"log_state_change",
"otel_enabled",
"reconcile_cron_failures",
"record_archived_jobs",
"record_attempt_failure",
"record_backpressure_error",
"record_cancel_actored",
"record_cancel_requested",
"record_capacity_refresh_failure",
"record_claim_latency",
"record_consumed_message",
"record_corrupt_dispatch_row",
"record_cron_budget_deferral",
"record_cron_failure",
"record_cron_lock_contention",
"record_cron_skipped_slots",
"record_deadline_exceeded_swept",
"record_dispatch_duration",
"record_dispatch_failure",
"record_election_attempt",
"record_enqueue_dedup",
"record_error_reporter_failure",
"record_exception_safe",
"record_exception_text",
"record_expired_archive_jobs",
"record_heartbeat_miss",
"record_job_abandoned",
"record_job_interrupted",
"record_job_interrupted_noop",
"record_job_timeout",
"record_leader_lease_expires_in_seconds",
"record_lock_contention",
"record_lock_expires_in_seconds",
"record_loop_stall_attribution",
"record_pool_acquire_duration",
"record_process_duration",
"record_progress_flush_failure",
"record_progress_publish_failure",
"record_pruned_jobs",
"record_published_message",
"record_queue_wait",
"record_ratelimit_acquire_dependency_failure",
"record_ratelimit_denial",
"record_ratelimit_refund_failure",
"record_reclaimed_jobs",
"record_reservation_denial",
"record_reservation_reclaim_drain_duration",
"record_reservation_reclaim_drain_failure",
"record_reservation_reclaim_drain_rows",
"record_reservation_reclaim_heal_failure",
"record_slot_pool_acquire_failure",
"record_sse_connection_closed",
"record_sse_connection_opened",
"record_sse_rejection",
"record_sub_enqueue_failure",
"record_sweep_batch_size",
"record_sweep_batch_size_configured",
"record_sweep_success",
"record_sweep_timeout",
"record_sweep_unexpected_error",
"redact_payload",
"render_exception",
"reset_claim_health_state",
"safe_exception_message",
"safe_repr",
"safe_start_span",
"safe_str",
"set_exception_message_max_chars",
"set_exception_redaction_enabled",
"set_otel_enabled",
"set_slot_pool_occupancy_source",
"set_worker_capacity_source",
"setup_logging",
"update_actor_backlog_cache",
"update_actor_oldest_pending_age_cache",
"update_actor_oldest_running_age_cache",
"update_cancel_pending_cache",
"update_cron_slots_behind",
"update_disabled_schedules_count",
"update_heartbeat_consecutive_failures",
"update_jobs_by_status_cache",
"update_jobs_retry_headroom_cache",
"update_jobs_retrying_cache",
"update_jobs_running_cache",
"update_keyed_reclaim_pending",
"update_oldest_due_age_cache",
"update_queue_depth_by_status_cache",
"update_queue_depth_cache",
"update_queue_live_workers_cache",
"update_queue_utilization_cache",
"update_ratelimit_bucket_tokens_cache",
"update_reservation_slots_cache",
"update_running_lease_expired_cache",
"update_scheduled_count_cache",
"update_scheduled_horizon_cache",
"update_stranded_jobs_cache",
"update_sweep_batch_size_cache",
]
ExporterWiring ¶
ExporterWiring = Literal[
"configured",
"preconfigured",
"none",
"disabled",
"sdk_disabled",
"sdk_missing",
]
What :func:configure_exporters did about the process-global providers.
configured: SDK providers were installed from the environment (andmetrics_port);otel-exporter-configurednames the exporters.preconfigured: a real provider was already set, an embedding application, a vendor distro oropentelemetry-instrumentowns the process, so nothing was touched.none: nothing asked for an exporter; the API's no-op proxies stay.disabled:TASKQ_OTEL_AUTOCONFIGURE=false.sdk_disabled:OTEL_SDK_DISABLED=true.sdk_missing: an exporter was requested but the package that provides it is not installed;otel-exporter-unavailablenames the extra.
ConsumedOutcome ¶
The outcome label set of messaging.client.consumed.messages.
Every value has a producer: the three terminal outcomes, and scheduled
for an attempt that ended with the row released back to the queue, a
retryable failure, a Snooze / RetryAfter, or an admission denial.
taskq.jobs.attempt_failures separates the failure share of
scheduled; taskq.jobs.abandoned counts real abandonment, which is
an operator cancel outlasting its graces and never a consumer outcome.
StrandedReason ¶
Why a pending/scheduled row can never dispatch, the closed reason
label set of taskq.jobs.stranded.
TimeoutKind ¶
Which budget a job exceeded, the closed kind label set of
taskq.jobs.timeouts.
ErrorReporterType ¶
Structural type alias for DI registration and parameter annotations.
Use :class:ErrorReporter directly in most contexts; this alias is provided
for register_value(ErrorReporterType, ...) calls where a distinct type
object is needed for the registry key.
OtelExporterConfigurationError ¶
Bases: RuntimeError
The operator asked for an exporter the SDK could not build.
Raised at worker startup, the earliest stage that can catch a misspelled exporter name or a protocol the installed exporters do not speak, so the worker refuses to start rather than run with a telemetry pipeline that looks configured and exports nothing.
ExceptionText
dataclass
¶
ExceptionText(
type_name: str,
raw_stacktrace: str,
message: ScrubbedText,
stacktrace: ScrubbedText,
)
One rendering of an exception, shared by every sink that reports it.
A failed attempt is reported on the attempt.N span, on the
job_exception and job-failed log lines and in the durable
ErrorInfo. Rendering the traceback and scrubbing it are the dominant
CPU cost of a failed job (a 27-frame traceback is ~0.8 ms to render and
~0.4-0.8 ms to scrub, GIL-held), so :func:render_exception does each
once and the sinks are handed this value rather than the exception.
raw_stacktrace is unscrubbed: the durable row lives inside the trust
boundary and keeps the DETAIL row values the operator needs; only the
telemetry-bound text is scrubbed.
type_name
instance-attribute
¶
__qualname__ of the exception class, as OTel's exception.type.
raw_stacktrace
instance-attribute
¶
traceback.format_exception output, verbatim.
message
instance-attribute
¶
str(exc) scrubbed and length-bounded -- see :func:safe_exception_message.
stacktrace
instance-attribute
¶
raw_stacktrace scrubbed line-wise, never length-bounded, so it stays diagnostic.
ScrubbedText ¶
Bases: str
Exception text that has been through :func:_scrub_text.
A str in every other respect, so every renderer and serializer treats
it as plain text. The type is the invariant: the log processor
(_scrub_exception_fields) passes a ScrubbedText field through
untouched, which is what lets a handler scrub a traceback once and emit it
on several log lines. Only this module constructs one from freshly
rendered text; a caller that needs one holds a value that already is one.
nul_escaped ¶
The same text with NUL codepoints rewritten to the visible \x00.
Scrubbing must run BEFORE the escape, never after: the credential
mask's keyword boundary is a lookbehind for a non-word character, and
the escape's trailing 0 satisfies it in the wrong direction, so
\x00password=… escaped first ships the password. The escape
itself introduces only backslash, x and 0 -- it cannot
reassemble a DETAIL line or a credential the scrub removed, so the
result keeps its scrubbed standing.
Source code in src/taskq/obs/_redact_exc.py
ErrorReporter ¶
Bases: Protocol
Vendor-neutral hook for routing terminal job failures to external systems.
Implementations capture the error and job row, then forward to a
vendor-specific backend (Sentry, Datadog, a DLQ, etc.). The library
calls :meth:report when a job reaches a terminal failure state ,
either because retries were exhausted or because the error was
non-retryable.
The call is wrapped in a try/except by :func:invoke_error_reporter;
a failing reporter never crashes the worker. Reporter failures are
counted on the taskq.error_reporter.failures counter with a
reporter_type attribute.
Register an :class:ErrorReporter instance as a DI provider
(registry.register_value(ErrorReporter, Scope.PROCESS, my_reporter))
or pass it directly to the worker bootstrap.
NullErrorReporter ¶
Default no-op :class:ErrorReporter, silently drops all reports.
Used when no vendor-specific error routing is configured. Instances are stateless and safe to share.
claim_health_snapshot ¶
Per-queue claim health over the recent window, at now.
Pure read: trims nothing the next record wouldn't, yields NOTHING for a queue with no window entries (the empty-not-zero discipline - a scrape of an idle worker produces no data points, never zeros).
Source code in src/taskq/obs/_claim_health.py
record_claim_latency ¶
Record ONE claim statement's latency (the wiring site is the dispatch helper, success or failure - a failed claim round is still a latency the queue paid).
The queue label is bounded HERE, at record time - the same
_bounded_queue discipline every taskq.* metric uses; a
payload-shaped string can never become a label.
Source code in src/taskq/obs/_claim_health.py
reset_claim_health_state ¶
Drop every recorded latency (test isolation).
configure_exporters ¶
Install SDK tracer and meter providers from the OTel environment.
Called once by the taskq worker CLI after settings load and before
the worker records anything: measurements a proxy instrument takes
before a provider exists are dropped, not replayed. Embedding
applications that host a worker without the CLI call this themselves
at process start, or configure the SDK directly, a provider that is
already set is never replaced.
Raises :class:OtelExporterConfigurationError when the environment
names an exporter the SDK cannot build; every other outcome is a
return value plus one log line.
Source code in src/taskq/obs/_exporter.py
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | |
get_meter ¶
Return the library's meter. Honors any globally-configured provider.
The meter object is resolved on first use and memoized, exactly like
:func:get_tracer: with no SDK set up, metrics.get_meter returns
a _ProxyMeter, which rebinds to the real provider's meter when an
SDK registers later (on_set_meter_provider notifies every proxy
meter, and every proxy instrument on it), so memoization never pins
the proxy/no-op behavior. And unlike the uncached call, it never asks
the proxy provider for a second meter: _ProxyMeterProvider appends
every get_meter result to a list with no cleanup path, so an
unmemoized accessor grows that list on every lazy-instrument call ,
one entry per rate-limit denial, reservation denial, flush failure,
and drain row-count, in exactly the default deployment (taskq never
installs a provider itself).
Source code in src/taskq/obs/_otel.py
get_tracer ¶
Return the library's tracer. Honors any globally-configured provider.
The tracer object is resolved on first use and memoized. That is safe
across the no-provider → real-provider transition: with no SDK set up,
trace.get_tracer returns a ProxyTracer, which re-checks the
global provider on every span start and rebinds to the real one when an
SDK registers later -- so memoization never pins the proxy/no-op
behavior.
Source code in src/taskq/obs/_otel.py
otel_enabled ¶
Whether telemetry emission is currently enabled.
Read at call time, never imported by value: the flag is module state
flipped once by worker startup (see :func:set_otel_enabled), and a
reader checks it to skip WORK whose only consumer is a metric, a
query whose result only a reconcile would export is a wasted round
trip when the flag is off. The emitters themselves stay gated
individually regardless.
Source code in src/taskq/obs/_otel.py
reconcile_cron_failures ¶
Move each actor's failure series onto totals, the database's own
summed cron_schedules.consecutive_failures per actor.
The series answers "is any schedule failing right now", and only the database knows. Schedules are enabled, disabled and deleted by clients, by the CLI and by the admin UI, every one of them outside the process that emits this metric, and each of those actions clears or removes a count some worker counted up. A worker cannot emit another process's delta, so a balance built from this process's deltas alone can only drift upward: an actor whose last failing schedule was deleted a month ago would report a failure level forever, and an alert on the series becomes unreadable exactly when an operator needs it. Reconciling against the database each tick makes every out-of-process change self-correct on the next tick with due work.
totals must be the complete per-actor truth, every actor with a
failing schedule anywhere in the table (see _actor_failure_totals),
not the slice one tick's batch happened to touch. A batch covers only
DUE schedules, so an actor whose failing schedule was deleted or
disabled with nothing left due never appears in a batch again; under a
batch-scoped totals its level would strand at its last value. An
actor ABSENT from totals therefore reads as a true zero, the
database holds no failing schedule for it, and its series is returned
to zero here.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_archived_jobs ¶
Bump the archived.jobs counter.
Called alongside record_pruned_jobs at the prune sweep call site in leader.py.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_attempt_failure ¶
Count one attempt that ended in an actor failure, retried or terminal.
Called from the failure handlers (worker/_handlers.py) once per
handled exception, after the retry decision and before the terminal
write: an attempt that raised failed whether or not the row write that
follows lands, and the consumed-messages outcome says what happened
to the row. retryable is the classifier's decision, true when
the attempt is rescheduled for another try, false when the failure
is terminal (a non-retryable class, or the attempt budget exhausted) ,
which is what a retry-rate alert reads. error_type is the exception
class name; omitted, it derives from the exception being handled
(_resolve_error_type): a closed set, never caller text.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_backpressure_error ¶
Bump the backpressure.errors counter.
Unconditional (not gated by _otel_enabled): backpressure errors are
safety-critical signals that must be counted even when OTel is disabled,
so operators always have visibility into enqueue rejections. kind
is the bounded enum named on the counter: the two capacity kinds, and
the two identity-serialization lock-timeout kinds that count their
refusals beside the typed errors' warning logs.
Source code in src/taskq/obs/_otel.py
record_cancel_actored ¶
Count one operator cancel the worker began acting on, labeled by actor.
Called from the cancel controller's two FIRST-observation arms only
(worker/cancel.py): the phase-1 cooperative observation, and the
PG-observation fast-advance when the local phase was still NONE. A
re-issue arm or a later tick walking an already-observed row records
nothing, so one cancel request is one increment no matter how many
heartbeat ticks it spans.
Unconditional (not gated by _otel_enabled), like the other
cancel-plane counters: a cancel the operator asked for and the fleet
cannot see acting on is the exact defect this counter exists to
expose, and it must survive an exporter misconfiguration.
Source code in src/taskq/obs/_otel.py
record_cancel_requested ¶
Bump the cancel-requested counter.
This counter is unconditional:
incremented once per JobsClient.cancel() call regardless of
cancellation_initiated outcome.
Source code in src/taskq/obs/_otel.py
record_capacity_refresh_failure ¶
Count a failed capacity-cache refresh.
The cache fails OPEN by design -- it keeps the last snapshot, or falls back
to the @actor literal, and stamps refreshed_at so a sick backend is
not re-queried on every enqueue. That reasoning is sound; the problem was
that a load-shedding gate which relaxes precisely when the backend is
degraded had no signal an operator could alert on, only a warning log.
has_snapshot=False is the materially worse case: the first refresh at
process start failed, so there is no stored data at all and every enqueue
enforces the code literal rather than the operator's tightened
max_pending -- for a full TTL at a time, indefinitely while the backend
stays sick.
error_type is the exception class name; omitted, it derives from the
exception being handled (_resolve_error_type), the call site records
from the refresh read's except block.
Unconditional (not gated by _otel_enabled) for the same reason as
record_backpressure_error: this is a safety-critical signal.
Source code in src/taskq/obs/_otel.py
record_consumed_message ¶
Bump the consumed-messages counter.
Called after job completion, outside the CONSUMER span body,
to ensure sampling independence.
Respects _otel_enabled, no-op when False.
outcome is the closed :data:ConsumedOutcome set. A consumer-path
AttemptOutcome of "scheduled" (retry, snooze, admission denial)
is recorded as exactly that, the row went back to the queue, never
as abandoned, which is the operator-cancel outcome and has its own
counter (:func:record_job_abandoned). A "noop" attempt consumed
nothing and must not reach this recorder.
Source code in src/taskq/obs/_otel.py
record_corrupt_dispatch_row ¶
Bump the corrupt-claimed-row counter.
Called from the dispatch round's per-row decode path. column is
the jsonb column that refused to decode (a closed set). Respects
_otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_cron_budget_deferral ¶
Count one cron fire deferred by the tick's factory budget.
The alertable face of the budget-deferral path: the log event
(cron-fire-budget-deferred) is unconditional and carries the
per-schedule attribution; this counter is the series an operator
alerts on when deferrals stop being transient. See the counter's
description for the sustained-rate diagnosis and the operator knobs.
Labeled by actor, admitted through the same cap as
:func:record_cron_failure (schedule rows accept any string at
creation time, so the label is bounded).
Respects _otel_enabled: no-op when False.
Source code in src/taskq/obs/_otel.py
record_cron_failure ¶
Record a cron failure delta on the UpDownCounter.
On failure, callers add +1 per failure. On success, callers add
-current_count for that schedule to reset the counter to zero ,
a simple add(-1) would leave a non-zero cumulative value if
there were multiple consecutive failures.
Labeled by actor, admitted through the same cap as queue:
the failure path emits the raw cron_schedules.actor string and
schedule rows accept any string at creation time, so the first
_MAX_ACTOR_LABEL_VALUES distinct names keep their series and
later names collapse onto _other_ (see the cardinality note
above _bounded_cron_actor). Schedules on one actor share a
series, and the per-schedule attribution the caller already holds
rides on the cron fired / cron fire failed log lines and the
cron-fire span instead, schedule_id is a per-row,
runtime-minted UUID and identity-like (see the cardinality note
above _lock_expires_in_seconds).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_cron_lock_contention ¶
Count a cron tick skipped because the advisory lock was held.
A steady low rate is the benign leader-handover overlap. A rate equal to
the tick rate, sustained, means cron is not running anywhere: the lock is
transaction-scoped and releases on COMMIT/ROLLBACK, which never happens if
the holding session was partitioned without a FIN. Before this counter the
two were indistinguishable, because the contended branch returned in
silence.
Respects _otel_enabled -- no-op when False.
Source code in src/taskq/obs/_otel.py
record_cron_skipped_slots ¶
Count count cron slots a fire attempt dropped, labeled by actor.
The catch-up window's skip branch (cron_loop._plan_fire) is the
system ADMITTING it is losing the race: a schedule's next_fire_at
fell behind server_now - cron_catch_up_window, and every schedule
occurrence between the owed slot and the recomputed one is a fire the
system will not even attempt. Until this counter existed that
admission was log-only (the cron missed slots skipped warning),
so "jobs clearing slower than the cron period" -- the runaway
fan-out -- was invisible on the metrics plane.
count is in FIRE UNITS, not seconds: a */5 schedule reported as
3 is three dropped fires (about 15 minutes of schedule time), the
unit the runaway predicate compares against the cron period. The
count is emitted from the tick's commit-gated emission, so a rolled
back tick (whose owed slots are re-attempted, not dropped) reports
nothing; it covers both committed successes and committed
suppressions (a budget-deferred fire of an overdue schedule durably
advances next_fire_at past the owed slots, so its skips count
too). The clearance comparison itself -- fires vs. job completion
rate, the runaway PREDICATE -- lives in taskq.insights; this
counter is one of the producer-side facts it reads.
A floor, not always the depth: the count walk is bounded by a slice
of the tick's own deadline (cron_loop._SKIP_WALK_BUDGET_FRACTION)
so a fleet-down backlog cannot outlive the tick and livelock the
fleet. A walk its budget cut short reports the count it reached and
flags it skipped_slots_partial=True on the cron missed slots
skipped warning, the cron fired line and the fire span; the
counter and the depth gauge then read that lower bound. Every
consumer tolerates it -- the runaway predicate compares the depth
against the cron period, and a backlog deeper than the budget's
reach is deeper than any period.
Labeled by actor, admitted through the same cap as
:func:record_cron_failure (schedule rows accept any string at
creation time, so the label is bounded). Per-schedule attribution
rides the cron missed slots skipped warning, the cron fired
log line and the cron-fire span's taskq.cron.skipped_slots
attribute, where cardinality is free. The depth companion (how far
behind, not just that a drop happened) is the
taskq.cron.slots_behind gauge.
Respects _otel_enabled: no-op when False.
Source code in src/taskq/obs/_otel.py
2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 | |
record_deadline_exceeded_swept ¶
Bump the deadline-exceeded sweep counter.
Unconditional (not gated by _otel_enabled): deadline-exceeded sweeps
indicate jobs that violated their execution budget, a correctness signal
that must be counted even when OTel is disabled, so operators always have
visibility into sweep activity.
Source code in src/taskq/obs/_otel.py
record_dispatch_duration ¶
Record dispatch query latency on the histogram.
Called outside the dispatch span body for sampling independence.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_dispatch_failure ¶
Bump the dispatch-failure counter.
Called from the dispatch round's exception path, outside the span body
for sampling independence. error_type is the exception class name;
omitted, it derives from the exception being handled
(_resolve_error_type), every call site records from an except
block, so the derived value is the caught exception's class.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_election_attempt ¶
Record a leader election attempt.
Always increments election_attempts; increments election_failures
only when the attempt did not win the lock.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_enqueue_dedup ¶
Bump the enqueue.dedups counter.
Called from the shared dedup-report helper
(backend/_enqueue.py::_log_enqueue_dedup) at every dedup hit, on
both backends, the log lines are per-hit observability, and the
per-hit terminal-target WARNING arm is budget-bounded at batch
scale, so the RATE a stampede produces has no log channel left to
ride on; this counter is that rate. dedup_reason is the bounded
enum of reasons a hit can occur (unique_for |
idempotency_key | zombie_ack_lost), the same value the
helper logs, never a caller-controlled string.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_error_reporter_failure ¶
Bump the error_reporter.failures counter.
Called at the error-reporter catch site when report() raises.
reporter_type is the exception-safe class name of the reporter
instance (bounded cardinality, one per registered implementation).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_expired_archive_jobs ¶
Bump the expired_archive.jobs counter.
Called at the archive expiry sweep call site in leader.py.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_heartbeat_miss ¶
Bump the heartbeat.misses counter.
Called in heartbeat.py on each heartbeat renewal failure.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_job_abandoned ¶
Count one job abandoned by an operator cancel that outlasted its graces.
Called from mark_abandoned on both backends once the abandon write
applied: the actor was asked to stop, then forced, and never exited, so
the row is taken from it. Shutdowns never produce this, a deploy
interrupts running attempts back to the fleet instead, which is why
any non-zero rate is worth a page. Attributes: actor.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_job_interrupted ¶
Bump the jobs.interrupted counter.
Called when a worker shutdown releases a running attempt back to the
fleet (mark_interrupted landed). held buckets the release by
whether the row was parked behind a hold (the actor was still running
when the graces expired) or re-pended immediately, the split an
operator reads to see whether deploys are interrupting responsive or
unresponsive actors. Attributes: actor, hold ("0" | ">0").
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_job_interrupted_noop ¶
Bump the jobs.interrupted_noop counter.
Called when mark_interrupted's fence declines the release (the row
moved: a reclaim, a terminal write, or an operator cancel in flight
owns it). A silent no-op here is the failure mode the project rule
names, an interruption that looks released but never landed, so the
fenced-out path is instrumented alongside the success path.
actor is None when the fenced-out read cannot attribute one.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_job_timeout ¶
Count count jobs that exceeded a time budget.
start_to_close is recorded at the timeout handler once per
attempt that hit its per-attempt budget, retried or not.
schedule_to_close is recorded wherever the whole-job deadline is
what ended it: the deadline sweep (which counts a batch at a time),
and the handler arms where the backend's deadline arbitration refused
a retry, a snooze or a denial's requeue with DeadlineExceeded.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_leader_lease_expires_in_seconds ¶
Stamp the leader lease's TTL as of this process's last elect/renew.
The leader-side mirror of :func:record_lock_expires_in_seconds
(heartbeat.py), but a gauge, not a histogram, because the contract
that matters for the lease is FRESHNESS, not distribution: the series
is present only on the pod that holds the lease, its value is the
TTL the server just stamped (leader_lease seconds out), and a
series that stops moving or vanishes is a leader that stopped
renewing, the split-brain/no-leader detector's per-pod evidence.
Called at election win and each successful renewal in
worker/leader.py; cleared on demotion by
:func:clear_leader_lease_expires_in_seconds so a demoted pod never
keeps claiming a lease it no longer holds.
Respects _otel_enabled, no-op when False.
Rebind, never write in place: the cache is read on the OTel reader
thread while this runs on the event-loop thread (same discipline as
:func:record_sweep_success).
Source code in src/taskq/obs/_otel.py
record_lock_contention ¶
Count an advisory-lock acquisition lost to another session.
Recorded by the LOSING side at every maintenance acquisition point
(leader election, prune, archive-expiry). Contention is the signal that
would have exposed two schemas in one database silently sharing one
lock, and a sustained rate equal to the attempt rate means the loser
never wins at all. Cron's contention stays on its own dedicated
counter (taskq.cron.lock_contention).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_lock_expires_in_seconds ¶
Record the measured remaining lock TTL on the histogram.
Called in heartbeat.py at each successful renewal after the first,
with the lease the previous renewal stamped minus the time elapsed
since, a measurement, never the configured constant, so the
lock-expiry alert can fire when renewals run late.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_loop_stall_attribution ¶
Count one event-loop stall attributed to the frame holding the GIL.
Called from the loop-lag watchdog's daemon thread at the stall's warn
and trip tiers: sampling the main thread's current frame from there is
the only attribution that works mid-block, and it needs no loop
cooperation. actor is None when no registered actor function
appeared in the sampled stack (the block sits under taskq's own code
or a non-actor coroutine). Respects _otel_enabled, no-op when
False.
Source code in src/taskq/obs/_otel.py
record_pool_acquire_duration ¶
Record the dispatch round's pool-acquire wait on the histogram.
Called outside the dispatch span body for sampling independence.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_process_duration ¶
record_process_duration(
actor: str,
queue: str,
elapsed: float,
*,
outcome: ConsumedOutcome,
) -> None
Record job execution duration on the histogram.
Called outside the CONSUMER span body for sampling independence, with
the same outcome the consumed-messages counter records for the
attempt: a start_to_close timeout lands at exactly the budget and
a failure at whatever it took, and either would drag a success
percentile if the distributions were shared.
Respects _otel_enabled, no-op when False.
Finer resolution is an operator's SDK View away (the shipped
boundaries are seconds-scaled, not operator-tuned).
Source code in src/taskq/obs/_otel.py
record_progress_flush_failure ¶
Bump the progress.flush_failures counter.
stage must be 'per_job' (one job's flush UPDATE failed, that
job's progress since the last flush is lost) or 'pool' (a pool
could not be obtained at all, the loop-level getter failed, or the
per-job acquire failed/exhausted, so every job's progress is lost).
The two are materially different incidents and must stay
distinguishable in an alert rule, which is also why both pool-stage
sites log a different kind than the per-job one.
error_type is the exception class name.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_progress_publish_failure ¶
Bump the progress.publish_failures counter.
channel must be 'per_job' or 'global', bounded cardinality.
error_type is the exception class name (e.g. 'ResponseError').
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_pruned_jobs ¶
Bump the pruned.jobs counter.
Called at the prune sweep call site in leader.py.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_published_message ¶
Bump the published-messages counter.
Called after successful enqueue, outside the PRODUCER span body,
to ensure sampling independence.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_queue_wait ¶
Record how long a job waited from eligibility to claim.
Called once per dispatch from the claimed row's own server-clock
stamps: started_at - scheduled_at, eligibility to claim. The
per-job companion of the sampled oldest_pending_age_seconds: the
gauge shows the head of the line, this histogram shows what every
dispatched job actually waited, retries and re-pends included. Labels
are the job-side pair (actor, queue capped as everywhere).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_ratelimit_acquire_dependency_failure ¶
Bump the ratelimit.acquire_dependency_failures counter.
Called when a rate-limit acquire fails because the limiter's store ,
Redis, or the PG fallback behind it, could not answer, and the
worker failed the acquire closed as a denial. An AVAILABILITY
signal, distinct from taskq.reservation.denials, which counts
admission decisions: a denial with this counter rising is an outage
masquerading as contention, and an operator must read the two
together before scaling a bucket. error_type is the exception
class name.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_ratelimit_denial ¶
Bump the ratelimit.denials counter.
Called from the rate-limit decision logger whenever a decision denies
admission. backend is the rate-limit backend enum (bounded). The
bucket name is deliberately NOT a dimension: keyed bucket names are
caller-derived and unbounded, so a bucket label would reintroduce the
cardinality class the queue-label cap exists to prevent.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_ratelimit_refund_failure ¶
Bump the ratelimit.refund_failures counter.
Called at the rate-limit refund failure catch site. bucket is the
handle's concrete bucket name, admitted through the same cap as
queue / the cron actor: a keyed bucket's name embeds a
caller-controlled key (base_name:key), so carried as-is it would
mint one never-released OTel series per distinct key ever seen (see
the cardinality note above _bounded_bucket). error_type is
the exception class name; omitted, it derives from the exception being
handled (_resolve_error_type).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_reclaimed_jobs ¶
Bump the per-actor, per-disposition reclaimed-jobs counter.
Unconditional (not gated by _otel_enabled): a reclaim is a worker
death the fleet recovered from -- the same class of fleet-health
signal as :func:record_deadline_exceeded_swept, which must be
counted even when OTel is disabled. Recorded aggregated per (actor,
disposition) after the sweep's transaction, by the Postgres sweep
(from its RETURNING) and by the in-memory twin alike, so the two
backends' label sets cannot drift. The disposition values are the
code-fixed enum repended / crashed / cancelled
(taskq.backend._sweeps._RECLAIM_DISPOSITIONS); actor flows
through registration like every other actor-labeled instrument.
Source code in src/taskq/obs/_otel.py
record_reservation_denial ¶
Bump the reservation.denials counter.
Called at the reservation-class denial handler for every
ReservationUnavailable a worker fields. source is
'reservation' or 'rate_limit' (bounded). bucket_name is
accepted so the call site reads naturally but is deliberately NOT a
dimension: keyed bucket names are caller-derived and unbounded, so
the label would reintroduce the cardinality class the queue-label cap
exists to prevent.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_reservation_reclaim_drain_duration ¶
Record the wall-clock duration of one reclaim drain statement.
Recorded on success and failure alike (callers pass it from a
finally), so a timeout that aborted the drain still leaves a
duration sample.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_reservation_reclaim_drain_failure ¶
Bump the ratelimit.reclaim_drain_failures counter.
Called when the keyed-reservation slot-row reclaim drain fails. A
persistently failing drain strands reservation_slots rows, a
STORAGE signal, and the pending-depth gauge shows the backlog
forming. Distinct from heal failures, which are an AVAILABILITY
signal; the two must not share a counter.
error_type is the exception class name.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_reservation_reclaim_drain_rows ¶
Count rows deleted by one keyed-reclaim drain, reservation_slots
slot rows and rate_limit_buckets bucket rows alike.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_reservation_reclaim_heal_failure ¶
Bump the ratelimit.reclaim_heal_failures counter.
Called when the acquire-path re-materialisation heal for a keyed
bucket whose slot rows were deleted by a sibling worker's drain
fails. A failing heal denies new admissions for that bucket, an
AVAILABILITY signal, the opposite of a drain failure (storage); the
two must not share a counter.
error_type is the exception class name.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_slot_pool_acquire_failure ¶
Bump the worker.slot_pool.acquire_failures counter.
Called from the exception branch of the bounded per-job acquire in
taskq.worker.dispatch, never the success path, matching
record_sweep_timeout's contract. A rate here is what separates
one transient timeout from every transactional job on a worker
failing to acquire, and error_type is the exception class name ,
omitted, it derives from the exception being handled
(_resolve_error_type).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_sse_connection_closed ¶
Record one SSE connection releasing its slot (the level gauge's -1).
Called from the exact finally/release sites the slot's semaphore is released at, so the gauge can never claim a connection whose slot was handed back.
Source code in src/taskq/obs/_otel.py
record_sse_connection_opened ¶
Record one SSE connection taking a slot (the level gauge's +1).
Called at the acquire site AFTER the slot is held, so the gauge and
the semaphore budget move together; the close is recorded at the
same site the slot is released (record_sse_connection_closed).
topic is the closed vocabulary the endpoints validate before the
slot lookup (see the section note above).
Source code in src/taskq/obs/_otel.py
record_sse_rejection ¶
Count one SSE connection refused at the cap (the 429 site).
Called from the two 429 sites (the admin topic endpoint's inline
cap check, and the shared acquire_sse_slot helper the progress
stream uses). Same closed label vocabulary as the connections
gauge, so the two join: rejections rising beside connections pinned
at the cap is saturation; rejections alone is a burst the cap
absorbed.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_sub_enqueue_failure ¶
Bump the sub_enqueue.failures counter by count failed child enqueues.
Called at the post-commit flush catch site: the parent job has already
been reported as succeeded, so every failed child enqueue is a job the
caller believes exists but does not. actor is the parent's actor
(bounded by the registered actor set). count is the number of
child enqueues that failed, so one incident with N lost children
records N, not 1. error_type is the exception class name; omitted,
it derives from the exception being handled (_resolve_error_type).
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_sweep_batch_size ¶
Record the batch size a sweep call actually used.
The maintenance sweeps degrade to a reduced batch after repeated
cancellations; a worker reporting the reduced tier is reporting an
unhealthy database and must not be silent about it. Feeds BOTH the
batch-size gauge below AND maintenance_health's reduced-tier view
(health.py reads the cache directly), so it is deliberately NOT gated
on _otel_enabled: the health body must keep reporting a latched
reduced tier when an operator has exported telemetry off, because that
body is the Prometheus-free surface the degraded signal exists for.
Source code in src/taskq/obs/_otel.py
record_sweep_batch_size_configured ¶
Record the batch size this worker is CONFIGURED to use for sweep_name.
Emitted at the same call site as :func:record_sweep_batch_size so
the two gauges carry the same sweep_name label set and a
gauge-to-gauge comparison is always label-matched. The alert pair
(used vs configured) is what makes the sweep-degraded signal track
per-worker event_writer_batch_size: a literal threshold is blind
on every deployment whose configured size is not the default.
Respects _otel_enabled, no-op when False (its only consumer is
the OTel gauge, unlike :func:record_sweep_batch_size).
Rebind, never write in place, same cross-thread reader discipline as
:func:record_sweep_success.
Source code in src/taskq/obs/_otel.py
record_sweep_success ¶
Stamp a sweep call's success in BOTH clock domains.
Feeds the staleness gauge below (the wall ledger, an absolute Unix
timestamp) and maintenance_health's stalled view (the monotonic
ledger, an elapsed-time anchor, see
_sweep_success_monotonic_cache for why the health view must not
read the wall ledger): time() - last_success answers "is this
sweep still making progress?" independently of row counts, so a
sweep that finds zero eligible rows every tick (healthy) is
distinguishable from one that never completes (stalled). The
row-count counter and the duration histogram share this call site
but NOT this sample population: a timed-out sweep records duration
and a timeout but no row sample, so rows and duration must be read
as different populations, which the sweep_timeouts counter
reconciles.
Rebind, never write in place: the caches are iterated on the OTel reader thread while this runs on the event-loop thread (see the section comment above).
Source code in src/taskq/obs/_otel.py
record_sweep_timeout ¶
Count a sweep or sampler read that did not complete.
Called on the failure path, never the success path. A sweep reports the
deadline family (TimeoutError / QueryCanceledError), where the
distinction between aborted and merely slow is the actionable one; a
gauge sampler reports every failure, because its gauge keeps serving its
last value either way and the read not happening is the whole fault.
Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
record_sweep_unexpected_error ¶
Count a sweep batch that failed with an error OUTSIDE the deadline family.
Called on the failure path, never the success path, from the prune
family's batch machinery: a batch aborted by an archive
UniqueViolation-class error (or any other transient fault the deadline
family does not name) never reaches record_sweep_timeout, and the
sweep's own row counters and success stamps only move on success, so
without this counter a stopped drain reads healthy on every metric
plane. Respects _otel_enabled, no-op when False.
Source code in src/taskq/obs/_otel.py
safe_start_span ¶
safe_start_span(
name: str,
*,
kind: SpanKind | None = None,
attributes: Attributes = None,
links: Sequence[Link] | None = None,
new_root: bool = False,
) -> Generator[Span, None, None]
Start a span safely, never propagates exceptions from OTel API calls.
Checks _otel_enabled first; when False, yields a no-op
NonRecordingSpan. When True, delegates to
get_tracer().start_as_current_span with a try/except around
span creation only. Exceptions from code inside the with block
propagate normally, only OTel API failures (misconfiguration,
exporter unavailability) are suppressed.
When new_root=True, passes an empty Context() so the span
has no parent, it is a root span linked (not parented) to the
ambient trace. This satisfies the "linked, not parented"
requirement for PRODUCER spans in the cron loop.
The SDK's own exception handling is switched OFF and replaced by
:func:_record_scrubbed_error, see the comment at the call below.
Source code in src/taskq/obs/_otel.py
set_otel_enabled ¶
Set the module-level OTel enabled flag.
Called by worker startup code after loading WorkerSettings so that
all safe helpers check the flag without requiring a WorkerSettings
import at every call site. Avoids circular imports (modules like
dispatch.py import from obs and should not import from
settings.py in a circular path).
Source code in src/taskq/obs/_otel.py
set_slot_pool_occupancy_source ¶
Point the slot-pool occupancy gauge at pool.
Called at slot-pool bootstrap open and whenever a credential reload
swaps the pool, so the gauge always reads the live one. None
clears the source, the gauge reports nothing, matching the
pool-not-open state.
Source code in src/taskq/obs/_otel.py
set_worker_capacity_source ¶
Point the worker capacity gauges at active_jobs and max_concurrency.
Called once at worker bootstrap, after the deps that own the
registry exist. None clears the source, both gauges report
nothing, matching a process that hosts no worker.
Source code in src/taskq/obs/_otel.py
update_actor_backlog_cache ¶
Replace the per-(actor, queue) pending-depth cache with fresh data.
update_actor_oldest_pending_age_cache ¶
Replace the per-(actor, queue) oldest-pending-age cache with fresh data.
Source code in src/taskq/obs/_otel.py
update_actor_oldest_running_age_cache ¶
Replace the per-actor oldest-running-age cache with fresh data.
Fed by the backlog sampler from the same grouped read as
:func:update_jobs_running_cache, so count and age describe one
moment. An attempt older than the actor's normal runtime while
taskq.jobs.timeouts stays flat is an actor with no
start_to_close: nothing will end the attempt, and no other gauge
can show it (running_lease_expired reads 0 while the heartbeat
keeps renewing). An actor with nothing running vanishes from the
series.
Source code in src/taskq/obs/_otel.py
update_cancel_pending_cache ¶
Record the count of non-terminal jobs with a cancel in flight.
cancel_phase > 0 over the non-terminal population, sampled by the
leader's queue-depth loop. This is the DURABLE cancel surface a
scrape can see: a CLI cancel writes cancel_requested_at to the row
and exits, the issuing process's own counter dies with it, and this
gauge is what shows the protocol still has work in flight. A value
pinned above zero across ticks is a cancel the grace ladder is not
finishing (read it beside taskq.jobs.cancels_actored_total, which
says the worker saw it).
None (the demotion clear) yields no data point: the series goes
stale and the new leader's is the one answering, the same
empty-not-zero discipline the leader-lease TTL gauge follows — a
demoted process exporting 0 would be an active claim that no cancel
is pending, silencing the gauge exactly at a failover.
Source code in src/taskq/obs/_otel.py
update_cron_slots_behind ¶
Merge one cron tick's observed per-actor skip depths into the gauge.
Called from the tick's commit-gated emission with every actor that had a committed fire or suppression that tick -- clean fires publish 0, so an actor that caught up decays its series on its next observation. MERGE, not replace: the tick observes only DUE schedules (a sample, never the whole table), so unobserved actors keep their last known depth rather than flapping in and out of the series set. The actor key is admitted through the same bounded label as the skip counter (schedule rows accept any string at creation time).
Source code in src/taskq/obs/_otel.py
update_disabled_schedules_count ¶
Update the module-level disabled-schedules count.
Called by the leader's schedule management code when schedules are disabled or re-enabled.
Source code in src/taskq/obs/_otel.py
update_heartbeat_consecutive_failures ¶
Update the module-level heartbeat consecutive-failures value.
Called by the heartbeat loop after each tick so the synchronous gauge callback can read the latest value on scrape.
Source code in src/taskq/obs/_otel.py
update_jobs_by_status_cache ¶
Replace the per-status job-count cache with fresh data.
Called by the backlog sampler. Replaces the merged pending+scheduled
queue-depth view: a growing scheduled count next to a flat pending
count is the promotion-stall signature, which a single summed number
cannot express.
Source code in src/taskq/obs/_otel.py
update_jobs_retry_headroom_cache ¶
Replace the per-actor minimum-retry-headroom cache with fresh data.
Fed by the backlog sampler from the same grouped read as
:func:update_jobs_retrying_cache (one statement, one moment).
Source code in src/taskq/obs/_otel.py
update_jobs_retrying_cache ¶
Replace the per-actor retrying-count cache with fresh data.
Fed by the backlog sampler from the same grouped read as
:func:update_jobs_retry_headroom_cache, so count and headroom
describe one moment. An actor with no non-terminal row past its
first attempt vanishes from the series rather than freezing.
Source code in src/taskq/obs/_otel.py
update_jobs_running_cache ¶
Replace the per-actor running-count cache with fresh data.
Fed by the backlog sampler from one grouped read over the running population. Per actor, not per (actor, queue): the running row's queue label is not what dispatched it (re-pended rows route by the actor's assignment), and the capacity question is per actor , which actors hold the fleet's slots while pending work waits. An actor with no running jobs vanishes from the series rather than freezing at its last count.
Source code in src/taskq/obs/_otel.py
update_keyed_reclaim_pending ¶
Replace the pending-reclaim depth (evicted bucket names waiting for the next drain tick).
A persistently non-zero value alongside a rising drain-failure counter is the visible signature of broken reclamation; the depth is a scalar because bucket names are caller-controlled and must not become label cardinality.
Source code in src/taskq/obs/_otel.py
update_oldest_due_age_cache ¶
Record the age of the oldest due-but-still-scheduled job.
0.0 when nothing is due. Monotonic growth of this gauge is the single best promotion-stall detector: it moves under failure regardless of queue depth or throughput, where absolute depth thresholds are environment-specific guesses.
Source code in src/taskq/obs/_otel.py
update_queue_depth_by_status_cache ¶
Replace the per-(queue, status) depth cache with fresh data.
Fed by the leader's queue-depth loop from the same tick's grouped
read (the depth read's own GROUP BY extended with status), so the
status split describes the same moment the depth gauge does. Keyed by
(queue, status) — the JOINABLE status split: joining it against
the other queue-keyed gauges works on queue, and the
pending/scheduled split a promotion stall lives in is one label
away, without a PromQL join modifier.
Source code in src/taskq/obs/_otel.py
update_queue_depth_cache ¶
Replace the queue-depth cache with fresh data from the leader's PG query.
Called by the background async task in the leader loop every 15s. The synchronous gauge callback reads from this cache.
Source code in src/taskq/obs/_otel.py
update_queue_live_workers_cache ¶
Replace the per-queue live-worker cache with fresh data from the
leader's query, sampled in the same tick as the queue depth, so the
two can be joined on queue without describing different moments.
A worker is live when its last_seen_at is within the liveness
window (admin_worker_liveness_seconds); a dead-but-unswept worker
row does not count. A queue with pending work and no live worker is
the condition TaskQQueueUnserved fires on, and it is invisible to
every other gauge: depth alone cannot say whether anyone is consuming.
Source code in src/taskq/obs/_otel.py
update_queue_utilization_cache ¶
Replace the per-queue utilization cache with fresh data from the
leader's query, sampled in the same tick as the queue depth and the
live workers, so the three gauges join on queue without
describing different moments.
The ratio is :func:taskq.insights.fetch_queue_imbalance's
utilization column — due depth ÷ effective capacity (routed actor
capacity x live workers) — exported as a first-class gauge so a
scaling operator reads it straight off the scrape instead of
recomputing it client-side (the computation needs actor_config,
a table the scraper does not have). A queue whose effective capacity
is zero reports NO utilization series — the insights row's NULL, the
starvation shape the depth + live_workers pair (and
taskq.jobs.stranded) already expose; a frozen zero there would
read as "idle", the opposite of the truth.
Source code in src/taskq/obs/_otel.py
update_ratelimit_bucket_tokens_cache ¶
Replace the rate-limit bucket-tokens cache with fresh peek data.
Fed by the leader's rate-limit sampler every 15s, keyed
(bucket, kind); keyed-materialised buckets arrive PRE-AGGREGATED
under bucket='_other_' with their own kind, so the cache's shape
is the series set and nothing at scrape time can mint a per-key
series.
Source code in src/taskq/obs/_otel.py
update_reservation_slots_cache ¶
Replace the reservation-slots cache with fresh data from the leader's PG query.
Called by the background async task in the leader loop every 15s. The synchronous gauge callback reads from this cache.
Source code in src/taskq/obs/_otel.py
update_running_lease_expired_cache ¶
Record the count of running jobs whose lock lease is past.
Fed by the backlog sampler (one statement beside jobs-by-status and oldest-due-age). The zombie-running shape, work claimed, lease expired, row still 'running', is invisible in jobs.by_status (it counts as a healthy running job) and in the miss counters (a dead worker emits nothing): this gauge is the direct count. A healthy fleet reads 0 (the reclaim sweep drains expired leases within a tick or two of expiry), so a SUSTAINED non-zero reading means reclaim is not draining. Rows with a cancel in flight (cancel_phase != 0) are carved out: the reclaim sweep deliberately waits out the cancel grace ladder for them, so their lease expiring mid-cancel is the protocol working, not a zombie: a cancel that never completes pages elsewhere (TaskQAbandonedJobs when its worker is alive to escalate through the phases, TaskQHeartbeatMisses when it died mid-cancel; reclaim honors the row to 'cancelled' either way). No dimensions: the fleet total is the alertable shape, and the per-job truth (locked_by_worker, lock_expires_at) lives on the row and the admin jobs page, not on a label.
Source code in src/taskq/obs/_otel.py
update_scheduled_count_cache ¶
Record the current count of scheduled-status jobs.
Emitted label-less (unlike taskq.jobs.by_status, which carries a
status label for every status) specifically so it joins on identical
label sets with taskq.jobs.oldest_due_age_seconds, also label-less ,
in TaskQScheduledBacklogGrowing. That alert needs both "the oldest
due job has waited a long time" AND "the scheduled count is actually
rising", not the age of a single straggling job (which climbs
monotonically toward its own promotion regardless of how healthily
everything behind it drains). A vector and/comparison between two
taskq_* series with mismatched label sets is a silent no-op join ,
valid PromQL that can never produce a result, so this gauge exists
to keep the two operands directly comparable without a join modifier.
Source code in src/taskq/obs/_otel.py
update_scheduled_horizon_cache ¶
Record how far out the furthest scheduled job is armed.
MAX(scheduled_at) - now over the scheduled population, sampled by
the backlog detector's tick beside its label-less siblings. 0.0 when
nothing is scheduled; NEGATIVE when even the furthest-armed job is
already overdue, the promotion-stall shape where every scheduled row
has crossed its wake time. Label-less per the twin convention
(taskq.jobs.scheduled_count): it joins the other label-less
backlog operands under vector and without a join modifier.
Source code in src/taskq/obs/_otel.py
update_stranded_jobs_cache ¶
Replace the stranded-jobs cache with fresh data from the leader's query.
Stranded jobs are pending/scheduled rows that can never dispatch: the
actor has no actor_config row (the dispatch CTE derives its
candidates from per_actor_capacity, which is FROM actor_config),
or the row sits on a queue no LIVE registered worker serves (dispatch
probes only its own subscription's queues, and a worker whose
last_seen_at has gone stale is not dispatching). Both shapes
accumulate invisibly to dispatch and the deadline sweep. Keyed by
(actor, reason) so the gauge says which condition held, the two
have different remediations (register the actor vs. subscribe a
worker to the queue), and a per-actor total made an operator who
found the actor_config row present conclude the detector lied.
This gauge exists because a log line alone is invisible in metrics: the detector's WARN fires exactly once per actor per process lifetime -- so the condition is invisible in metrics and its only trace is a single WARN at onset, which is the moment nobody is looking. An empty mapping clears the gauge, so recovery is visible too.
Source code in src/taskq/obs/_otel.py
update_sweep_batch_size_cache ¶
Replace the recorded batch size for sweep_name (cache-push gauge).
Rebind, never write in place, same cross-thread reader discipline as
:func:record_sweep_success.
Source code in src/taskq/obs/_otel.py
record_exception_safe ¶
Record exc on span without leaking row values or credentials.
The event-only form of :func:record_exception_text, for a call site that
sets the span status itself.
Source code in src/taskq/obs/_redact_exc.py
record_exception_text ¶
Mark span failed by text: ERROR status described by the message, plus the event.
render_exception ¶
Render and scrub exc once, for every sink that reports it.
A traceback's final line is the exception repr, so the same DETAIL text reappears there if it is not stripped. Chained causes are included, so each of their messages needs the same treatment -- hence scrubbing the rendered string rather than only the head exception.
Caveat: a traceback also quotes the SOURCE LINE of each frame. Those come from TaskQ's own source, not from data, so they carry no row values -- but a secret written as a literal in application code would appear. Do not put credentials in source.
Source code in src/taskq/obs/_redact_exc.py
safe_exception_message ¶
Exception text with the Postgres DETAIL dropped and URI creds masked.
The primary Postgres message is kept: it is a static template naming the
constraint or relation, which is the part that is actually diagnostic.
HINT and CONTEXT are kept for the same reason -- neither carries
row values, and both are what an operator reads next.
The str() is guarded: the exception is uncontrolled input (an actor
raises it), and an exception whose own __str__ raises would otherwise
convert HERE, inside the handler that was classifying it, into an uncaught
TypeError that escapes the classification entirely. The fallback
mirrors CPython's own traceback rendering for the same condition
("
Source code in src/taskq/obs/_redact_exc.py
safe_repr ¶
repr(exc) that cannot raise, for uncontrolled exceptions.
The except-handler logs that render repr(exc) (hook failures,
classifier failures) would otherwise have their own swallow converted
into an escape by a __repr__ that raises - the same defect shape as
:func:safe_str, one level deeper. The fallback keeps the class name
because it is the one diagnostic that survives (pinned), but the NAME
ACCESS is guarded too: a metaclass can define __name__ as a
property that raises, so even the interpolation is uncontrolled input
and degrades to <unknown>.
Source code in src/taskq/obs/_redact_exc.py
safe_str ¶
str(exc) that cannot raise, for uncontrolled exceptions.
The one conversion every sink of raw exception text shares: a hostile
or broken __str__ (an actor's exception class is the attacker's
code) must degrade to a constant marker instead of raising a fresh
exception inside a caller's except block and escaping the
classification that was running. Unlike :func:safe_exception_message
this is the RAW text (no scrub, no bound) - call sites that derive
stored/log fields from it apply their own guards
(sanitize_nul_str), and the render-once invariant
(render_exception is the single scrub) is not paid twice. The
fallback marker mirrors CPython's own traceback rendering for the same
condition (measured on 3.14: format_exception survives and prints
Boom: <exception str() failed>), so a reader sees one idiom. The
catch is a BARE except BaseException for the same reason CPython's
traceback._safe_string uses one: a __str__ may raise any
BaseException subclass, and except Exception would let it
convert inside the guard. Swallowing a CancelledError here is safe:
this is a string-rendering helper, never an await point, so there is no
suspension it could strand.
Source code in src/taskq/obs/_redact_exc.py
set_exception_message_max_chars ¶
Set the module-level bound on scrubbed message text.
Mirrors :func:set_exception_redaction_enabled: a module global set once
at worker startup, so the obs layer needs no import of settings.
Source code in src/taskq/obs/_redact_exc.py
set_exception_redaction_enabled ¶
Set the module-level exception-redaction flag.
Mirrors :func:taskq.obs.set_otel_enabled: worker startup calls this once
after loading WorkerSettings so every scrub site reads a module global
instead of importing settings (which would be a circular import from
the modules that depend on obs).
Passing False disables the DETAIL drop on BOTH the span and the log
channel -- they share :func:_scrub_text, so the toggle cannot be applied
to one and not the other. It does NOT disable the URI credential mask.
Source code in src/taskq/obs/_redact_exc.py
bind_job_context ¶
bind_job_context(
log: BoundLogger,
*,
job_id: UUID,
actor: str,
queue: str,
attempt: int,
identity_key: str | None,
trace_id: str,
span_id: str | None = None,
batch_id: str | None = None,
) -> structlog.stdlib.BoundLogger
Bind job-scope fields to a logger, returning a new immutable BoundLogger.
identity_key, span_id, and batch_id are omitted from the bound
dict when None, not set to null or empty string . trace_id
is always bound (defaults to "" when no active OTel span per spec).
Returns a new BoundLogger; does not mutate the input.
Source code in src/taskq/obs/_structlog.py
get_logger ¶
Return a structlog.stdlib.BoundLogger for the given dotted name.
Replaces direct structlog.get_logger() calls in library code so that
pyright strict mode gets an explicit return type (structlog.get_logger
returns Any).
Source code in src/taskq/obs/_structlog.py
log_cancel_phase_change ¶
log_cancel_phase_change(
log: BoundLogger,
*,
from_phase: int,
to_phase: int,
**extra: object,
) -> None
Emit an INFO log line with kind="cancel_phase_change".
from_phase and to_phase are the cancel-phase integers before
and after the escalation. cancel_observed_at is NOT included, it
is per-handler context, not part of the canonical schema.
Source code in src/taskq/obs/_structlog.py
log_state_change ¶
Emit an INFO log line with kind="state_change".
from_state and to_state are the job-status values before and
after the transition. All bound fields from the pre-bound log
(which carries job context from :func:bind_job_context) are included
automatically. The event name is "state-change" so the log is
queryable by both event and kind.
Source code in src/taskq/obs/_structlog.py
redact_payload ¶
Return the first 16 characters of the SHA-256 hex digest of the JSON-serialized payload.
Raw payload content does not appear in the return value. Deterministic for the same input.
Source code in src/taskq/obs/_structlog.py
setup_logging ¶
setup_logging(
*,
level: str = "INFO",
log_format: str = "json",
events_level: str = "info",
) -> None
Configure structlog with the canonical processor chain.
Production (log_format="json"): JSONRenderer via
ProcessorFormatter stdlib bridge. Development (log_format="console"):
ConsoleRenderer via ProcessorFormatter. Idempotent, guarded
by _logging_configured flag. Not called at import time (the
pre-setup default chain :func:_install_default_chain is installed
at import instead, so an embedding application that never calls this
still gets level-filtered logging).
events_level: the TASKQ_LOG_EVENTS_LEVEL value (validated by
:func:set_events_level, which raises ValueError on a typo).
Applied by the _events_level_filter processor in the shared
chain; the default info is today's stream byte-identical.
Source code in src/taskq/obs/_structlog.py
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 | |
invoke_error_reporter
async
¶
invoke_error_reporter(
reporter: ErrorReporter | None,
job: JobRow,
exception: BaseException,
timeout: float = 3.0,
*,
log: BoundLogger | None = None,
) -> None
Invoke reporter.report(), swallowing and counting failures.
A None reporter is treated as a no-op (equivalent to
:class:NullErrorReporter). Exceptions from report() are caught,
logged at WARNING, and counted on the taskq.error_reporter.failures
counter, they never propagate to the caller.
This mirrors the defensive pattern of
:func:~taskq.retry.invoke_on_retry_exhausted: a user-supplied hook
must never crash the worker's terminal-write path.