Running Continuous Profiling for Python Services

The argument for continuous profiling is that the profile exists before anybody knows it will be needed. That only holds if the agent is actually running on every instance, costing what was agreed, and labelled well enough to be found. This page covers the deployment details that decide all three: where the agent starts under each process model, which settings matter, how to roll it out, and how to notice when it has quietly stopped. It is a task article under continuous profiling in production, part of the Python profiling and performance observability section.

Why the agent must start after the fork Two arrangements of a prefork server with four workers. In the first, the profiling agent is started in the master process at import time, creating a sampling thread. The master then forks four workers. Threads are not copied by a fork, so each worker has the agent's configuration and state in memory but no sampling thread, and none of them ever produces a profile; the master, which does almost no work, is the only process profiled. In the second arrangement the agent is started from a post-fork hook, once in each worker, so each has its own sampler, its own uploads and its own instance label, and the master is not profiled at all because it has nothing worth profiling. The note records that the first arrangement produces no error and simply yields empty or misleading profiles. the same four workers, the agent started in two places started in the master master sampler thread worker · no thread worker · no thread worker · no thread worker · no thread threads do not survive fork — only the idle master is profiled started in a post-fork hook master · not profiled worker · sampler worker · sampler worker · sampler worker · sampler the first arrangement raises no error — it produces profiles of a process that does nothing
A sampler is a thread, and threads do not cross a fork. The agent has to start in the process that does the work.

Prerequisites

The specific agent package depends on the chosen backend; the application requirements are the same for all of them.

pip install "opentelemetry-sdk>=1.27.0,<2.0.0"
export OTEL_SERVICE_NAME=checkout
export SERVICE_VERSION=2026.09.18
export ENVIRONMENT=prod
export PROFILER_ENDPOINT=http://localhost:4040

Implementation

Step 1 — Start the agent where the work happens. For a single-process service, at application startup. For a prefork server, in each worker after the fork — the master's sampler thread is not copied into the workers, so an agent started in the master profiles only the master. For a Celery worker pool, in the worker process initialisation signal, for the same reason. Getting this wrong produces no error at all; it produces profiles of a process that does nothing, which is worse than no profiles because it looks like data.

# gunicorn.conf.py
import os

def post_fork(server, worker):
    # 1. One sampler per worker, created after the fork so its thread exists here.
    from profiling_agent import start
    start(
        service=os.environ["OTEL_SERVICE_NAME"],
        version=os.environ["SERVICE_VERSION"],
        environment=os.environ["ENVIRONMENT"],
        instance=f"{os.environ.get('HOSTNAME', 'local')}-w{worker.age}",
        endpoint=os.environ["PROFILER_ENDPOINT"],
        sample_rate_hz=100,
        upload_period_s=60,
    )

Step 2 — Drive every label from the environment the tracer already uses. The profile's service, version and environment must match the tracing resource exactly, or a profile cannot be found from a trace. Reading both from the same variables makes that structural rather than a matter of discipline. The version label in particular is what makes post-deploy comparison possible, so it must change with every release.

Step 3 — Measure one service before extending. Run the agent on one service for a full day — including its peak — and compare CPU per request, latency percentiles and resident memory against the same service without it. The difference is the real overhead, and it should sit inside the budget agreed in profiling overhead budgets and safety.

# CPU per request, with and without the agent, same service
sum by (profiling) (rate(process_cpu_seconds_total{service="checkout"}[1h]))
  /
sum by (profiling) (rate(http_server_requests_total{service="checkout"}[1h]))

Expected Output: a difference of a percent or two.

profiling="off"   0.00412 CPU-seconds per request
profiling="on"    0.00419 CPU-seconds per request   (+1.7%)

Step 4 — Extend by value, not alphabetically. The services investigated most often, the ones with the most complex code paths and the ones with the most expensive CPU footprint return the most from profiling. Starting there means the rollout pays for itself early, and the evidence it produces makes the case for the rest.

Step 5 — Confirm the agent never blocks the application. Stop the profiling backend in staging and verify the application's latency does not move. A well-behaved agent buffers briefly and drops, exactly as a telemetry exporter should; one that blocks on upload is a production risk regardless of its sampling overhead.

Step 6 — Alert on gaps. A service that has not uploaded a profile in several minutes has either never been configured or has an agent that failed. Both look identical to a service that was never profiled, so the only way to notice is to compare the set of services and instances uploading against the set that should be.

# instances expected to profile that have not uploaded recently
count by (service) (up{job="app"} == 1)
  unless on(service)
count by (service) (time() - profile_last_upload_timestamp_seconds < 300)
A rollout that pays for itself early A fleet of services is ordered by how often each is investigated for performance problems. The rollout begins with a single high-value service, where the overhead is measured over a full day and compared against the agreed budget. If it passes, the next stage adds the remaining frequently investigated services, then the services with the largest CPU footprint, and finally the long tail of small, rarely changed services, which may be left out entirely. Each stage is gated on two checks: the overhead is still within budget, and every instance in the stage is uploading profiles. The note records that ordering by value means the first stage already produces investigations that justify the rest. rollout ordered by value, each stage gated 1 service measure a full day most investigated value arrives here largest CPU cost attribution long tail optional each stage is gated on two checks overhead still within the agreed budget, measured per request every instance in the stage is uploading — no silent gaps ordering by value means the first stage already justifies the rest
Ordering the rollout by value means the evidence for continuing arrives from the first stage, rather than after the whole fleet is covered.

Settings that matter and settings that do not

Profiling agents expose many options, and only a few of them have much effect on either the value or the cost.

