keeps.flush()
before the unit of work returns.
The two design principles
Fail-open, always
No public keeps call ever raises into your code.capture(), context(),
flush(), and shutdown() swallow their own errors. If the network is down, if
the buffer is full, if the endpoint is misconfigured — your generation code runs
exactly as it would without keeps installed. Observability is never allowed to
take down the thing it observes.
Best-effort delivery, not at-least-once
keeps trades durability for staying out of your request path. There is no local disk queue, no write-ahead log, no acknowledgement protocol surfaced to your code. Events live in an in-memory buffer and are flushed in the background. If the process dies with events still buffered or in-flight, those events are gone. This is a deliberate tradeoff: keeps is a neutral observer, never a dependency your generations wait on. The cost is that you own the flush boundary in short-lived processes. The rest of this page is about getting that boundary right.What happens when you call capture()
capture() is a non-blocking, O(1) append to an in-memory buffer. It mints (or
reuses) the asset_id, builds the event envelope, appends it, and returns
immediately — it never does network I/O on your thread.
A background daemon thread does the actual sending:
Time-based flush
The daemon wakes every
flush_interval seconds (default 1.0s) and sends
whatever is buffered.Size-based flush
When the buffer reaches 200 events (
max_batch), the daemon is woken
immediately rather than waiting for the next tick.Bounded buffer
The buffer is capped at 10,000 events (
max_buffer). On overflow the
newest (incoming) event is dropped and dropped_overflow increments.atexit flush
On a normal interpreter exit, an
atexit handler flushes remaining events.
Crashes, SIGKILL, and frozen workers skip this.HTTP delivery and retries
When the daemon sends a batch:- It retries up to 3 times with exponential backoff (with jitter).
Timeouts and
5xxresponses count as transient; the server’s one deliberate transient signal is503(its storage is down) — everything else it has to say about your data comes back inside a202. - It honors
429responses, waiting for theRetry-Afterheader before retrying. - It does not retry
4xxresponses (other than429). A400or401means the batch is malformed or unauthorized — retrying won’t help, so the batch is dropped,dropped_sendincrements, and a one-time warning is logged. - A
413(batch over the server’s size cap) makes the transport bisect: it splits the batch in half and sends each half separately, recursively — so one oversized event costs only itself, never the batch it rode in. - A
202response can still reject individual events (bad timestamp, over-cap field, …). The SDK parses the response body: confirmed events add tostats()['accepted'], per-event rejects add tostats()['rejected_by_server']and log a one-time warning with the reason codes. These rejects are terminal — the server will never accept those bytes, so they are not retried.
The flush boundary: read this if your process exits fast
The correct pattern: flush in finally
Put the flush in a finally: so it runs whether the work succeeded or raised.
flush() itself never raises, so it’s safe there.
flush(timeout=5.0) returns True if the buffer drained and False if it timed
out. In a latency-sensitive serverless handler you can tighten the timeout — but
remember a False means some events may not have been delivered.
For a long-lived server (a web app, a daemon) you generally don’t need a
per-request flush — the 1s daemon tick keeps up, and
atexit catches the rest on
a clean shutdown. The flush boundary matters specifically when the process is
short-lived or can be killed/frozen out from under the daemon.Detecting silent loss with stats()
Because delivery is best-effort and fail-open, the SDK won’t shout when it drops events — that’s the whole point.keeps.stats() is how you observe the observer.
dropped_overflow > 0
dropped_overflow > 0
You’re producing events faster than the daemon can ship them, and the buffer
was already at its 10,000-event cap when a new event arrived. Usual causes: a
misconfigured or unreachable endpoint (nothing is draining), or a genuine
burst. Check connectivity first; if the endpoint is healthy and it’s pure
volume, raise
max_buffer in init() or reduce capture volume.dropped_send > 0
dropped_send > 0
Batches failed permanently — either repeated transient failures that
exhausted the 3-attempt retry budget, or a non-
429 4xx (commonly a bad
API key → 401, or a malformed/too-large batch → 400). Verify your API key
and endpoint.rejected_by_server > 0
rejected_by_server > 0
Events reached the server but were rejected one-by-one in the
202
response (a bad_ts, an over-cap field, …). These are terminal — the
server will never accept those bytes — so they are dropped, not retried.
The first occurrence logs a warning listing the reason codes; fix the
producer that builds those events. See the
HTTP API reference for every reason code.sent lags far behind emitted (and isn't catching up)
sent lags far behind emitted (and isn't catching up)
Some events are stuck in the buffer or never delivered. Right before a
short-lived process exits,
sent < emitted is exactly the symptom of a
missing flush() at the work boundary. A persistent gap on a long-lived
process points at delivery failures — cross-check dropped_send.flush(), the events that
made it into the buffer should be fully accounted for — that is,
sent + dropped_send should equal emitted (minus anything still buffered or
in-flight). Note dropped_overflow counts events that were rejected before
they entered the buffer, so they are not part of emitted — track that
counter on its own. A growing, unexplained gap means events are leaking.
On the server side of the ledger, accepted + rejected_by_server accounts for
what happened to sent: sent is what reached the endpoint, accepted is
what it stored.
Configuration gotchas that look like “nothing is happening”
Both of these fail open and quiet by design — so the only signal is that no data shows up in your dashboard. Check them first when delivery seems broken.The default endpoint never sends
Unconfigured endpoints are no longer a silent-drop hazard: with nothing set, the SDK delivers to the hosted ingest (https://ingest.keeps.dev).
KEEPS_INGEST_URL/endpoint= exist to redirect delivery, not to enable it.
You must point keeps at your ingest URL via KEEPS_INGEST_URL or the endpoint=
argument:
init() auto-appends /v1/events if your endpoint lacks that path, so
https://ingest.yourco.com and https://ingest.yourco.com/v1/events are
equivalent.
A missing API key disables the SDK
If noapi_key is provided (neither the api_key= argument nor the
KEEPS_API_KEY environment variable) and the SDK isn’t explicitly disabled, keeps
logs a warning and disables itself. Every subsequent capture() becomes a
no-op (it still returns an asset_id, but nothing is buffered or sent). Check
your logs for:
keeps.stats()['disabled'] is True when the
SDK has disabled itself (or when you set KEEPS_DISABLED=1 / disabled=True on
purpose).
Your
project_id is derived server-side from the API key — the client never
sends it and it is never a capture() argument. The key is the only thing that
binds your events to your project, which is why a missing key disables delivery
entirely.Unit-of-work helper: keeps.session()
keeps.session() is the built-in form of the flush-in-finally pattern: it
applies ambient tags like keeps.context() and guarantees a
flush on exit. Reach for it at every boundary where the process may exit or be
frozen fast — serverless handlers, queue jobs, CLI runs, agentic steps.session() is fail-open and does not swallow exceptions raised inside the
block — it flushes first, then the exception propagates, so your error handling
is unaffected. Pass flush_timeout= to bound the exit-flush (default 5s); keyword
args beyond user_id/flush_timeout become ambient properties tags.
Quick reference
| Behavior | Value / rule |
|---|---|
capture() | Non-blocking O(1) in-memory append; never raises; returns the asset_id |
| Time flush | Every flush_interval (default 1.0s) |
| Size flush | At 200 buffered events (max_batch) |
| Buffer cap | max_buffer (default 10_000); overflow drops the newest (incoming) event |
| Retries | Up to 3x exponential backoff; honors 429 Retry-After; no retry on other 4xx; 413 bisects the batch |
| Server rejects | Per-event, in the 202 body → stats()['rejected_by_server'] + one-time warning; terminal, never retried |
| Clean exit | atexit flush (skipped on crash / kill / freeze) |
| Loss counters | stats()['dropped_overflow'], stats()['dropped_send'], stats()['rejected_by_server'] |
| Delivery guarantee | Best-effort, not at-least-once |
| Your responsibility | keeps.flush() before a short-lived process returns |
Related
Recording provider calls
Where the
finally: keeps.flush() boundary fits into a full
submit → complete capture flow.API reference
Full signatures for
init, capture, context, flush, shutdown, and
stats.Core concepts
Events, correlation by reused
asset_id, and properties as group-by tags.Privacy & safety
References-not-bytes, verbatim capture, and how
project_id derives from your key.