Architecture

Database-first analytics, PostHog delivery, environment routing, and identity stitching

System architecture

Database-first delivery

The dispatcher persists a normalized event before attempting PostHog delivery:

  1. The client or backend creates a versioned event.
  2. The App validates the envelope and applies the property sanitizer.
  3. A row is inserted into journey_event.
  4. Delivery to PostHog runs after the response.
  5. Success writes posthog_delivered_at; failure records an attempt and safe error text.
  6. Pending rows can be retried. $insert_id uses the first-party event ID so PostHog can deduplicate repeated attempts.

Email delivery events stay in the first-party store for audit and aggregate reporting, but are marked delivered without being copied to PostHog. This keeps Activity focused on product behavior instead of notification noise.

Event identity

The browser creates a one-hour user_session_id in local storage. It also exposes the current PostHog distinctId and $session_id to the journey logger.

PostHog receives the first available stable identity in this order:

  1. authenticated Zooly user ID
  2. PostHog distinct ID
  3. user_session_id
  4. actor account ID
  5. a journey-event fallback ID

The Stats API groups rows by user_session_id first. This lets activity that began anonymously remain in the same journey after signup associates later events with a user or account.

Verified email, username and admin/zQA flags are now allowlisted person properties. App resolves its own account tag separately from the target account. Login refreshes browser identity; successful logout resets both the SDK and journey ID. See Identity and zQA Filters for the guest-ID model, trusted enrichment channel and retry boundaries.

Actor and target accounts

Account fields have different meanings:

  • account_id / actor_account_id: the authenticated actor's account, resolved by the server
  • entity_id / target_account_id: the account or entity being acted on
  • agency_ids and agency_slugs: active agency relationships derived from account_agents

The browser can describe a target through entityId, but it cannot choose its actor account. This prevents a client from attributing activity to an arbitrary account.

PostHog receives both flat properties and $groups for account and agency. Flat fields remain usable when Group Analytics is unavailable on the current PostHog plan.

Auth routing

zooly-auth is shared by Dev and Production, so it cannot infer the destination from its own deployment alone. The App passes a validated journey context containing:

  • allowed origin
  • user_session_id
  • PostHog distinct ID
  • PostHog session ID

Auth accepts only known origins and selects the matching signed ingest target:

  • dev.zooly.ai → Development ingest
  • app.zooly.ai → Production ingest

The internal endpoint validates Authorization: Bearer <secret> using a timing-safe comparison. Arbitrary return URLs cannot select an ingest destination.

Failure boundaries

  • Browser logging is fire-and-forget and never throws into product UI.
  • PostHog delivery happens after first-party persistence.
  • Missing PostHog configuration disables external delivery but not database storage.
  • Auth analytics delivery failure does not change the auth response.
  • Payloads over 16 KiB or invalid versioned envelopes are rejected before persistence.