Sample rate matters. It sets both the resolution and the overhead, roughly proportionally. A hundred hertz is the common default and the right starting point; lower rates are reasonable for services with very large thread counts, where the per-tick cost of walking every thread is higher.

Upload period matters less than it seems. Shorter periods give finer time resolution in the store, which helps when correlating with a short incident, at the cost of more requests to the backend. Anything between ten and sixty seconds works; the choice is about the backend's request budget more than about the application.

Wall-clock mode matters a great deal. It shows blocked time, which is often the more useful picture, and it samples every thread regardless of state, which costs more. Running on-CPU continuously and enabling wall-clock per service where waiting is the usual question is the common compromise.

Span context matters most of all. It is the difference between a whole-process picture and one that can be narrowed to a request, and it is the capability described in linking profiles to traces with span context. Leaving it off saves a small amount of overhead and removes most of the value.

Stack depth limits rarely matter. Python stacks in web services are deep but not pathologically so, and a default limit of a hundred or more frames almost never truncates anything meaningful. Lowering it to save overhead trades away readability for very little.

Process models other than prefork

The post-fork rule generalises, and each common process model has its own place for the agent to start.

A single-process async server. One process, one loop, one sampler. Start the agent in the application's startup hook, and make sure it records span context, because every coroutine shares the loop thread and the stack alone cannot say which request a sample belongs to.

A Celery worker pool. The pool's child processes are forked from the worker's main process, so the same rule applies: start the agent in the child initialisation signal. A Celery worker also runs long-lived tasks whose profiles are more interesting per task than per minute, and labelling samples with the task name through span context makes that view available.

A process pool inside a service. Work sent to a ProcessPoolExecutor runs in child processes that the main process's agent does not cover. If that work is where the CPU goes — which is usually why it is in a process pool — the pool's initialiser is where a second agent belongs, labelled so its profiles are identifiable as the pool's rather than the service's.

A serverless function. An execution environment is frozen between invocations, so a sampler thread cannot run on a schedule, and uploading at the end of each invocation adds to billed time. Most continuous profiling approaches do not suit this model well, and occasional on-demand profiling of a reproduction is usually the better tool.

In every case the verification from above applies: count the instances uploading and compare with the number of processes doing work. A mismatch always means an agent started in the wrong process.

Labels worth attaching to profiles A table of labels to attach to continuous profiles and the comparison each enables. service.name lets profiles from one service be selected. service.version lets profiles before and after a deploy be compared. deployment.environment keeps staging and production apart. The Gunicorn worker or process identifier lets one misbehaving worker be isolated. The route or task name, attached per sample, lets CPU be broken down by endpoint. The note says the same resource attributes used for traces and metrics should label profiles, so all four signals can be filtered the same way. label lets you service.name select one service's profiles service.version compare before and after a deploy deployment.environment keep staging and production apart worker / process id isolate one misbehaving worker route or task name break CPU down by endpoint use the same resource attributes as traces and metrics so all four signals filter the same way
Labels decide which comparisons are possible later. The version label alone makes every deploy reviewable.

Configuration options

Setting Recommended Why
Start location post-fork, per worker threads do not survive fork
Service / version / env from the tracer's variables profiles join traces
Instance label host plus worker id per-worker comparison
Sample rate 100 Hz resolution and cost balanced
Upload period 60 s backend request budget
Span context on per-request filtering
Wall-clock per service, selectively cost scales with threads
Backend unavailable buffer then drop never block the application

Verification

Confirm every expected instance is uploading and that the profiles contain the application's own frames rather than only machinery.

# instances that uploaded in the last five minutes, by service
curl -s "http://profiles.observability.svc:4040/api/v1/instances?since=5m" \
  | python3 -c 'import json,sys; [print(s["service"], s["instances"]) for s in json.load(sys.stdin)]'

Expected Output: instance counts matching the deployment's replica and worker counts.

checkout 16
inventory 8
pricing 12

Sixteen instances for a service with four replicas of four workers is correct. Four would mean the agent started in the master of each replica rather than in its workers — the failure from step 1, visible in one number.

Common mistakes

Starting the agent in the prefork master. Error signature: profiles showing only accept loops and supervision. Root cause: the sampler thread does not exist in forked workers. Remediation: start it in a post-fork hook.

Labels that differ from the tracer's. Error signature: no link from traces to profiles. Root cause: the profiler reads a different service name or version. Remediation: drive both from the same environment variables.

Rolling out fleet-wide without measuring. Error signature: a fleet CPU increase discovered on the bill. Root cause: the overhead was assumed. Remediation: measure one service for a full day first.

An agent that blocks on upload. Error signature: application latency rising during a profiling backend outage. Root cause: synchronous upload on a path the application waits for. Remediation: verify drop-on-failure behaviour in staging before rollout.

No gap alert. Error signature: a service discovered months later to have never uploaded. Root cause: a failed agent looks like an unprofiled service. Remediation: compare uploading instances against running instances continuously.

Frequently Asked Questions

Where should the profiler be started in a prefork server?

In each worker, after the fork. Threads do not survive a fork, so a sampler thread started in the master is absent from every worker. Most servers provide a post-fork hook, and that is where the agent belongs.

Does continuous profiling work with asyncio?

Yes, and span context matters more there than anywhere, because every coroutine shares the loop thread's stack. An agent that records the active span with each sample is the only reliable way to attribute a sample to a request.

What happens if the profiling backend is down?

A well-behaved agent buffers a small amount and then drops, exactly like a telemetry exporter. It should never block the application. Confirming this behaviour by stopping the backend in staging is worth doing before rollout.

How do I know the agent is running everywhere it should be?

Count services and instances that have uploaded a profile in the last few minutes, and compare with the list that should have. A gap is either a service that was never configured or an agent that failed silently.