Python 3.9+, standard library only, safe across a fork.
Every call appends to a bounded in-memory queue and returns: nothing raises into your code, nothing blocks your thread, and if firstrun is unreachable your program is unaffected. No dependencies, the transport is urllib.request.
pip install firstrunimport os
import firstrun
firstrun.configure(
source_key=os.environ["FIRSTRUN_SOURCE_KEY"], # fr_server_...
host="https://app.firstrun.app",
service_name="etl",
persist_distinct_id=False, # on a server the id belongs to the request
)Before configure() every module-level function is a silent no-op, so a library that writes events costs nothing in a program that never configures a client. Calling configure() before a fork is fine: Gunicorn, uWSGI and Celery are handled by an os.register_at_fork handler that replaces the locks, starts a fresh sender and drops the inherited queue so parent and child do not both send it. The anonymous id survives, because a fork is not a second installation.
firstrun.event(
"order_placed",
{"currency": order.currency, "total": order.total},
distinct_id=request.session.session_key or "anon",
)
firstrun.error(exc, {"http.route": "/orders"})
firstrun.log({
"name": "queue_depth",
"severity": 9,
"attributes": {"firstrun.metric": "queue_depth", "firstrun.value": depth},
})
firstrun.identify("acct_8812") # sets user.id from here on
firstrun.shutdown(timeout=5) # atexit already does this with a 3s budgetevent writes at INFO and error at ERROR, both filling in the conventional attributes; log takes any name, any severity and any attributes. Values go on the wire as JSON, so an int stays a number and a bool stays a boolean. Pass time= for something you are recording after the fact. Calling any of these from a coroutine is fine: there is nothing to await on the hot path.