Tracing, APM and error tracking¶
potatoq ships integrations for OpenTelemetry, Sentry and New Relic. Through OpenTelemetry it also works with Logfire, Datadog, Honeycomb, Grafana, Jaeger and every other backend that accepts OTLP. All of them give you:
- One trace from the request to the task.
.delay()puts the current trace context into the task message's headers, and the worker continues that trace. A task deferred to the end of a transaction carries the context of the.delay()call. - Whole workflows in one trace. A retry, a chain's next step and a chord's callback are sent from inside the running task, so each attempt is a child of the one before.
- A span or transaction per attempt, with the task's name, id, queue, retry count and final state, and how long it waited in the queue after it was due.
- Errors reported once. An attempt that will be retried isn't an error; the last one is. A task whose process died (hard time limit, out of memory) is reported by the worker's supervisor, because the process that ran it can't.
- Nothing lost when a worker process exits. Worker processes end with
os._exit()(as in Celery), which skips the SDKs' own exit hooks; potatoq flushes them first.
Task arguments and results are never recorded (Sentry: only with send_default_pii).
Set it up in every process
Configure tracing in the code that both your web processes and your workers load:
your celery.py, your Django settings or your app factory. The web side injects the
trace context; without it, each task starts a new trace.
OpenTelemetry¶
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from potatoq.contrib.opentelemetry import PotatoqInstrumentor
# Service name, endpoint and headers come from OTEL_* environment variables.
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(tracer_provider)
metrics.set_meter_provider(MeterProvider(metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())]))
PotatoqInstrumentor().instrument()
Import it from your celery.py or settings, next to your web framework's
instrumentation (DjangoInstrumentor, FlaskInstrumentor, FastAPIInstrumentor) and
your database client's, so the task's queries show up inside its span.
Or skip the code: potatoq registers itself with OpenTelemetry's auto-instrumentation.
$ uv add opentelemetry-distro opentelemetry-exporter-otlp-proto-http
$ export OTEL_SERVICE_NAME=shop-worker OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
$ opentelemetry-instrument potatoq worker
What you get¶
Each task message gets a send <task> span (kind PRODUCER) where it's sent, and each
attempt a process <task> span (kind CONSUMER) as its child, with the task's own logs,
queries and HTTP calls inside. Both follow OpenTelemetry's
messaging conventions:
| Attribute | Example | |
|---|---|---|
messaging.system |
potatoq |
|
messaging.destination.name |
default |
the queue |
messaging.message.id |
9f1c… |
the task id |
messaging.message.conversation_id |
4b2a… |
the workflow's root task id |
potatoq.task.name |
shop.tasks.send_receipt |
|
potatoq.task.state |
SUCCESS |
also FAILURE, RETRY, REJECTED, REVOKED, IGNORED |
potatoq.task.retries, potatoq.task.delivery_count |
1, 1 |
delivery_count > 1: redelivered after a crash |
potatoq.task.periodic |
nightly-report |
the beat_schedule entry |
potatoq.task.retry_eta, potatoq.task.reason |
when it retries; why it was rejected or revoked | |
error.type |
ConnectionError |
on failures, with the exception recorded |
Failed and rejected attempts have status ERROR. Retried ones don't, but the exception
is recorded as an event.
Metrics, with the task name, queue and (for durations) final state as attributes:
| Metric | Unit | |
|---|---|---|
messaging.client.sent.messages |
messages | tasks sent |
messaging.process.duration |
s | how long attempts took |
potatoq.task.wait.duration |
s | how long tasks waited in the queue after they were due: alert on this one |
Forking and gRPC¶
The worker forks its processes after your code configured OpenTelemetry. The SDK
restarts its export threads in each process, so this works with the HTTP exporter, as
above. gRPC connections don't survive a fork: with the gRPC exporter (or
OTEL_EXPORTER_OTLP_PROTOCOL=grpc), configure OpenTelemetry in each worker process
instead.
from potatoq import signals
@signals.worker_process_init.connect
def init_telemetry(**kwargs):
... # the provider setup from above
Where to send it¶
Any OTLP backend works: set OTEL_EXPORTER_OTLP_ENDPOINT and
OTEL_EXPORTER_OTLP_HEADERS as your vendor documents. For example:
| Backend | Settings |
|---|---|
| Honeycomb | OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io, OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=<api key> |
| Grafana Cloud, Jaeger, Tempo, SigNoz, Uptrace | the endpoint of your collector or of the service's OTLP gateway |
| New Relic, Dynatrace, Elastic, Splunk | their OTLP endpoints (New Relic also has an agent integration) |
| Azure Monitor | configure_azure_monitor() from azure-monitor-opentelemetry instead of the provider setup |
| AWS X-Ray, Google Cloud Trace | through the AWS Distro for OpenTelemetry or the OpenTelemetry Collector |
Use a different OTEL_SERVICE_NAME for web and worker processes (shop-web,
shop-worker), so you can tell them apart.
Logfire¶
Logfire is built on OpenTelemetry, so the same instrumentor works:
import logfire
from potatoq.contrib.opentelemetry import PotatoqInstrumentor
logfire.configure(service_name="shop-worker", distributed_tracing=True)
PotatoqInstrumentor().instrument()
Set distributed_tracing=True in both the web and the worker processes. Without it,
Logfire warns about "unintentional distributed tracing" when a worker continues a trace
that started elsewhere.
Sentry¶
import sentry_sdk
from potatoq.contrib.sentry import PotatoqIntegration
sentry_sdk.init(
dsn="https://…",
traces_sample_rate=0.1,
integrations=[PotatoqIntegration()],
)
- Errors: a task that fails for good is an issue tagged with the task's name, with
its id, queue and retry count. Breadcrumbs are per task: each attempt gets its own
scope.
SoftTimeLimitExceededis grouped per task. - Tracing: each attempt is a transaction (
queue.task.potatoq) in the trace of the request that sent it, with aqueue.processspan; the sender gets aqueue.publishspan. Sentry's Queues page shows each queue's latency and error rate from these. - Crons (off by default): with
monitor_periodic_tasks=True, everybeat_schedulerun sends check-ins, and the monitors are created from your schedules (crontabs, and intervals of a minute or more), so a run that's missed, fails or hangs alerts you. Sentry bills per monitor, so turn it on only for the entries you need to watch:exclude_periodic_taskstakes regexes of entry names to leave out, for example[r"^(?!nightly-)"]to monitor only entries whose names start withnightly-.
| Option | Default | |
|---|---|---|
propagate_traces |
True |
carry the trace into tasks (sentry-trace and baggage headers) |
monitor_periodic_tasks |
False |
send cron check-ins for periodic tasks |
exclude_periodic_tasks |
None |
regexes of beat_schedule entry names to leave out |
Once Celery is uninstalled, Sentry's own CeleryIntegration no longer turns itself on;
until then, both can be active without interfering.
New Relic¶
import newrelic.agent
newrelic.agent.initialize() # newrelic.ini, or NEW_RELIC_LICENSE_KEY and NEW_RELIC_APP_NAME
from potatoq.contrib import newrelic as potatoq_newrelic
potatoq_newrelic.instrument()
Or start each process with newrelic-admin run-program potatoq worker (which calls
initialize() for you) and call instrument() in your app's code.
Each attempt is a background transaction in the potatoq group, named after the task,
in the distributed trace of the request that sent it. Sending is a
MessageBroker/potatoq/Queue/Produce segment of the request. A task run eagerly inside
a request is part of the request's transaction instead. Transactions carry
potatoq.task_id, potatoq.queue, potatoq.retries, potatoq.delivery_count,
potatoq.state and potatoq.wait_time, for queries and alerts. The agent connects in
each worker process when it starts and sends its data before the process exits.
The agent is never activated in the worker's main process: New Relic reports nothing
from processes forked after activation ("No data will be reported for this process").
Don't call newrelic.agent.register_application() in code the worker loads either, for
the same reason. As a consequence, a task whose process died (hard time limit, out of
memory) isn't reported to New Relic, because the main process is the one that notices.
It's still dead-lettered, logged, and reported to Sentry and OpenTelemetry.
Datadog¶
Datadog's tracer accepts OpenTelemetry's API, so use the
OpenTelemetry integration with ddtrace as the provider:
from ddtrace.trace import tracer
from potatoq import signals
from potatoq.contrib.opentelemetry import PotatoqInstrumentor
PotatoqInstrumentor().instrument()
@signals.worker_process_shutdown.connect
def flush_datadog(**kwargs):
tracer.flush() # ddtrace's provider has no force_flush
Tasks then appear as process <task> spans in the same trace as the request that sent
them, alongside everything else ddtrace instruments.
Other tools¶
- Error trackers without tracing (Rollbar, Bugsnag, Honeybadger, Airbrake): report
from the
task_failuresignal, which fires once per task that fails for good. - Prometheus, StatsD: export the OpenTelemetry metrics above, or record your own from an instrumentation (below).
- Logs: potatoq's worker log records carry
task_idandtask_nameattributes. Sentry, New Relic and Logfire attach logs to the running task automatically. With OpenTelemetry,opentelemetry-instrumentation-loggingadds trace ids to log records; put%(otelTraceID)sinworker_task_log_formatto print them.
Writing your own¶
The integrations are built on two hooks in potatoq.instrumentation, which you can use
too:
import time
from contextlib import contextmanager
from potatoq import instrumentation
class Timings(instrumentation.Instrumentation):
def message_created(self, message):
"""Every task message, where it's sent: add your trace headers to message.headers."""
@contextmanager
def task_run(self, run):
"""Around every attempt (in workers and in eager mode)."""
yield
statsd.timing(f"tasks.{run.name}.{run.state}", run.runtime)
statsd.timing(f"queues.{run.queue}.wait", run.wait_time)
instrumentation.register(Timings())
run is a TaskRun: the task's message (with its headers), name, queue,
retries, delivery_count, periodic, is_eager and wait_time, and once the
attempt is over its state, exception, runtime and retry_eta. process_died is
true when the supervisor reports a task whose process died. An instrumentation that
raises is logged and skipped; it never breaks a task.
Good practices¶
- Sample at the start of the trace. Workers follow the sampling decision made where
the trace started (OpenTelemetry's default
parentbased_*samplers, Sentry'straces_sample_rate), so a request and its tasks are kept or dropped together. Set the rate on the web side (OTEL_TRACES_SAMPLER=parentbased_traceidratio,OTEL_TRACES_SAMPLER_ARG=0.1); periodic tasks start their own traces and are sampled by the worker's settings. - Alert on wait time, not only on failures:
potatoq.task.wait.duration, Sentry's queue latency or New Relic'spotatoq.wait_timeshow a backlog building up before users notice. Set up cron monitors for periodic tasks, so a run that never happens alerts you too. - One tracer per process. OpenTelemetry (with Logfire or Datadog) and New Relic both
use W3C
traceparentheaders: run one of them. Sentry can run next to either one; it keeps the other'sbaggageentries. - Delayed tasks (a long
countdown, a retry with backoff) start long after the span that sent them ended. That's expected: the trace stays connected, it's just long.