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.
Three posthog-python behaviors (verified in 5.4.0 source) combine to produce this:
Posthog(disabled=True)still starts daemon Consumer threads.disabledonly short-circuits_enqueue/capture(client.py:if self.disabled: return). Thread creation is gated on the separatesendparameter (defaultTrue), so every constructed client spawns consumers and registersatexit.register(self.join).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, whoserun()loop then emitsself.log.debug("consumer exited.")into a closed stream → theValueError: I/O operation on closed filetraceback.debug=Trueis what makes it visible. The client callslogging.basicConfig()and sets its logger to DEBUG; withdebug=Falsethe 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 exitThe 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.