Notes
One event, two consumers
by Omar Crosby · Updated July 14, 2026
Why the metering plane for paid tiers rides the audit stream we already had — and what we chose not to build.
The obvious way to add paid tiers to a product is to bolt on Stripe Billing, sprinkle metering calls through the code, and ship. The path is well-documented, the vendor is excellent, and it works. It’s also the wrong shape for a system that already emits structured events for a different reason — every metering call you add is a second instrumentation of a thing the codebase is already telling you about.
We had to build audit for compliance regardless. So the design question wasn’t how do we add metering? It was how do we make sure we never add metering as a separate concern?
The move: audit is the metering stream
Every service in the portfolio emits audit events through one function, audit.Emit(ctx, event). Each event carries a taxonomy — actor type and ID, subject, resource kind, resource path, service, and free-form attrs — that was designed for compliance and forensics. That same taxonomy is exactly what a metering system needs: who did what, when, to which resource, under whose authority.
So audit and metering share one instrumentation and one wire format. The audit sink is dual: a best-effort stderr_json for humans-reading-logs, and a durable Postgres sink for the events that need to survive to become invoice lines. A separate worker — jk-metering, roughly three hundred lines of Go — polls the audit_events table on a five-second interval, transforms each row into one Lago event with code = "usage", POSTs it, and marks the row consumed. Idempotent source, at-least-once sink, no message bus.
The two consumers see the same event through different lenses. Compliance reads for “was this action authorized?” Metering reads for “does this action increment a meter on someone’s plan?” Neither instruments anything the other doesn’t already emit.
The shim is intentionally dumb
The metering worker contains no SKU logic. It does not know what a plan is, what a meter is, or what anything costs. It is a JSON property pump: it flattens the audit envelope into Lago event properties and forwards it. Every discrimination — “this event increments the NWSL meter,” “this event triggers overage pricing on the Pro plan,” “this customer gets 20% off Q3” — happens as configuration inside Lago, in the operator UI.
This is a bet with real teeth: most future revenue work never touches deployable code. Adding a new tool to an existing MCP server bills automatically because the per-server metric already captures it. Adding a new pricing bundle is a Lago admin click. Launching a promotion is a coupon. The shim gets touched only when we add an entirely new shape of event to the audit schema — which happens on the order of quarters, not weeks.
The alternative — SKU logic in Go, with a code-review-and-deploy cycle for every pricing tweak — is what most in-house billing projects turn into, and it is why most in-house billing projects lose to the business team’s need to iterate on pricing weekly.
A three-AM gotcha, resolved: postgres-partman
Lago’s events table is append-only and unbounded. Every billable action, forever. Vendors that ship Lago as an image bundle the pg_partman PostgreSQL extension, which auto-partitions the table by time so queries against recent events stay fast as history grows. Their canonical docker-compose.yml uses getlago/postgres-partman, not vanilla Postgres.
The naive path — fly postgres create, wire it up, run Lago’s migrations — fails on migration because pg_partman isn’t in the base Postgres image. The failure surfaces as a stack trace about a missing extension, at the exact moment you’re trying to onboard.
The fix is to deploy getlago/postgres-partman:15.0-alpine as its own Fly app with a mounted volume, and treat Lago’s Postgres as a first-class piece of infrastructure rather than a managed database. Single-instance, no HA, backups on the operator to schedule. Documented as a trade-off in the runbook so the next person doesn’t rediscover it at three in the morning during a launch.
Small gotcha, forty-line runbook fix. The reason it’s worth writing down is that this class of paper-cut — a vendor image expects a specific runtime, your platform gives you a generic one — will bite anyone self-hosting SaaS-adjacent tools. The pattern generalizes; the specific fix does not.
What we deliberately delegated
Proration, dunning, tax, multi-currency, refund flows, invoice PDF generation, customer portals, card storage, subscription state machines — all of it. Every one of those is a problem someone else has thought about for longer than we ever will, and every one of them is where in-house billing projects sink years of engineering.
Lago owns the plans, the meters, the invoices, and the subscription lifecycle. Stripe owns the cards, the tax, and the payment execution. The portfolio owns the audit event, the durable sink, the shim, and the real-time policy enforcement that Lago can’t reach on the request path (Redis-backed quota counters, per-call cost gates). Every state machine is a vendor’s. The design’s job is to stay small.
Honest status
None of this is live. The code is merged, tested, and reviewed. The design is committed in an ADR. The operator runbook is written. What’s left is deployment: put Lago on Fly, configure the first billable metrics, wire Stripe from test into live mode. The gate is operations, not engineering.
This is a note about a design that is about to be run against real customers, not a retrospective on a shipped system. In a month either the design will hold up under first contact with real invoices or it will teach us something we didn’t know to write down yet. Both are useful outcomes; only one produces the follow-up essay.
Why write about it now
The homepage says the collective is wiring the metering plane so paid tiers can land. That sentence should point at something more than a project card. This is that something — the shape of the plane, the choices behind it, and an honest note that the plane is on the runway, not yet in the air.