> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keeps.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Privacy & safety

> Why keeps is safe to drop into a production app — references not bytes, server-derived tenancy, never in the request path, and fail-open by design.

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:

```python theme={null}
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.

<Note>
  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.
</Note>

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):

```python theme={null}
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}],
)
```

<Warning>
  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.
</Warning>

`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:

```python theme={null}
import hashlib

uid = hashlib.sha256(user.email.encode()).hexdigest()[:16]
keeps.capture(model="flux-pro", status="completed", user_id=uid,
             properties={"tier": "pro"})
```

<Note>
  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.
</Note>

## 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:

```python theme={null}
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:

```python theme={null}
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.

| Failure                                   | What happens to your app                                                                                                            |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| keeps ingest is down / slow / erroring    | Nothing. 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 itself                 | Caught 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 switch                               | `KEEPS_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:

```python theme={null}
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.

<Note>
  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](/reliability) for the full pattern.
</Note>

## Verify it yourself

You never have to take the above on faith.

<Steps>
  <Step title="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.

    ```python theme={null}
    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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Watch the no-op path">
    Set `KEEPS_DISABLED=1` and confirm `keeps.stats()` reports `disabled: True`
    and nothing leaves the process.
  </Step>
</Steps>

## 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:

```bash theme={null}
export KEEPS_DISABLED=1
```

Every keeps call becomes a no-op, and your application behaves exactly as if the
SDK were not installed.

## Related

<CardGroup cols={2}>
  <Card title="Reliability" href="/reliability">
    Buffering, flushing, overflow, and the serverless flush pattern in depth.
  </Card>

  <Card title="Recording provider calls" href="/recording-provider-calls">
    How to instrument fal / Replicate / OpenAI calls with `capture()`.
  </Card>

  <Card title="Core concepts" href="/core-concepts">
    Events, assets, correlation by `asset_id`, and properties.
  </Card>

  <Card title="API reference" href="/api-reference">
    Every option on `init`, `capture`, `context`, `flush`, and `stats`.
  </Card>
</CardGroup>
