Systeric / Docs
Open App →

Working with Events

The word “event” means three completely different things in a codebase, and confusing them causes real bugs. An engineer who treats a product-analytics event like a domain event (and builds business logic on it) ships something fragile; one who treats a domain event like a log line loses data. This doc separates the three and shows how to work with each.

The one thing they share is the shape of the idea: an event is a fact that already happened, named in the past tense, recorded for someone else to use later. Who that “someone else” is, another part of the system, your future debugging self, or the analytics funnel, is what makes them different.

KindWho consumes itMust not be lost?Tool here
DomainAnother part of the systemYes, it drives behaviorpgmq queue
ObservabilityYou, debugging laterSampling is fineOTel spans → Honeycomb
AnalyticsThe product/growth funnelRoughly completePostHog

Domain Events: the system talking to itself#

What’s expected: You use a domain event when something happens that other parts of the system must react to, asynchronously, and you treat delivery as a real, at-least-once contract.

A domain event says “this business fact occurred”, OrderPlaced, UserInvited, PaymentCaptured, and lets other parts react without the producer knowing or waiting. Instead of the checkout code directly calling the email service, the inventory service, and the analytics pipeline (and blocking on all three, and failing if any is down), it emits one OrderPlaced event and moves on. Consumers pick it up on their own time. This is how you decouple and how you keep the request fast. Ours run through the pgmq Postgres-backed queue.

The rules that keep this from biting you:

  • Emit the event as a fact, after it’s true. Past tense, and only once the change is actually committed. Emitting OrderPlaced before the DB transaction commits means a consumer can react to an order that then rolls back. Tie the emit to the commit (the transactional-outbox idea): the event and the state change succeed together or not at all.
  • Assume at-least-once delivery. Make consumers idempotent. A queue will occasionally deliver the same message twice. If processing OrderPlaced twice sends two emails or charges twice, that’s a bug in the consumer. Key off the event id or the order id so a repeat is a no-op.
  • Don’t assume order. Two events can arrive out of the sequence they were emitted in. If B depends on A having happened, the consumer checks state, it doesn’t trust arrival order.
  • Version the shape. Other code depends on the event’s fields. Adding a field is safe; removing or renaming one breaks consumers. Treat the event like a public API, because it is one.

Reach for a domain event when work can happen later and elsewhere. For something that must be true before you respond to the user, just call the function directly, an event is the wrong tool for a synchronous answer.


Observability Events: the system talking to future-you#

What’s expected: You record one rich, wide event per unit of work, with every field you might later want to slice by.

An observability event is what you’ll query at 2am. The modern form is the wide event: instead of scattering ten console.logs through a request, you attach every useful field, user id, order id, cart size, which code path, how long each step took, to one structured event (an OpenTelemetry span) for that request. Later you can ask any question of it: “p95 latency for checkouts, for signed-in users, on mobile, last Tuesday”, because all those dimensions live on the same event.

  • Wide, not many. One event with thirty fields beats thirty log lines. It keeps the facts about one request together instead of scattered across the log stream.
  • High cardinality is a feature. Put the user id, the order id, the exact code on the event. The whole point is to filter down to one bad request; you can’t do that with only coarse buckets.
  • Sampling is fine. Unlike a domain event, losing some of these is acceptable, you keep enough to see the shape and to catch the errors. They’re for understanding, not for driving behavior.

This is the same idea as logging well, taken to its conclusion: stop narrating, start recording structured events you can query.


Analytics Events: the product talking to the funnel#

What’s expected: You track a user action as a clean, consistently named event, fired once at the real moment, with the properties the funnel needs.

An analytics event records what a user did, so the product team can measure behavior, checkout_started, order_completed, discount_applied. Ours go to PostHog from the frontend. These feed the funnels behind Setting Good Metrics and the Product group’s numbers, so their quality is a product concern, not just a technical one.

  • Name consistently: object_action, past tense, order_completed, not Order Done or completedOrder. A messy event namespace makes every later analysis harder.
  • Fire once, at the true moment. Fire order_completed when the order is actually placed, not when the button is clicked (which may fail). A double-fired or premature event quietly corrupts every funnel built on it.
  • Capture the properties you’ll segment by, value, item count, whether a discount was used, but never secrets or PII here either.
  • Don’t build logic on them. Analytics events can be blocked, dropped, or delayed by the client. They’re for measuring behavior, never for driving it. If the system must react, that’s a domain event.

The One-Line Test#

When someone says “add an event,” ask who consumes it and the right tool falls out: the system must react → domain event (durable, idempotent); you need to debug it later → observability event (wide, queryable); the funnel needs to measure it → analytics event (clean, once). Same word, three jobs. Pick on purpose.


Related: How to Log, Observability, Metrics vs Traces vs Logs, Setting Good Metrics