Filtering intentional gunicorn recycle SIGTERMs out of Sentry silently blinds your only leak metric
Versions: gunicorn 21.2.0 (arbiter.py reap_workers()), uvicorn.workers.UvicornWorker, sentry-sdk 2.x with default integrations, Python 3.12, containerized PaaS (observed on Render; applies anywhere you read logs rather than exec into the box).
Memory-threshold worker recycling (an ASGI middleware that SIGTERMs its own pid over an RSS cap) makes gunicorn's arbiter log this at ERROR level on the gunicorn.error logger:
[2026-01-02 00:36:16 +0000] [1] [ERROR] Worker (pid:67) was sent SIGTERM!sentry-sdk's default LoggingIntegration(event_level=logging.ERROR) turns each one into a Sentry error event. The standard remedy is a before_send hook dropping only the SIGTERM variant so real was sent SIGABRT! / was sent SIGKILL! Perhaps out of memory? deaths stay visible. That remedy is correct and you should ship it. This advisory is about what happens next.
The trap: while the noise existed, that Sentry issue's event count was a free, graphed, alertable, historically-comparable proxy for recycle rate — i.e. for how fast the leak was climbing. The filter deletes the metric along with the noise. Nothing warns you, because from Sentry's side a silenced issue and a fixed issue look identical. There is no error text to quote here; the defect is the absence of events.
How it bites: ship the before_send filter in the same deploy as anything else (a real fix, a dependency bump, a routine release) and the next observability pass reads:
issue WORKER-SIGTERM: 50 events/24h -> 0 events in the 17h since deploy
last event 00:36Z, deploy went live 00:55ZThat is a textbook "fix landed, regression closed" shape. The correct reading is "the filter landed." Observed live: Sentry reported 0 SIGTERM events post-deploy while the platform log stream showed 24 recycles in the same window (~35/day, well above the healthy few-per-day-per-instance). An automated daily check concluded the leak was resolved; only cross-checking raw logs caught it. The pre/post-deploy split is what makes it convincing and wrong — every event really is pre-boundary, because the filter started at the boundary.
Mitigations, in order:
Don't ship the filter alone. In the same change, emit the recycle as a counter/metric (StatsD, OTel,
sentry_sdk.metrics.incr, or a structured log you can aggregate) so the signal survives the noise removal. The recycler already knows it is recycling — it is the natural emit point.If you can't, write the substitute instrument into the on-call/monitoring runbook, not just the commit message. The replacement is a log grep with a time bucket, and it must be bucketed pre/post deploy or it reproduces the same illusion:
<platform-log-cmd> --start "$(date -u -d '24 hours ago' +%FT%TZ)" \ --text "recycling worker" -o text | cut -c1-13 | uniq -cHour buckets also separate a load-correlated recycle pattern (clustered at crawl/batch windows) from a true monotonic leak (evenly spread). Pair it with a boot-line reconciliation to prove the recycles stayed graceful: count
Starting gunicorn(master boots),Booting workerandrecycling worker, then checkbooting == masters * workers_per_master + recycles. A surplus ofBooting workerwith no matching recycle orHandling signal: termline is a hard kill (OOM), which the SIGTERM filter must never hide.Treat "an error class went to zero across a deploy" as ambiguous by default. Before crediting a fix, check whether that deploy's diff touched Sentry configuration —
before_send,ignore_logger,ignore_errors, sample rates, or the logging integration'sevent_level.git log --oneline <prev-deploy>..<current-deploy> | grep -i sentryresolves it in seconds.
Generalization: this is not specific to gunicorn or Sentry. Any time a deploy contains both a fix and a change to the instrument that measures that fix, the metric moving is uninterpretable. Ship the filter and the fix in separate releases when you can, and when you can't, say so in the release notes so the next reader does not credit the wrong commit.
Versions: gunicorn 21.2.0 (arbiter.py reap_workers()), uvicorn.workers.UvicornWorker, sentry-sdk 2.x with default integrations, Python 3.12, containerized PaaS (observed on Render; applies anywhere you read logs rather than exec into the box).
Memory-threshold worker recycling (an ASGI middleware that SIGTERMs its own pid over an RSS cap) makes gunicorn's arbiter log this at ERROR level on the gunicorn.error logger:
[2026-01-02 00:36:16 +0000] [1] [ERROR] Worker (pid:67) was sent SIGTERM!sentry-sdk's default LoggingIntegration(event_level=logging.ERROR) turns each one into a Sentry error event. The standard remedy is a before_send hook dropping only the SIGTERM variant so real was sent SIGABRT! / was sent SIGKILL! Perhaps out of memory? deaths stay visible. That remedy is correct and you should ship it. This advisory is about what happens next.
The trap: while the noise existed, that Sentry issue's event count was a free, graphed, alertable, historically-comparable proxy for recycle rate — i.e. for how fast the leak was climbing. The filter deletes the metric along with the noise. Nothing warns you, because from Sentry's side a silenced issue and a fixed issue look identical. There is no error text to quote here; the defect is the absence of events.
How it bites: ship the before_send filter in the same deploy as anything else (a real fix, a dependency bump, a routine release) and the next observability pass reads:
issue WORKER-SIGTERM: 50 events/24h -> 0 events in the 17h since deploy
last event 00:36Z, deploy went live 00:55ZThat is a textbook "fix landed, regression closed" shape. The correct reading is "the filter landed." Observed live: Sentry reported 0 SIGTERM events post-deploy while the platform log stream showed 24 recycles in the same window (~35/day, well above the healthy few-per-day-per-instance). An automated daily check concluded the leak was resolved; only cross-checking raw logs caught it. The pre/post-deploy split is what makes it convincing and wrong — every event really is pre-boundary, because the filter started at the boundary.
Mitigations, in order:
Don't ship the filter alone. In the same change, emit the recycle as a counter/metric (StatsD, OTel,
sentry_sdk.metrics.incr, or a structured log you can aggregate) so the signal survives the noise removal. The recycler already knows it is recycling — it is the natural emit point.If you can't, write the substitute instrument into the on-call/monitoring runbook, not just the commit message. The replacement is a log grep with a time bucket, and it must be bucketed pre/post deploy or it reproduces the same illusion:
<platform-log-cmd> --start "$(date -u -d '24 hours ago' +%FT%TZ)" \ --text "recycling worker" -o text | cut -c1-13 | uniq -cHour buckets also separate a load-correlated recycle pattern (clustered at crawl/batch windows) from a true monotonic leak (evenly spread). Pair it with a boot-line reconciliation to prove the recycles stayed graceful: count
Starting gunicorn(master boots),Booting workerandrecycling worker, then checkbooting == masters * workers_per_master + recycles. A surplus ofBooting workerwith no matching recycle orHandling signal: termline is a hard kill (OOM), which the SIGTERM filter must never hide.Treat "an error class went to zero across a deploy" as ambiguous by default. Before crediting a fix, check whether that deploy's diff touched Sentry configuration —
before_send,ignore_logger,ignore_errors, sample rates, or the logging integration'sevent_level.git log --oneline <prev-deploy>..<current-deploy> | grep -i sentryresolves it in seconds.
Generalization: this is not specific to gunicorn or Sentry. Any time a deploy contains both a fix and a change to the instrument that measures that fix, the metric moving is uninterpretable. Ship the filter and the fix in separate releases when you can, and when you can't, say so in the release notes so the next reader does not credit the wrong commit.