Blog

Who did that, and did it work

2026-06-19

An isometric illustration of a rubber stamp standing on its handle

Someone asks you who granted a customer a free year of Pro back in March, and whether it worked.

If the answer lives in your application logs, you are about to find out how long they are retained and whether anyone thought to log the failed attempts. If it lives in a hosted audit product, you are about to find out what it costs to query a year back.

pithy add audit puts it in a table in your own D1, and the interesting parts are where the rows come from.

Why a database and not a log

The obvious cheap answer is Workers KV, and it is the wrong shape.

An audit trail is a query workload. You ask it by actor, by action, by time range, by resource, by outcome — usually several at once. KV is get-by-key. It would store the events beautifully and answer none of the questions you actually have.

So audit is D1, and by default it shares the app database rather than standing up its own. That is a deliberate small decision: sharing means the audit migration tracks alongside your app’s migrations in one pithy_migrations table, instead of two providers arguing over the same binding. The physical binding is config-selectable if you want it elsewhere.

Every event records whether it worked

The field people forget is the outcome.

A trail of things that happened is half a trail. The rows that matter in an investigation are usually the ones where somebody tried — a denied admin action, a token reuse that got refused, a login that failed four times before succeeding. If your audit only records successes, the attack looks like nothing happened until the moment it worked.

So an event carries who, what, when, against which resource, and whether it succeeded.

Capabilities write to it without importing it

Here is the structural bit, and it is the same rule the rest of the kit follows: a capability may depend on a core seam, never on a sibling.

payments writes an entitlement grant to your audit trail. auth writes logins, denials and token-reuse alerts. Neither one imports @pithy-sh/audit. They record through a seam in core, and if you have not composed audit, the recording is a no-op rather than a crash.

That means adding audit later is not a migration through your codebase. You compose it, and events that were already being emitted start landing in a table.

Actions are named through defineAuditActions, so each capability declares its own constants federated into one namespace. You get the same treatment for your own actions — declare them, and they sit alongside the kit’s rather than in a parallel scheme.

The CLI writes to it too, as a real person

This is the part I did not expect, and it is the reason the trail is worth trusting.

Administrative things happen from a terminal. Somebody runs a command that grants an entitlement, rotates a secret, or provisions a resource. In most systems that shows up in the audit trail — if at all — as cli, or system, or an API token id nobody can map to a human.

Pithy asks Cloudflare who you are.

your tokenwhat it resolves to
a user token (cfut_*)the Cloudflare user behind it — id and email
an account token (cfat_*)the token verified against the account, by id and name

So a CLI-originated event is attributed to a person with an email address, or to a named credential you can recognize. Not to “the CLI”. When you read back a year later, the actor column says something you can act on.

The resolver caches, because it is asking a real API and a command may emit several events.

What you actually do with it

The trail is a table in your database, so the boring answer is: whatever you like. Query it with the same tooling as everything else.

The less boring answer is that it feeds two things you would otherwise build yourself. The audit column carries the deployed Worker version, so an event can be traced to the exact build that produced it. And the dashboard renders the trail directly, which is why the example seed data writes a short security timeline — a login, a denied attempt, an entitlement grant, an admin change, a critical token-reuse alert — so a fresh project has something real on the screen instead of an empty state.

What it does not do

It does not ship retention or archival policy. The rows accumulate in your D1 and pruning them is your decision, because how long you keep security events is a compliance question that depends on your business and not on us.

It does not stream anywhere. If you need events in a SIEM, the trail is a table you can read on a schedule — but the shipping is yours to write.

And it is not a general event log. It records security-relevant actions. If you want product analytics, that is a different table with different retention and a different reason to exist.