Skip to content

FastAPI co-hosting, OpenTelemetry tracing, and per-endpoint web feature flags - #748

Open
wbarnha wants to merge 11 commits into
masterfrom
claude/fastapi-repo-strategy-ez6ssu
Open

FastAPI co-hosting, OpenTelemetry tracing, and per-endpoint web feature flags#748
wbarnha wants to merge 11 commits into
masterfrom
claude/fastapi-repo-strategy-ez6ssu

Conversation

@wbarnha

@wbarnha wbarnha commented Aug 4, 2026

Copy link
Copy Markdown
Member

Description

Three related pieces of work on the web and observability layers:

  1. Run a FastAPI (or any ASGI) application in the same process and event loop as a Faust worker.
  2. Get a single distributed trace across the HTTP request, Kafka, and the agent that consumes the message.
  3. Control each built-in web endpoint individually, and expose worker performance metrics as JSON.

Fixes #448
Fixes #435
Fixes #322

Reviewable commit by commit, in order.


1. The event-loop fix

This is the bug behind all three issues, and it is not really a FastAPI bug at all.

Faust apps are declared at module scope, but under uvicorn the app is started from a loop created later by asyncio.run(). Several call sites resolved app.loop at declaration time, pinning the App to whichever loop mode's get_event_loop() found at import — a loop that is never run. Everything built during startup then inherited that dead loop:

AssertionError: Please create objects with the same loop as running with
RuntimeError: Task ... got Future ... attached to a different loop

faust worker survived by accident: faust/cli/base.py reused the same policy loop mode had already grabbed, so the pinned loop happened to be the right one. asyncio.run() creates a different one, so the pin became fatal.

The fix is to stop resolving the loop eagerly and let mode.Service late-bind it inside _default_start() — i.e. in the loop that actually runs the app:

  • Agent, Collection, Web and livecheck Case no longer pass loop=app.loop to Service.__init__.
  • App._new_transport() / _new_producer_transport() no longer pass loop=, and Transport.loop resolves lazily (still settable; an explicit loop= still wins).
  • Transport.create_conductor() no longer passes loop=.
  • App.tables no longer passes loop= to the table manager.

Worth a reviewer's eye: the transport chain (@app.agentapp.topics_new_conductor()app.transport) is the load-bearing part. Fixing only the four Service.__init__ sites leaves app._loop pinned — found by measuring, not reading.

A consequence caught by CI: nothing calls set_event_loop() before the CLI runs any more, and Python 3.14 removed the get-or-create fallback from get_event_loop_policy().get_event_loop(). faust/cli/base.py now routes through mode.utils.loops.get_event_loop(), which keeps get-or-create across versions. Only the 3.14 legs caught this; 3.10–3.13 still auto-create.

2. faust.contrib.fastapi

The ASGI server drives:

api = FastAPI(lifespan=faust_lifespan(faust_app))

@api.post("/greet")
async def greet(text: str):
    await greetings.send(value=text)
    return {"ok": True}

or the worker drives, serving your ASGI app on its own loop:

serve_asgi(faust_app, api, port=8000)

Details that matter:

  • bind_to_running_loop() reads app._loop, not app.loop — the public property resolves and caches a loop as a side effect of being read, which is exactly what it is trying to detect. Raises LoopMismatch naming the usual causes.
  • faust_app_running() uses maybe_start(), so it composes with an already-running app and will not stop one it did not start.
  • faust_lifespan() yields None: Starlette merges a non-None lifespan value into the ASGI scope as state.
  • serve_asgi() registers via the public App.service() decorator, so the server starts after table recovery — when it is actually safe to serve.
  • Imports nothing from fastapi/starlette, so it works with Starlette, Quart and Litestar too. uvicorn is imported lazily.

Also fixes faust[aerospike], which shipped requirements/extras/aerospike.txt without the matching BUNDLES entry and so installed nothing despite being advertised in the README. tests/unit/test_packaging.py guards both directions of that mapping.

3. OpenTelemetry

opentelemetry-instrumentation-aiokafka already wraps AIOKafkaProducer.send and AIOKafkaConsumer.getmany, so most of a trace already works. The hop nobody outside Faust can supply is receiveprocess: the consumer runs in its own thread (ConsumerThread) and contextvars never cross threads, so the receive span is opened and closed on a thread the agent never runs on. The result is an orphaned span and an unparented agent, which reads worse than no instrumentation.

