Skip to content

refactor(otel): build providers in a postfork hook via worker_id dispatch (MAPCO-11222) - #88

Merged
razbroc merged 4 commits into
masterfrom
feat/otel-postfork-init
Aug 2, 2026
Merged

refactor(otel): build providers in a postfork hook via worker_id dispatch (MAPCO-11222)#88
razbroc merged 4 commits into
masterfrom
feat/otel-postfork-init

Conversation

@razbroc

@razbroc razbroc commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
Question Answer
Bug fix
New feature
Breaking change
Deprecations
Documentation
Tests added
Chore

Related issues: MAPCO-11222, MAPCO-11221 (sub-tasks of MAPCO-11217)

Further information:

What this does

Moves TracerProvider / MeterProvider construction out of import scope into _init_telemetry(), and adds a dispatch block at the bottom of src/app.py that decides when to call it based on which process did the import:

Import context uwsgi.worker_id() Behaviour
uWSGI master (lazy-app = false) 0 defer to a postfork hook
uWSGI worker (lazy-app = true) >= 1 initialise eagerly — fork already happened
not under uWSGI (compose, tests) ImportError initialise eagerly

Instrumentors are still installed at import time. They bind to ProxyTracer objects that resolve once a real provider is set, so the seven explicit tracer_provider= kwargs became redundant and were removed. Both providers are now flushed from an atexit handler so the final batch is exported on worker recycle.

Why this is safe to merge on its own

This PR does not change runtime behaviour. The chart still ships lazy-app = true, so the module is imported in the worker, worker_id() is >= 1, and telemetry initialises eagerly exactly as it does today. The postfork path is dormant until lazy-app is flipped, which is the stacked follow-up PR.

That split is deliberate: the dispatch is what removes the "must be atomic" constraint that MAPCO-11223 was originally written around.

Correction to previously documented behaviour

The readme claimed a master-initialised BatchSpanProcessor has its background export thread "die silently on fork". That is not accurate, and the claim is removed here.

Verified against the SDK pinned in this image (1.44.0): BatchSpanProcessor delegates to BatchProcessor in opentelemetry.sdk._shared_internal, which registers an os.register_at_fork handler that restarts the export thread in the child. A span emitted in a forked child was exported with no force_flush. PeriodicExportingMetricReader is protected the same way, and _ProxyHistogram was confirmed to resolve post-fork.

Worth knowing if you re-check this: in SDK 1.7.1 the handler lived in opentelemetry.sdk.trace.export, so grepping that module on a current SDK returns zero and makes the protection look absent. That module move is what produced the wrong claim in the first place.

So the remaining justifications for building post-fork are narrower than the readme implied:

  • gRPC channels are not fork-safe — grpc-python does not support forking with live channels. This is the binding constraint; the SDK's at-fork handling restarts threads but does not rebuild an inherited channel.
  • Not depending on private SDK internals_shared_internal is private and the machinery has already moved once between versions.

Testing

