Skip to main content
This page is written to hand to your security reviewer as-is. It explains the guarantees keeps makes about what leaves your process, what it can never touch, and why adding it cannot affect the latency or availability of your generation pipeline. The short version: keeps observes from inside your process and reports out-of-band. It is never a proxy, it ships references rather than media, and every code path is fail-open — a keeps call can drop its own data, but it can never raise into, block, or alter your application. keeps captures the inputs you hand it verbatim; if a field is sensitive, redact it yourself before the call (see below).

References, not bytes

keeps records that a generation happened and where its outputs live — never the media itself. You pass outputs as URLs and metadata:
asset_id = keeps.capture(
    model="flux-pro",
    status="completed",
    inputs={"prompt": prompt, "seed": 7},
    outputs=[{"url": result_url, "width": 1024, "height": 1024}],
)
Anything you hand to capture() is coerced to a JSON-safe structure before it is buffered. Raw binary never survives that step: a bytes/bytearray/memoryview value is replaced with a length stub — {"__bytes__": true, "length": 41523} — so even if you accidentally pass an image body, only its size is recorded.
This holds for inputs and outputs alike. If a provider hands you an inline base64 image and you pass it through, keeps stores its length, not its content. The asset bytes stay where the provider put them.
Hard size caps keep a pathological payload from ever becoming a large upload, and all truncation is byte-based (UTF-8), so multi-byte text can’t blow past a cap:
  • Strings truncate at 8 KiB.
  • An inputs object over 64 KiB collapses to a hashed truncation stub ({"__truncated__": true, "sha256": …, "length": …}) rather than shipping in full — the hash keeps equality analytics working.
  • A properties bag over 8 KiB has its values trimmed to fit — keys are never replaced with a stub, so your group-by dimensions stay clean.
  • outputs cap at 50 references and 64 KiB total; over-cap lists keep the first references that fit plus one trailing {"__dropped__": n} marker recording how many were cut.
Every cut is also flagged out of band: the event carries a truncated array naming each field the SDK touched, and the server stores it — so you can always tell exactly which fields were cut, without inspecting payloads. Typical events are well under 2 KiB.

Prompts are captured verbatim

keeps does not redact. Whatever you pass in inputs (and outputs) is recorded as-is — the point of keeps is maximum fidelity on what your app generated. There is no redaction switch to forget to turn on, and no field-name heuristic that might silently miss a prompt hiding under a custom key. If a specific field is sensitive, redact it yourself before the call. Because you control the value, you decide exactly what is recorded — and you can keep a stable hash so equality still holds server-side (group by prompt without ever storing the text):
import hashlib
import keeps

prompt = "internal product codename render"
keeps.capture(
    model="flux-pro",
    status="completed",
    inputs={
        # a stable hash: identical prompts still group together server-side
        "prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(),
        "seed": 7,
    },
    outputs=[{"url": result_url}],
)
Output URLs are stored as-is. If your provider embeds content inside a URL (a signed URL with query parameters, or a data: URI, for example), that URL is recorded verbatim — strip or hash it before passing it if that is a concern.
properties tags are your group-by dimensions, not payloads, and are recorded as-is like everything else. If a tag value is sensitive (for example a raw end-user identifier), hash it before you attach it:
import hashlib

uid = hashlib.sha256(user.email.encode()).hexdigest()[:16]
keeps.capture(model="flux-pro", status="completed", user_id=uid,
             properties={"tier": "pro"})
Properties fold per key on the backend: each event updates only the keys it carries (latest value per key by ts wins), so keys you omit on a later call are left intact, not dropped. For safety this means: once you set a key to a hashed value, don’t later send that same key with the raw value — a newer event would overwrite the hash for that key.

Tenancy is derived server-side

The client never tells the backend which project the data belongs to. There is no project_id parameter on any keeps call, and the SDK never sends one. project_id is resolved server-side from the API key on the Authorization: Bearer header of every batch. A client cannot spoof tenancy by forging a field, and one project’s key can never write into — or read from — another project’s data. Your API key scopes everything you send and everything you can query. If the key is missing or empty (and the SDK is not explicitly disabled), keeps logs a single warning and disables itself rather than sending unauthenticated data:
keeps.init()  # no api_key, no KEEPS_API_KEY → one warning, SDK becomes a no-op

Never in the request path

