The activity log: what users did, without their words
5 min read
The first real support question was small and unanswerable: "I scanned a receipt on Tuesday and I can't see it — did it save?" The database could say whether a receipt row existed. It couldn't say what the person had done — whether they'd scanned and cancelled, scanned and hit an error, or scanned twice and deleted one. The app had state but no history of actions.
An activity log is the standard answer, and the standard answer has a standard failure mode: a table that quietly accumulates copies of every private thing users type, under different access rules than the data it copied, with a different retention lifetime. The whole design of this one is about not building that.
TL;DR — Users write their own rows; only admins read them. No user text, ever — a row carries an action key, a count and ids, and the admin UI composes the sentence from a constants file (28 keys, mirrored across both apps). One row per user action, never per write — a voice capture of 30 items is one row with
count: 30. Never log inside a mutation function — those re-run on retry and offline replay; log at the tap, with a queue that keeps the original timestamp when signal returns. Deliberately unlogged: item ticks (the ledger already is that record, at 30× the volume). And the repo's first GSIs: date-partitioned and per-user, paged with limits — the one screen that must not use the load-everything helper.
(Part 36 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
Rule 1: no user text, ever
A row looks like this in spirit: { userId, action: "receipt.scanned", count: 12, targetId, at }. No list name, no item name, no store, no note — nothing the user typed or the parser read. The admin feed composes the human sentence ("scanned a receipt with 12 lines") from a constants file of 28 action keys, mirrored verbatim between the mobile app that writes and the web console that reads.
Three things fall out of that one rule:
- Different auth, different lifetime, no leak. Activity rows are admin-readable and live a year; shopping items are user-private and live forever. Copying item names into the log would put private text under looser access with a different retention policy. Ids and keys leak nothing.
- Rewording needs no migration; a new action needs no deploy. The sentence lives in the UI. Change the words, change nothing in the table.
- The replication masker passes it straight through. A table with no free text has nothing to fake; the mirror copies it structurally, safely, by construction.
Rule 2: one row per action, never per write
The unit of logging is what the user did, not what the code did. A voice capture that creates 30 items is one row, count: 30. A receipt save that fans out into N item updates, a bulk add, and a receipt create is one receipt.scanned. If the log mirrored the write pattern, a single afternoon's shopping would produce a hundred rows nobody could read, and the count of "receipts scanned" would be wrong by a factor of the fan-out.
Which leads directly to the rule that took the most discipline: never log from inside a mutation function. Mutation functions re-run — on retry, and on offline replay after an app kill. A log call inside one would double-count every retried action and re-log yesterday's supermarket at the moment signal returned. The log call lives at the call site, at the tap — the moment the human did the thing. And because a mutation paused offline never reaches its success callback, the logger has its own queue for that case.
Rule 3: what is deliberately not logged
The absences were designed as carefully as the presences:
-
Item ticks.
checkedAton the item — the purchase ledger — already is that record, at roughly thirty times the volume of every other action combined. Logging it twice would drown the feed. - The auto-created default list, push-token refreshes, and writes to the shared product cache — machine actions, not human ones.
- Account deletion — the deletion Lambda erases its own rows, and the public web form is a guest who can't write activity at all. (Deletions are counted, not logged: the anonymous rollups from Part 32.)
The logger never throws and is never awaited
logActivity() is fire-and-forget by contract: it can't fail the action it's describing. On failure — offline, transient — it buffers to per-user local storage (capped, with a seven-day cutoff so an abandoned device doesn't flush a month of stale rows later), and flushes on foreground or reconnect. The flush keeps the original timestamp: an action taken in a supermarket yesterday must appear at yesterday's time, not at the moment the phone found signal in the car park. A log whose timestamps mean "when we managed to write it" is a log of connectivity, not behaviour.
The repo's first GSIs — and the screen that must not listAll
Every other read in this app pages through the load-everything helper — fetch all pages, filter client-side — because the data is per-user and small. An activity table is neither. Loading a year of rows to render ten is the bug the paging rule exists to prevent, inverted.
So this model got the repo's first secondary indexes: activityByDate — a bounded partition per local day — and activityByUser, both paged with a limit and a continuation token. A date-range query in the admin feed walks day partitions backwards, resuming from a {date, token} cursor and capped at 31 days per page; picking a person switches to the user index instead. The feed's filters live in the URL (?from=&to=&user=) so a refresh or a shared link keeps the view — which, in the App Router, means the page needs a Suspense boundary or it stops prerendering and the build fails. A small tax on a good habit.
Retention is a TTL attribute (one year out), set through the table wrapper — which needed two extra permissions on the provider role that nothing else had ever required. And TTL deletion emits stream REMOVE events, so when production rows expire, the mirrored dev copies expire with them. Retention policy replicates itself.
What I took away
- Log keys and counts, compose sentences in the UI. No user text means no leak, no migration for rewording, and a masker with nothing to do.
- The unit is the human action. One row per tap, however many writes it fans out into.
- Never log where code re-runs. Mutation functions retry and replay; the call site doesn't.
- Decide what you won't log, especially anything another field already records.
- Fire-and-forget, buffer on failure, keep the original time.
- Know which table breaks your paging convention — and give it real indexes before it does.
Next up
Part 37 walks through the door of the admin console — a web app bolted onto a mobile app's identity pool, where a valid sign-in proves nothing, and the front door has to send the wrong people straight back out.
What does your audit log accidentally copy — and would you be comfortable if its access rules were the loosest in your system?
0 reactions · 0 comments
Discuss on dev.to