Built as raster/docker-mapproxy:v2.0.0-postfork-test and deployed to raster-dev as an isolated release. With lazy-app = false (i.e. the follow-up PR's config) the load order was confirmed from logs:

[otel] imported in uWSGI master (lazy-app=false) - telemetry deferred to postfork hook
WSGI app 0 (mountpoint='') ready in 13 seconds on interpreter ... pid: 1
spawned uWSGI worker 1 (pid: 25) ... spawned uWSGI worker 6 (pid: 30)

followed by the collector probe, TracerProvider and MeterProvider init lines once per worker (6x each). Tiles served byte-identical output to the deployed v1.9.2 — same 2662-byte 256x256 PNG for the same WMTS tile.

Not verified — stated explicitly so the green result is not over-read:

  • Spans confirmed arriving in the collector backend. Only "collector reachable, no export errors across ~150 requests" was observed. Not the same claim.
  • The atexit flush on a real worker recycle.
  • The lazy-app = true arm as a controlled A/B, because lazy-app is hardcoded in the chart ini rather than exposed as a Helm value. See MAPCO-11224.
  • The non-uWSGI eager-init path under docker-compose.

Review notes

  • No automated tests exist for app.py in this repo, and CI has tests and openapi-lint gated off with if: false. This PR does not add a test harness; that would be a larger change than the refactor itself.
  • The OTel packages are installed unpinned in the Dockerfile. Since the correctness argument above is version-sensitive, that is worth a separate look.

…hook

Move the TracerProvider/MeterProvider construction out of import scope
into _init_telemetry(), and dispatch on uwsgi.worker_id() at the bottom
of the module to decide when to call it:

  - imported in the uWSGI master (lazy-app = false) -> defer via postfork
  - imported in a worker (lazy-app = true)          -> initialise eagerly
  - not under uWSGI at all                          -> initialise eagerly

Instrumentors are installed at import time and bind to ProxyTracer
objects, which resolve once a real provider is set, so the explicit
tracer_provider= kwargs are no longer needed and were removed.

Both providers are flushed from an atexit handler so the final batch is
exported when a worker recycles.

This commit is behaviour-preserving under the chart's current
lazy-app = true: the module is imported in the worker, so telemetry
initialises eagerly exactly as before. Flipping lazy-app is a separate
change.

Also corrects the readme's claim that a master-initialised
BatchSpanProcessor has its export thread die silently on fork. Verified
false against SDK 1.44.0, where BatchSpanProcessor delegates to
BatchProcessor in opentelemetry.sdk._shared_internal and an at-fork
handler restarts the thread in the child.

Refs: MAPCO-11222, MAPCO-11221
razbroc added 2 commits July 27, 2026 16:28
…claim

Three review findings from PR #88.

1. uwsgidecorators raises a bare Exception, not ImportError, when the uWSGI
   master is disabled:

       if uwsgi.masterpid() == 0:
           raise Exception("you have to enable the uWSGI master process ...")

   The dispatch caught only ImportError, so any uWSGI run without
   `master = true` died while importing app.py — and with `need-app = true`
   uWSGI then refuses to boot. Before this refactor the module never imported
   uwsgi at all, so this was a regression. The deployed chart sets
   master = true, so dev and prod were unaffected; debug runs and
   consumer-supplied inis were not.

   It was also imported unconditionally, including on the worker branch where
   postfork is never used. Now imported only in the master branch, wrapped in
   `except Exception`, falling back to eager init so a masterless worker gets
   telemetry rather than none.

2. The atexit flush was documented as guaranteeing the final batch is exported
   on worker recycle. It does not. uWSGI's python plugin skips Py_Finalize(),
   and therefore all atexit handlers, when the worker is hijacked, is busy in a
   request, or runs async; SIGKILL paths (harakiri, worker-reload-mercy expiry)
   bypass it entirely. Softened to best-effort in both the readme and the
   _shutdown_telemetry docstring, with the conditions spelled out.

3. The readme described the postfork hook as the shipped path, which is not
   true while the chart has lazy-app = true. Reworded to lead with the
   worker_id dispatch and to stay accurate under either setting.

Refs: MAPCO-11221, MAPCO-11222
…uilt one

_init_telemetry() assigned the module global before calling set_tracer_provider
/ set_meter_provider, then reassigned it to a fresh fallback provider in the
except handler. Both setters are FIRST-WINS -- a second call logs "Overriding of
current TracerProvider is not allowed" and does nothing (verified against the
SDK in the image).

So if anything raised *after* the set succeeded -- a broken log handler from
log.ini is the realistic trigger, since an _otel_log call sits between the set
and the end of the block -- the global ended up pointing at an inert fallback
while the real provider stayed globally installed. _shutdown_telemetry() then
shut down the orphan and never flushed the live BatchSpanProcessor queue: the
exact opposite of what the atexit handler exists to do.

Both blocks now build into a local, and a finally clause binds the global from
trace.get_tracer_provider() / metrics.get_meter_provider() so it always tracks
whatever is actually installed. A Proxy*Provider means nothing was installed and
has no shutdown(), so the global is left None -- which _shutdown_telemetry
already skips.

Reproduced against the image's SDK: under the old pattern the global was not the
live provider; under the new one it is, and calling shutdown() on it flushes a
span that the orphan would have dropped.

Refs: MAPCO-11221, MAPCO-11222
CL-SHLOMIKONCHA
CL-SHLOMIKONCHA previously approved these changes Aug 2, 2026
@CL-SHLOMIKONCHA

Copy link
Copy Markdown
Collaborator

Please create ticket about the app.py telemetry support - separate relevant method from app.py)

…CO-11223) (#89)

* perf(uwsgi): set lazy-app = false so the master preforks the app

Flip the chart's uwsgi ini to load mapproxy.yaml once in the master and
fork workers from it, so the parsed config is shared copy-on-write
instead of being parsed once per worker.

Safe only on top of the _init_telemetry() dispatch: app.py detects that
it was imported in the master and defers provider construction to a
postfork hook, so each worker still gets its own export threads and its
own gRPC channels.

The replaced comment claimed the opposite of the value it annotated
("Fork workers after app load (required for OTel)" on lazy-app = true)
and carried a stale todo.

Refs: MAPCO-11223, MAPCO-11221

* Update helm/config/mapProxyUwsgi.ini

Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com>

---------

Co-authored-by: Shlomi k <65117898+CL-SHLOMIKONCHA@users.noreply.github.com>
@razbroc

razbroc commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@razbroc
razbroc merged commit 4d30bc1 into master Aug 2, 2026
4 checks passed
@razbroc
razbroc deleted the feat/otel-postfork-init branch August 2, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants