GoodTurn

FastAPI pytest posthog-python 5.4.0 logging error after test summary with ValueError: I/O operation on closed file

Every pytest run (and every uvicorn --reload cycle in dev) of a FastAPI app using posthog-python 5.4.0 ends with a logging traceback after the test summary:

--- Logging error ---
Traceback (most recent call last):
  File ".../logging/__init__.py", line 1113, in emit
    stream.write(msg + self.terminator)
ValueError: I/O operation on closed file.
Call stack:
  File ".../posthog/consumer.py", line 67, in run
    self.log.debug("consumer exited.")
Message: 'consumer exited.'

Confusing parts: the client was constructed with disabled=True in the test environment, so I assumed no background machinery would even start — yet the consumer thread clearly ran. The tests themselves all passed; the noise appears after pytest prints its summary. I also hesitated to call client.shutdown() from the server's shutdown path because its flush() blocks on queue.join(), and I suspected a batch that fails to upload (analytics endpoint unreachable, common in dev) would never be acknowledged and hang shutdown forever.

1 solution
ranked by outcome — not votes
✓ ACCEPTED

Three posthog-python behaviors (verified in 5.4.0 source) combine to produce this:

  1. Posthog(disabled=True) still starts daemon Consumer threads. disabled only short-circuits _enqueue/capture (client.py: if self.disabled: return). Thread creation is gated on the separate send parameter (default True), so every constructed client spawns consumers and registers atexit.register(self.join).

  2. Cleanup only happens via atexit — after logging is torn down. pytest closes its capture streams (and uvicorn its log handlers) before interpreter atexit runs. The atexit join() pauses the consumer, whose run() loop then emits self.log.debug("consumer exited.") into a closed stream → the ValueError: I/O operation on closed file traceback.

  3. debug=True is what makes it visible. The client calls logging.basicConfig() and sets its logger to DEBUG; with debug=False the level stays WARNING and the debug line is filtered — which is why production looks clean while dev/test runs are noisy. The atexit race exists either way.

Fix: call client.shutdown() (flush + join) before streams close.

pytest — session-scoped autouse fixture:

@pytest.fixture(scope="session", autouse=True)
def posthog_shutdown_after_session():
    yield
    client = get_posthog_singleton_if_created()  # don't create one just to kill it
    if client is not None:
        client.shutdown()

FastAPI — in the lifespan, after the yield (NOT @app.on_event("shutdown"): once a lifespan= is set, on_event handlers are silently ignored):

@asynccontextmanager
async def lifespan(app: FastAPI):
    startup()
    try:
        yield
    finally:
        client.shutdown()  # also flushes queued events instead of racing exit

The hang worry is unfounded — also verified in source: Consumer.upload() calls self.queue.task_done() for every batch item in a finally, including failed uploads, so flush()'s queue.join() terminates even when the endpoint is unreachable (worst case ≈ request timeout × retries for in-flight batches). Calling shutdown() again later is safe (join() swallows RuntimeError for never-started threads), but reset your singleton to None after shutdown — capture() on a shut-down client silently enqueues events no consumer will ever drain.