Graceful Shutdown and Telemetry Flush
The spans and log records produced in a process's final seconds describe why it stopped, which makes them the most valuable telemetry it ever produces and the most likely to be lost. This page covers the handler, the ordering and the grace period that get them out, for long-running servers, prefork workers and processes whose whole life is shorter than one export interval. It is a task article under backpressure, retries and delivery guarantees, part of the Python telemetry pipelines and delivery section.
Prerequisites
pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
"opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"
Implementation
Step 1 — Install a handler for the signal your platform actually sends. Container platforms send a termination signal and then wait; a process with no handler receives the default disposition, which ends it immediately. Installing a handler is what buys the window. Note that the handler runs on the main thread between bytecode instructions, so it must not block indefinitely — which is why every call inside it needs a timeout.
import signal
import threading
_shutting_down = threading.Event()
def _on_term(signum, frame):
_shutting_down.set() # cooperative: the server loop checks this
signal.signal(signal.SIGTERM, _on_term)
signal.signal(signal.SIGINT, _on_term)
Step 2 — Stop accepting work before flushing. This ordering is the part that is most often wrong, and the failure is subtle: a service that flushes immediately on the signal, then drains, has shut down its providers before the drain produces its telemetry. The result is a clean shutdown with no record of it, which is worse than no handler at all because it looks correct. Stop accepting, let in-flight work finish, then flush.
# FastAPI / Starlette lifespan — the ordering made explicit
from contextlib import asynccontextmanager
from opentelemetry import trace, metrics, _logs
@asynccontextmanager
async def lifespan(app):
yield
# 1. Reached only after the server has stopped accepting and drained.
# 2. Now the last spans and log records exist, so flush them.
trace.get_tracer_provider().shutdown()
metrics.get_meter_provider().shutdown()
_logs.get_logger_provider().shutdown()
Step 3 — Shut down every provider. Each signal has its own provider, its own queue and its own export thread, and shutting down the tracer provider does nothing for the metrics or logs queues. A service that flushes only traces loses the metric points and the log records from the same window, which in practice are the ones an engineer looks at first.
Step 4 — Give the grace period room for both phases. The platform kills the process when the grace period expires, wherever it has got to. That period has to cover the longest in-flight request plus the flush. Against a local collector the flush is usually well under a second, so the request drain dominates and the number should come from the service's own latency distribution rather than from a default.
spec:
terminationGracePeriodSeconds: 45
containers:
- name: app
lifecycle:
preStop:
exec:
# 1. Let the load balancer notice before the server stops accepting.
command: ["sh", "-c", "sleep 5"]
Step 5 — Make it idempotent and make it visible. A shutdown path can be entered twice — a signal during an already-running shutdown, or a handler plus an atexit hook — and shutting a provider down twice should be harmless rather than an exception in the final seconds. Emitting a line when the flush completes is equally important: it is the only evidence the flush happened, and its absence in a terminated pod's log is the fastest possible diagnosis.
import logging
import threading
log = logging.getLogger(__name__)
_flushed = threading.Event()
def flush_telemetry(reason: str = "signal") -> None:
if _flushed.is_set():
return # idempotent
_flushed.set()
for name, provider in (
("tracer", trace.get_tracer_provider()),
("meter", metrics.get_meter_provider()),
("logger", _logs.get_logger_provider()),
):
shutdown = getattr(provider, "shutdown", None)
if callable(shutdown):
try:
shutdown()
except Exception: # never block the exit path
log.exception("provider shutdown failed", extra={"provider": name})
log.info("telemetry flushed", extra={"reason": reason})
Expected Output: a terminating pod whose final line proves the window was used.
2026-09-18T14:12:31Z INFO preStop: sleeping 5s for endpoint removal
2026-09-18T14:12:36Z INFO server stopped accepting; 3 requests in flight
2026-09-18T14:12:38Z INFO drain complete
2026-09-18T14:12:38Z INFO telemetry flushed reason=lifespan
The cases that need more than a signal handler
The pattern above covers a long-running server. Three other shapes need something different.
Prefork workers. Under a prefork server each worker is a separate process with its own providers and its own queue, so the handler has to be installed in each worker rather than in the master. The master's own handler typically forwards the signal and then waits, which means the workers get their window — but only if the master's shutdown timeout is longer than the workers' flush. Configuring the two independently, with the master's larger, is the arrangement that works. The related provider-initialisation ordering is covered in Prometheus multiprocess mode with Gunicorn, and the same after-fork rule applies to the tracer provider.
Short-lived jobs. A batch task, a cron job or a data pipeline step may run for less time than one export interval, in which case no scheduled export ever happens and the flush is not a safety net but the only delivery mechanism. The clean pattern is a context manager around the whole job so the flush cannot be forgotten, and for very small jobs a simple span processor that exports on span end is a reasonable alternative — the volume is low enough that synchronous export costs nothing meaningful. This is developed further in logging from cron and batch jobs.
Processes killed without warning. An out-of-memory kill, a node failure or a forced termination runs no handler at all. Nothing in the process can help, which means the only protection is not keeping much in the process: a short export interval and a small queue lose less when the process disappears. This is the one case where a small queue is better than a large one, and it is worth weighing against the buffering arithmetic, since the two pull in opposite directions.
A fourth case deserves a mention because it produces the most confusing symptom: a process that exits normally after its work completes, without any signal. The interpreter runs atexit handlers here, so registering the flush there covers it — but atexit does not run on a signal, and a signal handler does not run on a normal exit. Both paths are needed, which is why the flush function above is written to be idempotent.
Configuration options
| Setting | Where | Typical | Note |
|---|---|---|---|
| Signal handled | application | SIGTERM, SIGINT |
the platform's default is immediate exit |
| Drain before flush | application | always | flushing first loses the drain's telemetry |
| Providers shut down | application | all three | each has its own queue |
terminationGracePeriodSeconds |
pod spec | 45 | longest request plus flush |
preStop sleep |
pod spec | 5 s | lets the load balancer remove the endpoint |
| Worker shutdown timeout | prefork server | below the grace period | master waits for workers |
atexit registration |
application | also | covers a normal exit with no signal |
Verification
Prove the flush happens by looking for its evidence in a pod that has already gone.
# terminate a pod and read its final lines afterwards
kubectl delete pod checkout-7d9f8c5b6-xk2lm
kubectl logs checkout-7d9f8c5b6-xk2lm --previous --tail=5
# and confirm the spans from that window actually arrived
# (query the backend for spans whose service.instance.id matches the dead pod)
Expected Output: the flush line, and spans in the backend timestamped after the termination signal.
2026-09-18T14:12:38Z INFO drain complete
2026-09-18T14:12:38Z INFO telemetry flushed reason=lifespan
spans stored after SIGTERM: 47
Zero spans after the signal, with a flush line present, means the flush ran before the drain — the ordering failure from step 2, which is invisible in the log and obvious in this query.
Common mistakes
Flushing before draining. Error signature: a clean shutdown with no telemetry describing it. Root cause: providers shut down at the moment the signal arrives. Remediation: drain first; flush in the lifespan's exit path or after the server loop returns.
Only the tracer provider is flushed. Error signature: spans from the final seconds present, metrics and logs missing. Root cause: three providers, one shutdown call. Remediation: shut down all three, guarding each so one failure does not skip the others.
A grace period shorter than the drain. Error signature: the process killed mid-flush, with partial telemetry. Root cause: the platform's timeout expiring during the request drain. Remediation: set the grace period from the service's own latency tail plus a second for the flush.
Relying on atexit alone. Error signature: flushes that work locally and never in production. Root cause: atexit does not run when the process is terminated by a signal. Remediation: install a signal handler and register the same idempotent function with atexit.
No evidence the flush ran. Error signature: an argument about whether telemetry was lost or never produced. Root cause: the flush is silent. Remediation: log a line on completion, and treat its absence in a terminated pod's log as the diagnosis.
Frequently Asked Questions
Why does my service lose its last few seconds of telemetry on every deploy?
Because the process exits with records still in the batch processor's queue. The processor exports on an interval, so anything produced since the last export is in memory, and nothing flushes it unless the application calls shutdown explicitly.
Does atexit handle this?
Only for a clean interpreter exit. A process terminated by a signal does not run atexit handlers unless a signal handler causes a normal exit, and a process killed with SIGKILL runs nothing at all. Install a signal handler and treat atexit as a secondary path.
How long should the termination grace period be?
Long enough for the longest in-flight request to finish plus the flush. Thirty to forty-five seconds suits most services; the flush itself is usually under a second against a local collector, so the request drain dominates.
What about short-lived jobs and Lambda functions?
They need the flush structurally rather than defensively, because their whole lifetime can be shorter than one export interval. Call shutdown at the end of the job, or use a simple span processor that exports synchronously if the volume is small.