0013 — Dual retention for KPI telemetry: aggregate long, detail short

Renamed KPI → Metric on 2026-04-30 (PR-followup to PR #525). This ADR's body preserves the original wording from when KPI was the canonical term. Code, data, AWS resources, and active docs all use "metric" now; this file is kept verbatim as historical record. See docs/planning/kpi-to-metric-rename.md.

Context

Two kinds of telemetry feed the dashboard:

  1. Daily aggregate: one row per property per day, KPI values at that moment. Used for year-over-year sparklines.
  2. Per-event detail: e.g. one row per AppFolio sync run (every 15 min), browser-agent heartbeat. Used for operator debugging ("why did property X fail to sync last Tuesday?").

Naive approach — store everything forever — has a volume problem. At 1000 properties × 96 sync runs/day × 400 days = 38M rows of sync log alone. That's not a cost problem (DynamoDB handles it), it's a scan cost and query clarity problem.

Naive alternative — store nothing longer than a week — kills the year-over -year sparklines that are the whole point of the snapshot system.

Decision

Two tiers. Different TTLs per tier.

Tier Purpose Example entity TTL
Aggregate Year-over-year sparklines, trend analysis KpiSnapshot (PK=PROP#id, SK=KPI#date) 400 days
Detail Operator debugging, last-2-months audit SyncLog, activity-log entries 60–90 days

The rule: if a metric is a count, rate, or average — it belongs in the daily aggregate only. The detail log exists for the "which specific incident caused the spike" question, not for computing the spike.

The daily cron reads the detail logs (where applicable) and rolls them up into the aggregate. After that, the detail is expendable.

Worked example

Sync errors.

The dashboard sparkline for "sync errors" reads only KpiSnapshot. The "show me the failures from last Tuesday" operator view reads SyncLog (as long as Tuesday was within 90 days). After 90 days, you still see "on 2026-01-15 there were 4 sync errors" (from KpiSnapshot) but you can no longer see which properties or which error codes.

That's the accepted trade.

Consequences

Easier

Harder

Follow-up

Alternatives considered

Store detail forever at matched 400-day TTL. Rejected. 38M rows of sync log per year per 1000 properties. Not a cost issue, a clarity issue — most queries would have to filter by recency anyway.

Store aggregate at matched 90-day TTL. Rejected. Kills the year-over -year sparkline, which is the whole product feature that justified the snapshot system.

Single tier, aggregate only, no detail log. Rejected. Operator debugging needs specifics ("which property? which error code?") that an aggregate scalar can't answer.

CloudWatch logs as the detail tier. Rejected for anything the dashboard needs to render. CloudWatch is fine for post-mortems but has no structured query, and we'd need a parallel story for JSON-backend local dev anyway.