OpenTelemetrySensor closes it — extracting trace context from the message headers and opening a {topic} process span that stays current while the stream processes the event:

from faust.contrib.opentelemetry import setup_opentelemetry
setup_opentelemetry(app)

FastAPI apps are instrumented automatically, but only when OpenTelemetry is installed and a real TracerProvider is configured — until an SDK is configured the API is a no-op, so this never enables telemetry nobody asked for.

  • Depends on opentelemetry-api only, and never calls set_tracer_provider() — configuring the SDK is the application's job.
  • Reads trace context from headers, never writes it. The aiokafka instrumentation already injects on produce and its setter appends unconditionally, so a second injector would put two traceparent headers on the wire.
  • Warns when the opentracing TracingSensor is registered, since that one does inject.
  • Every OpenTelemetry call is wrapped — telemetry must never break message processing.

4. Per-endpoint web feature flags

Which built-in endpoints get served was decided entirely by debug: it enabled the statistics and graph endpoints together or neither, and /router and /table were mounted unconditionally with no way to turn them off — even though /table serves table data over HTTP and /router publishes the URL of every other worker.

web_stats_enabled     default_alias="debug"   /  and  /assignment/
web_graph_enabled     default_alias="debug"   /graph
web_router_enabled    default=True            /router
web_tables_enabled    default=True            /table
web_metrics_enabled   default=False           /performance/

default_alias="debug" resolves to conf.debug at read time when unset, so an app setting none of these serves exactly what it served before. /router and /table keep defaulting to on because disabling /router breaks @table_route across nodes — they are simply switchable now.

Selection moved into Web._enabled_blueprints(), driven by a blueprint_flags mapping. Blueprints with no entry are always enabled, so anything a subclass or user adds is unaffected.

5. /performance/ metrics endpoint

Off by default, no new dependency, independent of debug. Not a flat Monitor.asdict() dump — two things are computed that monitor does not provide:

  • Consumer lag, from log end offsets minus offsets actually read. Monitor tracks both but never subtracts them. Partitions with an unknown read offset are skipped rather than reported as fully lagged, which would spike alerts on every restart.
  • Latency percentiles. Monitor keeps raw deques of thousands of samples. Nearest-rank rather than statistics.quantiles, which raises on fewer than two samples — a metrics endpoint should not fail on a worker that just started.

New extras

pip install "faust-streaming[fastapi]"          # fastapi + uvicorn
pip install "faust-streaming[opentelemetry]"    # api + fastapi/aiokafka instrumentation

requirements/test.txt gains only opentelemetry-api, -sdk and uvicorn — all pure Python — so the new code is covered in CI without pulling fastapi/pydantic-core into all 15 matrix legs. FastAPI-dependent tests use importorskip; the OpenTelemetry instrumentation paths are covered by stubbing the module through sys.modules.

Backwards compatibility

Audited by snapshotting seven production-shaped app configs (plain, debug, web_enabled=False, web_in_thread, custom port/host, prod-like, debug+in_thread) on master and on this branch, then diffing settings, route tables, reverse route names, agents, tables, topics and loop identity.

The only difference is loop identity — False on master, True here — which is the bug being fixed. Settings, routes and registered objects are identical for every config. install_requires is unchanged, faust/web/base.py's __all__ is unchanged, and the set of shipped faust.* packages is byte-identical to master. tests/unit/web/test_blueprint_flags.py::Test_backwards_compatibility pins the served-blueprint set for the default and debug=True cases so this cannot silently regress.

That audit also caught one genuine production-visible change and fixed it: setup.py excluded "examples" but not "examples.*", so find_packages was installing the example sub-packages into site-packages under an examples namespace. Renaming examples/fastapi to examples/fastapi_project therefore changed what the wheel installs. Excluding "examples.*" means no example package ships either way.

Testing

2320 passed, 8 skipped locally. isort, black, flake8 and bandit clean; Sphinx builds.

