ReviewOS

stacks/reportshq

Stacks integration package: auto-instrumented events

#7
Closed chris opened this 8 days ago ·
8 days ago

Context

"Integrate and your reports build themselves." For Stacks apps this means a first-party package that hooks the framework's own event surface - models with the observe trait emit product:created / order:created / user:created style events, and commerce/CMS/auth modules dispatch domain events - and forwards them to ReportsHQ as the standard taxonomy from docs/events.md (#6).

Scope

  • New package (suggested: @reportshq/stacks, repo stacksjs/reportshq-stacks or a packages/ workspace inside this repo - decide and document). Zero third-party deps; Bun-first.
  • Config: REPORTSHQ_KEY + optional REPORTSHQ_ENDPOINT env, a config/services.ts-style block, per-domain toggles (commerce: true, users: true, cms: false), sampling rate.
  • Transport: in-process buffer with size + interval flush, batched POST /ingest, bounded retry with backoff, drop-oldest under pressure, never blocks the request path, flush on shutdown.
  • Mappers from Stacks events to taxonomy events: commerce.order.created {total, currency, items}, commerce.order.refunded, user.registered, user.login, cms.post.published, ... - exactly the reserved names in docs/events.md; extend that doc first when a mapper needs a new name.
  • Wiring: one-line install documented (register listeners via app/Events.ts or a package-provided boot hook - whichever the framework supports cleanly; if a clean package-boot hook is missing in Stacks, add one upstream rather than documenting a workaround).
  • Tests against a fake ingest server: batching, retry, sampling, mapper payload shapes.
  • Publish with bun run release:patch once green; dogfood it inside reportshq itself (ReportsHQ reporting on ReportsHQ).

Acceptance

  • A fresh Stacks app with the package installed and REPORTSHQ_KEY set emits user.registered and commerce.order.created events visible via the read API (#6) with no further code.

Depends on #6. Feeds #14. Part of #1 (M5 Integrations and auto-reports). Ground rules: see #1.

8 days ago

Done in ea67fbd. CI green, 32 package tests plus 3 new drift tests in the app.

Acceptance, verified rather than asserted. A project was created, listen() was spread into an events map exactly as an app would, two framework events were emitted, and the process exited. user.registered and commerce.order.created arrived correctly mapped, with value, currency, subject and properties, delivered by the shutdown hook with no explicit flush, and visible through the read API. No other code.

Where it lives. packages/stacks inside this repo, not a separate one. The deciding argument is that the mapper table has to agree with docs/events.md, which is also what the report templates, the app's validation and the builder's friendly names are written against. Keeping them in one repo means one test can assert they agree, and it now does, in both directions, plus a check that no two framework events map onto one taxonomy name (which would silently double-count).

Sampling keeps subjects whole, not events. This is the design decision worth arguing with if anybody disagrees. Sampling events independently is what sampleRate usually means and it quietly ruins the reports it feeds: a funnel asks how many people who viewed a product went on to check out, and if each of those events is kept or dropped by its own coin flip, the steps stop belonging to the same people and every conversion rate becomes noise. Subjects are hashed instead, so somebody is wholly in the sample or wholly out, and funnels, retention and unique counts stay internally consistent. Totals are then a sample and are not scaled up, because scaling invents precision the sample does not have.

The transport exists to make one promise: an application is never slower or less available because of analytics.

  • track() appends to a buffer and returns. Nothing on a request path waits for HTTP.
  • The buffer is bounded and drops the oldest at the limit. If delivery has been failing, recent events describe what is happening now, which is what somebody staring at a dashboard during an incident needs.
  • 5xx and 429 retry with backoff. 4xx does not: a bad key is bad every time, and retrying is a slower way to fail while blocking everything behind it.
  • Timers are unref'd, so a CLI that sends one event still exits.
  • beforeExit, SIGINT and SIGTERM flush. A deploy is exactly when events are most likely to be lost.
  • Nothing throws into the host. onError is there if you want to hear about failures.

With no key it registers no listeners at all, rather than listeners that do nothing. An unconfigured app cannot pay for this package in any way, which matters because the same code runs in tests and on a laptop.

Two things worth noting for whoever does #8 (Laravel):

CI did not run the package's tests until I added a step for it: buddy test walks the app's tests/ only. It also does not typecheck packages/ - the include list is framework-owned and stops at the app - and adding a separate tsconfig.json for the package immediately surfaced two real errors, including a cast that claimed to produce a typed event without checking it still had a name on it. Both are now in CI.

Not done here: publishing to npm. The package is versioned 0.1.0 and ready, but I have not run a release, since that is a public artefact and worth being a deliberate decision rather than a side effect of a work session. Say the word and it goes out.

Sign in to comment on this issue.