Metrics and connectors
A worked example across every layer. Three vendors, one metric pipeline, three views, and the rule that decides what we store.
Metrics and connectors
A worked example, not a new layer.
Three asks arrive together: a revenue dashboard from Stripe, a product dashboard from PostHog, and a system errors dashboard from Sentry. Each one must become rows in tables that already exist. If any of them needs new machinery, the architecture has failed its own test.
Do not build three dashboards
Build one metric pipeline, three source adapters, and three filtered views.
Three dashboards means three ingest shapes, three storage decisions and three page types, and the fourth source costs the same again. One pipeline means the fourth source is a connections row plus a workflows row.
What we keep
This is the rule that decides everything else. There are three answers, and no fourth.
| The data | Where it lands | Why |
|---|---|---|
| A number over time — MRR, DAU, error rate | A metrics row in company state | An agent reasons over it, and a trigger fires on it |
| An entity we already own — a Stripe customer | An external_refs mapping to the CRM row | We map a vendor id. We never mirror a vendor's object model |
| A record detail — one invoice, one error event | Nothing is stored. A read tool fetches it at render | It is unbounded, the vendor owns it, and it is a drill-down |
So each dashboard is stored numbers plus a live drill-down.
We are not rebuilding three analytics products. We store the few numbers the company is operated on, so that they sit in one view and an agent can act on them. Deep analysis stays in the vendor product, behind a link. That boundary is the main scope risk in this feature, and it is easy to cross by accident.
The pipeline
One shape. Every source uses it.
trigger → vendor tool → normalize → metrics.upsert → metrics → view
L4 L2 L4 L2 L3 L6
| Stage | Layer | What it is |
|---|---|---|
| Trigger | L4 | A triggers row: a schedule, a webhook, or both. |
| Vendor tool | L2 | A tools row over an adapter, and a connections row for the credential. |
| Normalize | L4 | A workflow step. It discards the payload and keeps one number per period. |
metrics.upsert | L2 | An internal tool. A run writes to company state only through a tool. |
metrics | L3 | One table, one shape, every source. |
| View | L6 | The Company view, filtered by domain. Not a new surface. |
No model runs anywhere in this pipeline. Ingest is deterministic. An agent reads the result later; it does not do the ingest.
The three sources
| Source | Domain | Push | Stored as metrics | Read live |
|---|---|---|---|---|
| Stripe | Finance | Webhook, plus a daily reconcile | MRR, new customers, churn rate, failed payment count | One invoice, one subscription |
| PostHog | Product | None. Schedule only | DAU and WAU, activation rate, retention, feature adoption | One funnel, a session replay |
| Sentry | Engineering | Webhook, plus a reconcile every 15 minutes | Error rate, new issues, regressions, p95 latency | One issue, one event |
A webhook always has a schedule beside it. Delivery is at-least-once and a message can be missed. The schedule re-reads the same window and the upsert makes the repeat harmless. A webhook on its own is a dashboard that is quietly wrong.
Why we store the number
A live proxy renders the same chart, and it costs less today. This is what it cannot do.
a new metrics row lands: error_rate = 4.1% (yesterday it was 0.4%)
│
▼
a trigger is watching that metric
│
▼
an agent starts. Its brief holds the metric, the runbook and the last incident
│
▼
it proposes: open a P1 task, and page the on-call
│
▼
policy: allow, or require approval
│
├── allow ──────────────────────────────► the tool acts
└── require approval ──► a person decides ──► the tool acts
A trigger cannot watch a chart that is fetched at render time. Storing the number is what turns a dashboard into something that watches the company. The dashboard is the side effect; the trigger is the product.
Rules
- A metric is company state. It is not a cache of a vendor, and it is not a report.
- Ingest is an upsert, never an append. The key is
(key, grain, at, dimensions). A vendor restates a closed period, so the window must be re-runnable. - One metric key means one thing, in every source.
churn_ratehas one definition, one grain and one unit. Two definitions is worse than no metric. - The query definition is versioned with the metric. A PostHog insight can be edited, which silently changes what the number means. Store the definition version on the row.
- Store the native currency, and a normalized value beside it. The rate is itself a metric, with its own source.
- Everything is UTC, and
atis the period start. A boundary bug is invisible and it never gets found. - A drill-down is a read tool, and it is still policy-checked. Read-through is not a bypass.
- Never mirror a vendor's object model. If the answer needs one, the answer is a link to the vendor.
Data model
Two tables, and one worked shape.
metric
key mrr · new_customers · churn_rate · dau · activation_rate · error_rate
source stripe · posthog · sentry
domain finance · product · engineering
grain hour · day · month
at the period start, UTC
value numeric
unit currency · count · ratio · millisecond
dimensions plan, country, release
definition the version of the query that produced it
observed_at when we read it
source_ref the vendor id behind it
| Table | Holds |
|---|---|
metrics | One number, for one key, at one period. Every source writes the same shape. |
external_refs | One mapping: our entity, our id, the connection, the vendor id. This is what makes revenue per account possible without mirroring Stripe. |
connections and tools are already in the tools layer. Three connections and about a dozen tool rows cover all three sources.
Risks
| Risk | Mitigation |
|---|---|
| It grows into an analytics product, badly | Store only numbers the company is operated on. Deep analysis is a link to the vendor |
| A vendor restates a closed period and the chart is wrong | Upsert on the key, and re-read a trailing window on every schedule |
| A webhook is missed, and the number is quietly stale | Every webhook trigger has a reconcile schedule beside it |
| A metric definition changes and history becomes incomparable | The definition version is on the row. A change is a new version, never an edit |
| Ingest hits a vendor rate limit and retries make it worse | The adapter owns the limit and backs off. The run waits at a step boundary |
| A metric key means two things in two views | One key, one definition, one owner. A second meaning is a second key |
| Vendor credentials sit in a row | The vault holds the value. connections holds a reference |
Open questions
- What is the trailing reconcile window per source? Stripe refunds can land weeks late, and Sentry merges issues.
- Do we store a metric per account, or only company-wide? Per account is what makes revenue actionable, and it multiplies the row count.
- Who owns a metric definition? A metric with no owner drifts.
- Does a threshold live on the metric, on a trigger row, or on a policy limit? All three are defensible, and only one should be true.
- What is the retention for hourly grain? A daily rollup is cheap, and it loses the incident detail.
Diagram source. ac-docs/diagrams/mission-control-layers.drawio, page mission-control-metrics.
Edit that page, then run ./scripts/export-diagrams.sh mission-control-layers.
Update this page and the diagram together.