keeps cannot affect the latency or availability of your provider calls, because it is never between you and your provider. There is no proxy mode — by design and forever. Your traffic to fal / Replicate / OpenAI flows exactly as it did before: same endpoints, same credentials, same TLS, same latency. You call keeps yourself, around your own code, and it reports out-of-band:
import keeps

# 1. Your provider call — completely untouched by keeps.
result = call_my_provider(prompt=prompt, seed=7)

# 2. You record what happened, after the fact.
keeps.capture(
    model="flux-pro",
    status="completed",
    inputs={"prompt": prompt, "seed": 7},
    outputs=[{"url": result["url"]}],
)
capture() is a non-blocking, O(1) append to an in-memory buffer; it returns immediately and a background daemon thread delivers events later. If keeps ingest is slow, down, or returning errors, your provider call has already returned — nothing in keeps can stall it. There are no sockets held open against your provider, no agents, and no sidecars.

Fail-open, everywhere

Failure isolation is the SDK’s central design constraint. Every public call is fail-open: it can lose its own data, but it can never raise into, block, or alter your application.
FailureWhat happens to your app
keeps ingest is down / slow / erroringNothing. Events retry briefly on a background thread, then drop and count. Your code already returned.
Buffer fills (traffic spike, long outage)Memory is bounded (max_buffer, default 10,000). The newest event is dropped and counted; nothing blocks, nothing grows unbounded.
A bug inside keeps itselfCaught and logged at debug level. The exception never reaches your code.
Process forks (gunicorn prefork, etc.)The child starts a clean buffer — no double sends, no inherited locks.
Misconfiguration (no API key)SDK disables itself with one log line. Your app runs normally.
Kill switchKEEPS_DISABLED=1 (or disabled=True) makes every call a no-op.
Delivery is best-effort, not at-least-once — keeps deliberately drops data rather than risk your process. The transport is intentionally boring: an HTTPS POST of JSON (gzipped once a batch crosses ~1 KB) to a single endpoint, retried up to three times with jittered exponential backoff, honoring Retry-After on 429s, and never retrying any other 4xx (a rejected batch is dropped, not re-sent). You can watch the counters at any time:
print(keeps.stats())
# {'initialized': True, 'disabled': False, 'emitted': 1240, 'sent': 1240,
#  'batches': 7, 'retries': 0, 'dropped_overflow': 0, 'dropped_send': 0,
#  'accepted': 1240, 'rejected_by_server': 0}
A non-zero dropped_overflow or dropped_send tells you keeps shed load — and that your app was never affected while it did.
Because delivery is asynchronous, in short-lived or serverless workers the process may be frozen or killed before the 1-second flush tick. Call keeps.flush() before the unit of work returns so buffered events are delivered. See Reliability for the full pattern.

Verify it yourself

You never have to take the above on faith.
1

Point keeps at your own collector

Set KEEPS_INGEST_URL (or pass endpoint=) to an endpoint you control and inspect every byte the SDK emits.
keeps.init(api_key="lk_test_…", endpoint="https://your-collector.internal")
init() appends /v1/events to the endpoint if you don’t include a path. The default endpoint is an RFC 2606 .invalid host that can never resolve to a third party — until you set KEEPS_INGEST_URL (or pass endpoint=), nothing is sent anywhere.
2

Confirm what leaves your process

Point KEEPS_INGEST_URL at a collector you control, capture a known event, and inspect the payload — you will see your inputs recorded verbatim (keeps does not redact) and any bytes value reduced to a {__bytes__, length} stub. Redact sensitive fields yourself before the call.
3

Watch the no-op path

Set KEEPS_DISABLED=1 and confirm keeps.stats() reports disabled: True and nothing leaves the process.

Trivially removable

There are no decorators, no monkey-patching, and no auto-instrumentation. keeps only runs where you explicitly call it, so removing it is mechanical: delete your keeps.init(...) and keeps.capture(...) lines, and the SDK is gone. Nothing about your provider calls changes, because keeps was never in them. The single fastest off switch — no code change at all — is the environment:
export KEEPS_DISABLED=1
Every keeps call becomes a no-op, and your application behaves exactly as if the SDK were not installed.

Reliability

Buffering, flushing, overflow, and the serverless flush pattern in depth.

Recording provider calls

How to instrument fal / Replicate / OpenAI calls with capture().

Core concepts

Events, assets, correlation by asset_id, and properties.

API reference

Every option on init, capture, context, flush, and stats.