---
title: Python SDK
path: sdk/python
status: published
---

# Python SDK

The Python SDK is the reference ScaiLog SDK: `from scailog import log, pi`, and
you have a structured, GDPR-native logger. It emits entries over a Unix socket to
the local `scailog-agent` and marks personal information at the field level. This
page covers install, a full emit, the log levels, `call_site_id` rules, context
propagation, and what happens when the agent socket is unavailable.

## Install

The base install is the SDK only, and it is **pure stdlib** — zero third-party
dependencies:

```bash
pip install scailog
```

Importing the SDK opens no socket and loads no heavy dependency; the process
logger is built lazily on first use. The running components (`scailog-server`,
`scailog-agent`, the CLI, the audit job) each pull their own extra —
`pip install 'scailog[server]'` and so on — but a service that only emits logs
needs none of them.

## Emit a log entry

`log` is a lazily-initialized process logger configured from the environment.
Call a level method with a `call_site_id`, a constant `message`, and structured
`fields`; wrap any personal-information value in `pi()`:

```python
from scailog import log, pi

event_id = log.info(
    "SCAI-CHECKOUT-PAID-001",
    "order paid",
    subject="usr_8842",  # required because a pi() field is present
    fields={
        "method": "card",
        "email": pi("jan@example.nl", "direct_identifier"),
        "ip": pi("203.0.113.7", "online_identifier"),
    },
)
```

The call returns the entry's ULID `event_id` (a lexicographically sortable
string). Plain fields (`method` above) are stored in cleartext and must stay
PI-free; `pi()` fields are routed to the encrypted `pi` map and never enter the
plaintext full-text index. The `message` is a constant description — put variable
data in `fields`, never in the message.

### Configuring the process logger

`log` reads these environment variables (with defaults):

| Variable | Default | Meaning |
| --- | --- | --- |
| `SCAILOG_TENANT` | `default` | Tenant id |
| `SCAILOG_SERVICE` | `unknown` | Service name |
| `SCAILOG_ENV` | `prod` | Environment |
| `SCAILOG_SOCKET` | `/run/scailog/agent.sock` | Agent Unix socket path |

When you need more than one configuration in a process, construct a `Logger`
explicitly:

```python
from scailog import pi
from scailog.sdk import Logger

log = Logger(tenant="acme", service="checkout", environment="prod")
log.info("SCAI-CHECKOUT-PAID-001", "order paid",
         subject="usr_8842",
         fields={"email": pi("jan@example.nl", "direct_identifier")})
log.flush()   # best-effort drain before exit
log.close()
```

## Log levels

Six methods, in increasing severity, all with the same signature
`level(site, message, *, subject=None, fields=None, trace_id=None, request_id=None)`:

```python
log.trace("SCAI-CACHE-MISS-001", "cache miss")
log.debug("SCAI-CACHE-EVICT-001", "evicted key")
log.info("SCAI-USER-LOGIN-001", "user logged in", subject="usr_8842")
log.warn("SCAI-RATE-NEAR-001", "approaching rate limit")
log.error("SCAI-DB-TXN-FAIL-001", "transaction rolled back")
log.fatal("SCAI-BOOT-CONFIG-001", "missing required config")
```

## `call_site_id` rules

The first argument is a developer-assigned call-site id and it is validated
before the entry is emitted. It must match `^[A-Z0-9]+(-[A-Z0-9]+){2,}$` — at
least three uppercase-alphanumeric segments separated by hyphens. An id that does
not match raises `ValueError`:

```python
log.info("bad_site", "nope")
# ValueError: invalid call_site_id: 'bad_site' (pattern ^[A-Z0-9]+(-[A-Z0-9]+){2,}$)
```

The `AUTO-` and `SCAILOG-` prefixes are reserved: the SDK accepts them at runtime
(they are used for auto-derived and internal sites) but you should not assign
them yourself, and `scailog-ci` rejects them at build time. If you pass an empty
site, the SDK derives an `AUTO-<hash>` fallback rather than dropping the entry.

Two more contract checks raise `ValueError`:

- A reserved field name (`event_id`, `msg`, `subject`, …) →
  `reserved field name: '<name>'`.
- A `pi()` field with no subject →
  `SUBJECT_REQUIRED: entry has PI markers but no subject=`.

## Context propagation

Request/trace ids and the data-subject assertion propagate through
`contextvars`, so handlers don't have to thread them through every call. Bind
them for the duration of a block with `context()`; the logger uses them as
defaults when you don't pass `subject`, `trace_id`, or `request_id` explicitly:

```python
from scailog import context, log, pi

with context(request_id=req.id, trace_id=req.trace, subject=user.id):
    # picks up subject + request_id + trace_id from the ambient context
    log.info("SCAI-API-HANDLED-001", "handled",
             fields={"email": pi(user.email, "direct_identifier")})
```

An explicit keyword argument on the call always overrides the ambient context.

## Stderr fallback when the agent is unavailable

Entries are queued in process (bounded queue, default 10,000) and sent by a
background thread as NDJSON over the Unix socket. ScaiLog never *silently* drops
a log: if the queue overflows or the socket can't be reached, the entry is
written to stderr in the same NDJSON shape instead.

Because stderr typically lands in container/journald logs, PI field **values**
are stripped before the fallback write — the field name and its `pi_type` are
kept (so the drop is auditable) but the value is replaced with
`{"type": "...", "redacted": true}`. This is deliberate: writing plaintext PI to
stderr would be the exact leak ScaiLog exists to prevent. Plain fields and the
`message` are PI-free by contract, so they pass through unchanged.
