Python SDK · BufferedRecorder
The default recorder buffers events in memory and flushes in batches on a background thread. Because record() only enqueues, it's safe to call from async def handlers without blocking the event loop:
rec = es.new_recorder(buffer_size=2000, flush_interval=2.0)
# or
rec = recorder.new(project_id, api_key, buffer_size=2000)
Options
| Option | Default | Purpose |
|---|---|---|
buffer_size |
1000 |
Capacity of the in-memory event buffer. |
flush_size |
100 |
Pending-event count that triggers an immediate flush, and the most events per send. |
flush_interval |
5.0 |
Max seconds between flushes when the size threshold isn't reached. |
flush_timeout |
30.0 |
Socket timeout (seconds) for each delivery attempt. It bounds each connect and read, not the whole attempt, and doesn't cover the DNS lookup. |
overflow |
"drop-newest" |
Behavior when the buffer is full: "drop-newest", "block", or "error". |
drain_timeout |
30.0 |
Max seconds close() waits for pending events, plus up to 1 s after it stops retrying. 0 or less means it doesn't wait. |
initial_backoff |
0.5 |
Base wait (seconds) before the first retry of a failed batch. |
max_backoff |
30.0 |
Cap (seconds) on a single wait between retries. |
max_retry_duration |
300.0 |
How long (seconds) one batch is retried before its events are dropped as RETRIES_EXHAUSTED. |
on_drop |
None |
Callback invoked with the events, a DropReason and an error whenever events are discarded. See What happens when delivery fails. |
request_timeout |
10.0 |
Per-request HTTP timeout (seconds) when the HTTP recorder is used directly. |
logger |
module logger | A logging.Logger for diagnostics (overflow warnings, flush errors). |
base_url |
production | Override the ingestion endpoint (tests, staging). |
transport |
urllib |
Custom transport callable (primarily for tests). |
auto_idempotency_key |
off | Copy event.id into event.idempotency_key when the latter is empty. |
Times are in seconds (Python convention), where the Node SDK uses milliseconds. overflow accepts either the OverflowPolicy enum or the equivalent string. flush_size, flush_interval and the backoff and retry options treat None, <= 0 or NaN as the default.
Overflow policies
"drop-newest" # drop the incoming event, log a warning (default)
"block" # wait for space (until the buffer drains or the recorder closes)
"error" # raise BufferFullError
Under an async server keep the default "drop-newest". The ASGI middleware records the request event on the event loop, so a "block" wait there stalls every request for up to 15 s.
Close & flush
close()drains pending events, with retries, for up todrain_timeout. It raisesDrainTimeoutErrorif the drain timed out, so log what it raises. Call it once, on the path that actually runs at shutdown.flush(timeout=None)returns once the events queued at call time are delivered or reported toon_drop. Useful in tests and for graceful shutdown sequences. Always pass atimeout: retries during an outage can take minutes. It raisesTimeoutErrorif the timeout elapses first, and never cancels a send.stats()returns aBufferedStatswithdropped(onlyBUFFER_FULLdrops),dropped_by_reason,flushed,flush_errs,retries,pendingandbuffer_sizefor observability.
What happens when delivery fails
record() only puts the event on an in-memory buffer, so your request path never waits on the network. A background thread sends batches and handles failures:
| Failure | What happens |
|---|---|
NetworkError, TimeoutError, 429 or 5xx |
Retried with jittered exponential backoff: 0.5 s first wait, 30 s max wait, server Retry-After honored up to that cap. After 5 minutes the batch is dropped as RETRIES_EXHAUSTED. Tune with initial_backoff, max_backoff and max_retry_duration. |
400, 409, 413 or EncodeError |
The batch is split in half and each half retried, so one bad event does not sink the rest. An event that still fails is dropped as REJECTED. |
| Any other error | The whole batch is dropped as REJECTED. |
| Buffer full | Depends on the overflow policy. "drop-newest" (default) drops the new event as BUFFER_FULL, "block" makes record() wait for space, "error" raises BufferFullError. |
close() drain timeout |
Retries stop and what is left is dropped as SHUTDOWN. |
record() after close() |
The event is dropped as CLOSED, as is a "block" wait that close() ends. |
Pass on_drop to hear about every dropped event:
import logging
log = logging.getLogger("audit")
def on_drop(events, reason, err):
log.error("everscribe: dropped %d events (%s): %s", len(events), reason.value, err)
rec = es.new_recorder(on_drop=on_drop)
reasonis aDropReason:BUFFER_FULL,REJECTED,RETRIES_EXHAUSTED,SHUTDOWNorCLOSED.reason.valuegives the lowercase name for metric labels.erris the last delivery error forREJECTEDandRETRIES_EXHAUSTED. For aBUFFER_FULLdrop that happens when the middleware records the request event, it is aBufferFullErrorunder"error"and aTimeoutError(the 15 s wait) under"block". Otherwise it isNone.- The callback must not block and must not call
record,flushorcloseon the same recorder. It may run on several threads at once. AnExceptionit raises is caught and logged; aBaseExceptionraised on the background thread stops it, can leave the rest of that batch unreported, and later records are dropped asCLOSED. - A dropped event may have been delivered anyway, for example when the last request timed out. Resending it with its original
idis safe: the server deduplicates it. - Delivered events are immutable. Dropped events cannot be recovered, so route
on_dropto your own logs or queue.
Always call close() before the process exits. It drains the buffer, with retries, for up to drain_timeout (30 s by default). At the timeout it stops retrying, waits up to 1 s more, and raises DrainTimeoutError. The background thread is a daemon thread, so a process that exits without close() loses what is buffered without an on_drop report.
Give flush a deadline. flush(timeout=...) returns once the events queued at the call are delivered or reported to on_drop, or raises TimeoutError if the timeout elapses first. It does not raise delivery errors and never cancels a send.
Watch stats(). dropped_by_reason counts drops per reason, retries counts retries, flush_errs counts failed attempts, and dropped counts only BUFFER_FULL drops.
For the split budget, custom recorders and middleware edge cases, see Delivery details.
Delivery details
- Splitting is limited to 32 sends per batch. Events left when that budget runs out are dropped as
REJECTED. - An event
json.dumpscannot encode (asetor adatetimein metadata, for example), or one with a wrongly typed field (a stringoccurred_at, for example), fails withEncodeErrorand is handled like a400: that event is dropped asREJECTEDand the rest of the batch is delivered. NaN and infinity are written as-is and the server rejects them with a400, so the outcome is the same. record()stores a shallow copy of the event, and a retry can keep it queued for minutes. Don't mutate an event'sactor,targetormetadataafter recording it.BUFFER_FULLandCLOSEDreachon_dropon the thread that calledrecord(). The other reasons arrive on the background thread or the thread callingclose().- A custom inner recorder decides what is retried through its errors:
NetworkError,TimeoutError, and anything with a truthytransientattribute ortransient()method are retried. Anything else, after any split, drops the batch asREJECTED.record_batchgetstimeout=flush_timeoutif it accepts atimeoutkeyword. An inner recorder withoutrecord_batchgets onerecord()call per event, and a failure that isn't retried drops only that event. ABaseExceptionsuch asSystemExitfrom the inner recorder drops that send asREJECTEDand the background thread keeps running. - A running inner call can't be interrupted. If one is still running after
drain_timeoutplus the 1 s grace,close()reports what is still queued asSHUTDOWNitself and raisesDrainTimeoutError. The in-flight batch is reported asSHUTDOWNwhen the call returns, unless it succeeded, which can be afterclose()has returned. - Calling
close()again, or concurrently, waits for the first call and gets the same outcome. Aclose()interrupted byKeyboardInterruptcan be called again.flush()afterclose()returns at once, as does aflush()once the background thread has stopped, without delivering anything.
Drops of the middleware's event
The middleware records the request event when the handler finishes, and has no caller to return an error to. If the buffer is full then, the event is reported to on_drop as BUFFER_FULL. Under "error" the report carries the BufferFullError. Under "block" the middleware waits up to 15 s for space, then gives up and the report carries a TimeoutError.
A custom Recorder that reports its own drops can call everscribe.event.is_auto_recording(e) to tell this case apart.
Where close goes
The recorder is constructed once at import time and lives as long as the process, so nothing in request handling ever closes it. Hook the framework's shutdown. For FastAPI or Starlette that is the lifespan context manager:
import logging
from contextlib import asynccontextmanager
from everscribe.recorder import DrainTimeoutError
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
yield # the app serves for the duration of the yield
try:
rec.close() # runs, and drains
except DrainTimeoutError as err:
logging.error("everscribe: drain incomplete: %s", err)
app = FastAPI(lifespan=lifespan)
close() is synchronous: it joins the background thread and blocks for up to drain_timeout plus 1 s. That is fine at shutdown, when nothing else is being served. Use await asyncio.to_thread(rec.close) if you would rather keep the event loop free while it drains.
Flask, Django, and any other WSGI app use their process manager's equivalent hook (Gunicorn's worker_exit, for instance). What matters is that the hook runs when the worker is sent SIGTERM, not only on a clean interpreter exit.