New tests bind no socket and start no uvicorn, so they stay fast and keep the autouse lingering-thread/task guards happy:

  • tests/functional/test_fastapi_cohost.py reproduces the import-time-vs-running-loop split without a broker, by declaring the app in a synchronous fixture and asserting from async tests. Verified these fail when the old code is restored.
  • tests/unit/contrib/test_opentelemetry.py asserts against a real InMemorySpanExporter that the process span continues the trace from a traceparent header and that nested spans parent to it.
  • tests/unit/web/test_blueprint_flags.py covers the flag matrix and the back-compat cases.
  • tests/functional/web/test_metrics.py drives /performance/ through the existing aiohttp test client.

Verified against a real broker

Both directions were run end to end against Kafka 4.0 (KRaft, single node):

faust worker + serve_asgi
  POST -> await topic.send() on the worker's loop -> 200
  agent in the same process consumed the message
  real SIGINT to the process group -> exit 0, 1.4s clean shutdown

uvicorn + faust_lifespan   (the literal #448 scenario)
  POST -> await topic.send() -> 200, zero loop errors
  agent consumed it, including the backlog from a previous run

Signal handling

Checked against real SIGINTs — a mode.Worker hosting AsgiService with uvicorn serving on a real port, signalled at the process group like a terminal Ctrl-C, single and repeated, with and without the override. All four combinations exit 0 and complete a slow service's drain.

That measurement corrected the code comment: mode.Worker registers via loop.add_signal_handler(), which is delivered through asyncio's wakeup fd, so uvicorn's signal.signal() does not displace it. The override is kept to avoid a second concurrent shutdown path, and the docstring now says that rather than something untrue.

Unrelated issues surfaced, not fixed here

  • extra/tools/render_configuration_reference.py iterates related_cli_options string values character by character, so make configref rewrites :option:faust --debug`` as :option:faust -`, :option:`faust d`, ...` and drops hand-written prose. The new settings are hand-added to `settingref.txt` in the generator's format instead.
  • make configcheck fails on master because web_application_options was never added to settingref.txt. Documented here so the symmetric difference is empty, but configcheck is a tox environment CI never invokes, which is why it drifted.
  • The PyPy leg failed on a hypothesis FlakyFailure in the partition-assignor property test (5123ms against a 4000ms deadline on one run, 3455ms on the next). Pre-existing and unrelated; the deadline is raised on PyPy only, so CPython keeps the tighter bound.
  • An agent will not consume from a topic that does not exist when the worker starts — messages are written but unprocessed until a restart. Confirmed to be general Faust behaviour (faust worker reproduces it identically), so it is documented in the userguide caveats rather than worked around.

claude added 4 commits August 3, 2026 21:11
Faust apps are declared at module scope but, under an ASGI server such as
uvicorn, started from a loop created later by asyncio.run(). Several call
sites resolved app.loop at *declaration* time, which pinned the App to
whichever loop mode's get_event_loop() found at import -- a loop that is
never run. Every service built during startup then inherited that dead loop
and the worker failed with "Please create objects with the same loop as
running with" or "Task ... got Future ... attached to a different loop".

faust worker survived this by accident: faust/cli/base.py reuses the same
policy loop mode already grabbed, so the pinned loop happened to be the
right one. asyncio.run() creates a different loop, so the pin became fatal.

Stop resolving the loop eagerly and let mode.Service late-bind it on first
access, which happens inside _default_start() -- i.e. in the loop that
actually runs the app:

- Agent, Collection, Web and livecheck Case no longer pass loop=app.loop
  to Service.__init__.
- App._new_transport()/_new_producer_transport() no longer pass loop=, and
  Transport.loop is now resolved lazily on first access (it stays settable,
  and an explicit loop= argument still wins).
- Transport.create_conductor() no longer passes loop=, since app.topics
  builds the conductor at import time when an agent is declared.
- App.tables no longer passes loop= to the table manager.

Adds tests/functional/test_fastapi_cohost.py, which reproduces the
import-time-vs-running-loop split without a broker by declaring the app in a
synchronous fixture and asserting from async tests. Constructing a Transport
performs no I/O, so the exact invariant that broke can be asserted directly.

Fixes #322
Fixes #435
Fixes #448

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Running FastAPI and Faust in one process previously meant hand-rolling a
lifespan handler, and the two shipped examples both carried a "this doesn't
work yet" caveat. This adds a supported way to do it, in both directions.

The ASGI server drives:

    api = FastAPI(lifespan=faust_lifespan(faust_app))

    @api.post("/greet")
    async def greet(text: str):
        await greetings.send(value=text)
        return {"ok": True}

or the Faust worker drives, serving your ASGI app on its own loop:

    serve_asgi(faust_app, api, port=8000)

Public API: faust_lifespan(), the composable faust_app_running() context
manager, the lower-level bind_to_running_loop(), AsgiService and
serve_asgi(). bind_to_running_loop() reads app._loop rather than app.loop,
since the public property resolves and caches a loop as a side effect of
being read -- exactly what it is trying to detect -- and raises LoopMismatch
with a message naming the usual causes.

Details worth knowing:

- faust_app_running() uses maybe_start(), so it composes with an app that is
  already running and will not stop one it did not start.
- faust_lifespan() yields None: Starlette merges a non-None lifespan value
  into the ASGI scope as state.
- serve_asgi() registers via the public App.service() decorator, so the
  server starts after table recovery -- when it is actually safe to serve.
- The module imports nothing from fastapi or starlette, so it works with
  Starlette, Quart and Litestar too; uvicorn is imported lazily.
- uvicorn's signal handling is neutralized so mode.Worker keeps owning
  SIGINT/SIGTERM. The hook moved in uvicorn 0.27, so both the old
  install_signal_handlers() and the new capture_signals() are handled.

Packaging: adds the faust[fastapi] extra, and fixes faust[aerospike], which
shipped requirements/extras/aerospike.txt without the matching BUNDLES entry
and so resolved to nothing despite being advertised in the README.
tests/unit/test_packaging.py now guards both directions of that mapping.

Tests bind no socket and start no uvicorn, so they stay fast and keep the
autouse lingering-thread/task guards happy. The integration test drives a
real FastAPI app in-process through httpx.ASGITransport and is skipped
unless the extra is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
opentelemetry-instrumentation-aiokafka already wraps AIOKafkaProducer.send
and AIOKafkaConsumer.getmany, which is exactly what Faust's aiokafka driver
calls, so most of a distributed trace already works:

    FastAPI server span
      -> aiokafka "{topic} send"     (PRODUCER, injects traceparent)
      -> [Kafka]
      -> aiokafka "{topic} receive"  (CONSUMER, extracts traceparent)
      -> ???

The last hop is the one nobody outside Faust can supply. The consumer runs
in its own thread (ConsumerThread) and contextvars never cross threads, so
the receive span is opened *and closed* inside getmany, on a thread the agent
never runs on. The result is an orphaned receive span and an unparented
agent, which reads worse in a trace viewer than no instrumentation at all.

OpenTelemetrySensor closes it. It extracts trace context from the Kafka
message headers and opens a "{topic} process" CONSUMER span that stays
current for exactly as long as the stream is processing the event, so
anything the agent does nests underneath it:

    from faust.contrib.opentelemetry import setup_opentelemetry
    setup_opentelemetry(app)

The FastAPI side is instrumented automatically. faust_lifespan() and
AsgiService attach FastAPIInstrumentor when opentelemetry is installed *and*
a real TracerProvider has been configured -- until an SDK is configured the
OpenTelemetry API is a no-op, so this never enables telemetry nobody asked
for. Pass opentelemetry=False to opt out, or True to force. Apps already
carrying _is_instrumented_by_opentelemetry (e.g. started under
opentelemetry-instrument) are left alone rather than double-wrapped.

Deliberate choices:

- Depends on opentelemetry-api only, and never calls set_tracer_provider():
  configuring the SDK is the application's job, not a library's.
- Reads trace context from headers, never writes it. The aiokafka
  instrumentation already injects on produce and its setter appends
  unconditionally, so a second injector would put two traceparent headers on
  the wire.
- Warns when the opentracing TracingSensor is already registered, since that
  one does inject and running both produces duplicate headers.
- Span naming follows the Python contrib convention ("{topic} process")
  rather than the spec's "{operation} {destination}", so Faust and aiokafka
  spans stay consistent in one backend.
- Every OpenTelemetry call is wrapped: telemetry must never break message
  processing.

Attributes follow the messaging semantic conventions, including omitting
messaging.kafka.message.key when the key is null as the spec requires.

Adds the faust[opentelemetry] extra. requirements/test.txt gets the API and
SDK only -- both pure Python -- so the sensor is covered in CI without
pulling fastapi into all 15 matrix legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Adds docs/userguide/fastapi.rst, the first userguide page for any of this.
It covers the one-process-one-loop rule and why violating it produces the
errors in #448, composing with your own lifespan, running under faust worker,
how Faust's own aiohttp server relates to yours, and the OpenTelemetry setup.
The troubleshooting section quotes the literal error strings so searching for
them lands here. Also adds reference stubs for both new contrib modules and
extras blurbs in the installation docs.

Both examples are rewritten onto faust.contrib.fastapi, and the
"You MUST have app defined ... but this doesn't work yet" comment is gone --
that caveat was a name collision, not a missing feature. `faust -A` looks for
an attribute named `app`, and the examples had bound that name to the FastAPI
object; binding it to the Faust app and calling the API `api` is all it took.
The examples now say so, since the FastAPI convention of naming the
application `app` is exactly what breaks it.

examples/fastapi/ is renamed to examples/fastapi_project/. As a package
directory named `fastapi`, it shadowed the real fastapi distribution whenever
examples/ was on sys.path -- so the documented `uvicorn fastapi_example:api`
died with "cannot import name 'FastAPI' from 'fastapi'". Both examples now
run as documented.

Also adds examples/fastapi_project/worker_main.py showing the same API served
from inside `faust worker` via serve_asgi(), and de-stacks @faust_app.timer
from @router.get -- stacking them registers the undecorated function as the
route and the timer-wrapped one as the timer, which is rarely what is meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.07%. Comparing base (4acc180) to head (0cc470f).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #748      +/-   ##
==========================================
+ Coverage   95.97%   96.07%   +0.10%     
==========================================
  Files         103      105       +2     
  Lines       11072    11326     +254     
  Branches     1191     1226      +35     
==========================================
+ Hits        10626    10882     +256     
+ Misses        352      350       -2     
  Partials       94       94              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude added 6 commits August 4, 2026 10:30
Testing against real SIGINTs showed the comment on _disable_signal_handling
was wrong. It claimed that uvicorn's handlers "win" because they are
installed later, so Ctrl-C would stop only the web server while the worker
kept running. That is not what happens.

mode.Worker registers SIGINT/SIGTERM through loop.add_signal_handler(), which
on Unix is delivered via asyncio's wakeup file descriptor. uvicorn installs
its handlers with signal.signal(), which does replace the OS-level handler --
but not the wakeup fd, so both still fire. Verified directly: after
signal.signal() displaces asyncio's _sighandler_noop, a SIGINT still reaches
the asyncio callback.

Measured end to end with a mode.Worker hosting an AsgiService on a real port,
signalling the process group the way a terminal does. With the override and
without it, single Ctrl-C and double Ctrl-C, all four combinations exit 0,
run the ASGI on_stop, and complete a slow sibling service's drain. The only
observable difference is that without the override uvicorn logs a duplicate
interrupt.

So the override is not load-bearing for graceful shutdown. It is still worth
keeping -- it avoids a second concurrent shutdown path, and stops uvicorn
setting force_exit on a repeated signal while the worker is still draining --
but the docstring now says that rather than something untrue.

Also adds a test that _create_server() actually applies the override to a
real uvicorn.Server. The existing tests exercised _disable_signal_handling in
isolation with mocks, which would not have caught _create_server forgetting
to call it; the new test fails when that call is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Two CI failures on this branch.

1. Python 3.14 (both Cython legs): every tests/integration/cli test failed
   with

       faust/cli/base.py:625, in run_using_worker
           loop = asyncio.get_event_loop_policy().get_event_loop()
       RuntimeError: There is no current event loop in thread 'MainThread'.

   This is a regression from the loop-binding change, and a good example of
   why that change needed care. Previously an eager app.loop read at import
   time went through mode's get_event_loop(), which creates a loop *and*
   calls set_event_loop(). Removing those reads means nothing sets a current
   loop before the CLI runs, and Python 3.14 removed the get-or-create
   fallback from the policy, so the call now raises. 3.10-3.13 still
   auto-create, which is why only the 3.14 legs went red.

   Route the three call sites in faust/cli/base.py through
   mode.utils.loops.get_event_loop(), which keeps get-or-create behaviour
   across versions -- the same helper Transport.loop already uses. The
   docstring example in faust/worker.py gets the same treatment.

   The new test reproduces this on any Python by clearing the current loop,
   and fails when the fix is reverted. It restores both asyncio's current
   loop and mode's thread-local cache afterwards, and closes the loop it
   causes to be created: leaving a non-running loop in mode's cache hangs
   every later test that resolves one.

2. codecov patch coverage. The bulk of the uncovered diff was
   AsgiService._create_server(), which needs uvicorn. uvicorn's only
   dependencies are click (already required by faust) and h11, both pure
   Python, so it is cheap enough to add to requirements/test.txt -- unlike
   fastapi, which pulls in pydantic-core and has no wheel for every matrix
   leg. Adds tests for the remaining reachable gaps: the stop_timeout branch
   of faust_app_running(), a lifespan invoked without an ASGI app argument,
   and non-bytes Kafka header values.

   What stays uncovered in CI is instrument_asgi_app()'s body, which cannot
   run without opentelemetry-instrumentation-fastapi. It is covered locally
   with the extra installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
…dency

Codecov settled at 93.44% patch coverage once all 16 matrix uploads landed
(the earlier 2.45%/31.90% readings were a partially-uploaded run). The one
remaining gap was instrument_asgi_app(), which CI cannot reach because
opentelemetry-instrumentation-fastapi is deliberately not installed there --
it pulls in fastapi and pydantic-core, which has no wheel for every leg.

Rather than add that dependency, stub the instrumentation module through
sys.modules. The tests then exercise our own branching everywhere, including
CI: package missing, instrumented successfully, the TypeError retry for
releases predating exclude_spans, that retry also failing, and a generic
failure. Those last two were marked "pragma: no cover"; they are reachable
now, so the pragmas are gone.

Also covers three branch partials: a sensor state carrying neither span nor
token, and a stream that exposes no app (so no consumer group name).

faust/contrib/fastapi.py and faust/contrib/opentelemetry.py are now at 100%
statement and branch coverage, verified both with the instrumentation package
installed and with it blocked to simulate CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Two related additions to the web layer.

Feature flags
-------------

Which built-in endpoints get served was decided entirely by `debug`: it
enabled the statistics and graph endpoints together, or neither, and
`/router` and `/table` were mounted unconditionally with no way to turn them
off. That last part matters, because `/table` serves table *data* over HTTP,
and `/router` publishes the URL of every other worker in the cluster.

Five settings now control them individually:

    web_stats_enabled     default_alias="debug"   / and /assignment/
    web_graph_enabled     default_alias="debug"   /graph
    web_router_enabled    default=True            /router
    web_tables_enabled    default=True            /table
    web_metrics_enabled   default=False           /performance/

`default_alias="debug"` means an unset flag resolves to `conf.debug` when
read, so an app that sets none of these serves exactly what it served before
-- verified for both the debug and non-debug cases. `/router` and `/table`
keep defaulting to on because disabling `/router` breaks `@table_route`
across nodes; they are simply switchable now.

Blueprint selection moves into `Web._enabled_blueprints()`, driven by a
`blueprint_flags` mapping from blueprint to setting name. Blueprints with no
entry are always enabled, so anything a subclass or user adds is unaffected.
Statistics and the production index both mount at "/", so exactly one of them
is served -- the index takes over whenever statistics are off.

Metrics endpoint
----------------

`faust/web/apps/metrics.py` serves throughput, latency, consumer lag and
table statistics as JSON. Off by default: it is a new endpoint and turning it
on should be deliberate. It needs no extra dependency and is independent of
`debug`, so it can be left on in production.

It is not a flat dump of `Monitor.asdict()`. Two things are computed here
that monitor does not provide:

- **Consumer lag**, derived from log end offsets minus offsets actually read.
  Monitor tracks both but never subtracts them, and lag is the number you
  usually alert on. Partitions with an unknown read offset are skipped rather
  than reported as fully lagged, which would spike alerts on every restart.
- **Latency percentiles**. Monitor keeps raw deques of up to a few thousand
  samples; serializing those into an HTTP response is not useful. Percentiles
  use nearest-rank rather than statistics.quantiles, which raises on fewer
  than two samples -- a metrics endpoint should not fail on a worker that
  just started.

TP-keyed counters go through Monitor's existing `_tp_*_dict()` helpers, since
`Counter[TP]` has namedtuple keys that are not JSON-serializable.

Unrelated flake fixed in passing
--------------------------------

tests/meticulous/assignor/test_copartitioned_assignor.py failed on the PyPy
leg with hypothesis FlakyFailure: the largest generated cases took 5123ms
against a 4000ms deadline on one run and 3455ms on the next. The deadline is
raised on PyPy only, so CPython keeps the tighter bound as a performance
guard. This predates this branch and is not related to the changes above.

Note: docs/includes/settingref.txt is generated by
extra/tools/render_configuration_reference.py, but regenerating it today also
mangles every `related-command-options` entry into per-character options and
drops the OAuth2 documentation block, so the new settings are added by hand
in the generator's format instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Audited every change on this branch against master for observable impact on
an existing deployment, by snapshotting seven production-shaped app configs
(plain, debug, web_enabled=False, web_in_thread, custom port/host, prod-like,
debug+in_thread) on both revisions and diffing settings, route tables,
reverse route names, agents, tables, topics and loop identity.

Result: the only difference is loop identity -- False on master, True here --
which is the bug this branch fixes. Settings, routes and registered objects
are identical for every config.

That audit did surface one real production-visible change, now fixed:

setup.py excluded "examples" but not "examples.*", so find_packages picked up
the example sub-packages and shipped them into site-packages under an
``examples`` namespace -- a name no library should claim. Renaming
examples/fastapi to examples/fastapi_project therefore changed what the wheel
installs. Excluding "examples.*" means no example package ships either way,
so the rename is invisible to installs, and a pre-existing namespace
pollution goes away. Verified the set of shipped faust.* packages is
byte-identical to master.

Two smaller gaps, both found by cross-checking the design against the source:

- The typed Settings.__init__ stub was missing the five new settings. It is
  never executed (_init_subclass_settings replaces __init__) but the file
  keeps it in sync for mypy and editors, so the settings are added there too.
- docs/includes/settingref.txt documented 108 of 109 settings:
  web_application_options was never added when that setting landed.
  sphinx_celery's configcheck builder compares the two sets symmetrically
  under -W, so it fails on master today. Documenting it makes the difference
  empty. Not caught by CI because configcheck is a tox environment the
  workflows never invoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
@wbarnha wbarnha changed the title Co-host FastAPI/ASGI apps with the worker, and trace them with OpenTelemetry FastAPI co-hosting, OpenTelemetry tracing, and per-endpoint web feature flags Aug 4, 2026
Verified this branch end to end against a real Kafka 4.0 broker (KRaft,
single node), in both directions:

  faust worker + serve_asgi
    HTTP POST -> await topic.send() on the worker's loop -> 200
    agent in the same process consumed the message
    real SIGINT to the process group -> exit 0, clean 1.4s shutdown

  uvicorn + faust_lifespan  (the literal issue #448 scenario)
    HTTP POST -> await topic.send() -> 200, zero loop errors
    agent consumed it, including the backlog from a previous run

That closes the item the pull request listed as unverified: the loop fix
works against a live broker, not just against the invariant asserted in the
test suite.

The runs also surfaced a genuine operational gotcha. On a first run against a
fresh cluster the topic does not exist when the app starts, so the agent
subscribes to nothing: messages produced by an endpoint are written to Kafka
but never processed until the process restarts. Nothing is lost -- the
backlog is consumed on the next start -- but it reads like a broken agent,
and co-hosted apps hit it more often than most because they usually produce
to and consume from the same topic.

Confirmed this is general Faust behaviour rather than anything to do with
co-hosting: `faust worker` reproduces it exactly, consuming nothing when the
topic is deleted beforehand and everything once it exists. Documented in the
caveats section rather than worked around, since creating topics up front is
the right fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants