Nakodo has 77 call sites that look like this:
await audit({ action: "campaign.launched", userId, entityType: "campaign", entityId: campaign.id });
One line, one row in audit_log. For 17 of the 64 possible actions it is also one event in PostHog, under the same name, and for one entity type a field quietly does not make the trip.
The two things an action log is for
There is a log of what happened because somebody has to be able to answer "who stopped this campaign and when". There is a product analytics tool because somebody has to be able to answer "do people who launch a second campaign stay". Those are different questions, with different storage, different retention and different readers.
They are also, for a large fraction of events, the same moment in the code. Writing both calls by hand at 77 sites means that in six months some of those sites will have one and not the other, and nobody will notice until a funnel has a hole in it.
So there is one function:
export async function audit(entry: AuditEntry): Promise<void> {
forward(entry);
try {
await db.insert(auditLog).values(row(entry));
} catch (e) {
console.error("audit log write failed", entry.action, e);
}
}
The log write is awaited. The forward is not: it hands off to a fire-and-forget sender that runs after the response.
Never throwing is the point
That try around the insert is not defensive habit. An audit log that can fail the action it is recording is strictly worse than no audit log, because the failure mode is "the user could not delete their campaign, because we could not write down that they deleted their campaign".
The same reasoning runs through the sender, which swallows its own errors and only logs them. Nothing a customer does should be able to fail because an analytics host is having an afternoon.
Which 17, and why not 64
const TRACKED = new Set<AuditAction>([
"user.signed_up",
"onboarding.completed",
"campaign.created",
"campaign.launched",
"campaign.paused",
"campaign.find_more",
"campaign.deleted",
"channel.shortlisted",
"outreach.started",
"outreach.approved",
"outreach.replied",
"outreach.said_no",
"plan.changed",
"support.opened",
"export.csv",
"account.exported",
"account.deleted",
]);
Read down it and you can see the shape of the product: sign up, finish setup, create a campaign, launch it, shortlist somebody, start outreach, approve it, get a reply, change plan. That is the journey how Nakodo works describes in prose, written out as the only events anybody is going to build an insight on.
The other 47 are things like outreach.redrafted, campaign.keywords_added, admin.notice_sent, password.reset_requested. They belong in the log, because the log is the record. They do not belong in an analytics project, where every unused event name is a line in an autocomplete dropdown that makes the useful ones harder to find.
A set rather than a flag on each call site, because the decision "is this worth an event" is a product decision about the whole list, and it should be reviewable as a list.
The field that gets dropped
function forward(entry: AuditEntry) {
if (!entry.userId || !TRACKED.has(entry.action)) return;
const entityId = entry.entityType === "channel" ? undefined : entry.entityId;
const properties: Record<string, unknown> = { ...entry.metadata, entityType: entry.entityType, entityId };
if (entry.action === "plan.changed") properties.$set = { plan: entry.metadata?.to };
track(entry.userId, entry.action, properties);
}
Two things happen in those four lines.
A channel's ID never reaches PostHog. Platform data on Nakodo lives under retention rules that a maintenance job enforces, and those rules cannot reach inside a third-party analytics store. Our privacy notice says YouTube data is deleted within 30 days unless it is refreshed, and an analytics event that kept the channel ID for a year would make that sentence false. The cheapest way to honour a retention rule is to never let the data out of the system that enforces it.
entityId for a campaign is fine, because a campaign belongs to the account and goes when the account goes. Same field, same code path, one undefined based on what kind of thing it points at.
The second thing is $set. A plan change is both an event and a fact about the person, so the same call stamps plan on the person profile. Without it, "activity by plan" means joining every event back to a plan the hard way. With it, the breakdown is a dropdown.
The three events that do not come from here
track(thread.userId, "outreach.first_sent", { campaignId, kind, origin });
track(thread.userId, "outreach.creator_replied", { ... });
track(thread.userId, "outreach.handed_off", { campaignId, kind, by: byUser ? "brand" : "nakodo" });
Those happen with nobody signed in. A first email goes out on a schedule, a creator replies at midnight, an introduction is made because a reply said yes. There is no session, no request and no browser, but there is an account that cares, so the events are sent for the campaign's owner directly.
by: byUser ? "brand" : "nakodo" is there because that distinction, between a customer doing the thing and the product doing it for them, is the single most interesting number in an automation product. The row in the audit log carries the same field in its metadata.
The log row itself
userId: uuid().references(() => authUsers.id, { onDelete: "set null" }),
actor: text({ enum: ["user", "system"] }).notNull(),
action: text().$type<AuditAction>().notNull(),
entityType: text({ enum: ["campaign", "channel", "user", ...] }),
entityId: text(),
metadata: jsonb().$type<Record<string, unknown>>().notNull().default({}),
action is text in Postgres with the union as a TypeScript type on top, rather than a Postgres enum, because adding a value to a Postgres enum is a migration and adding one here is a line in a union. Sixty-four values that grow with every feature is exactly the case where that trade is worth making.
onDelete: "set null" rather than cascade. A deleted account's rows stay, unlinked, which is what the privacy notice promises and what makes the log usable for the security questions it exists to answer.
actor is derived, not passed: entry.userId ? "user" : "system", so a cron job cannot accidentally file its work under the last person who touched the campaign.
There is also auditMany, which exists because one customer skipping 200 creators at once should be 200 rows in one insert and not 200 inserts, and because entries.forEach(forward) is the right amount of code for the analytics side of that.
What I would keep
The pattern worth stealing is small: one call site, one function, an allowlist deciding what is also an event, and a transform in the middle that knows which identifiers are not allowed to leave the building. The alternative, which I have written before, is an analytics.track() sitting next to an audit() at every call site, drifting apart one feature at a time.
Top comments (0)