Files

459 lines
22 KiB
Markdown

# Chart bounded context
Time-series charting for the Fleet dashboard. Given a metric like "how many hosts
were online" or "how many hosts were affected by a critical CVE," this package
records a per-hour history and serves it back as bucketed data points the frontend
renders as a line, checkerboard, etc.
This is a **self-contained bounded context** (like `server/activity/` and
`server/mdm/`), not the traditional `server/fleet``server/service`
`server/datastore` layering. It owns its own types, datastore, service, HTTP
transport, and bootstrap, and it must not import `server/fleet` or
`server/contexts/viewer` directly — an architecture test enforces this (see
[Dependency rules](#dependency-rules)).
## Table of contents
- [Mental model](#mental-model)
- [Package layout](#package-layout)
- [Dependency rules](#dependency-rules)
- [Data model: SCD type-2 + bitmaps](#data-model-scd-type-2--bitmaps)
- [Sample strategies](#sample-strategies)
- [The write path (collection)](#the-write-path-collection)
- [The read path (GetChartData)](#the-read-path-getchartdata)
- [Scoping, config gating, and scrubbing](#scoping-config-gating-and-scrubbing)
- [How to add a new dataset](#how-to-add-a-new-dataset)
- [How charts reach the frontend](#how-charts-reach-the-frontend)
- [Testing](#testing)
- [File reference](#file-reference)
## Mental model
A **dataset** answers one question over time ("uptime," "cve"). On a schedule, a
collection cron asks each dataset to **collect** the current state: it produces a
set of host IDs (optionally grouped by an *entity*, e.g. one host-set per CVE) and
hands them to the datastore as a [roaring bitmap](https://roaringbitmap.org/). The
datastore folds that observation into a slowly-changing-dimension (SCD) history in
the `host_scd_data` table.
On read, a chart request resolves which hosts the caller may see (a "filter mask"
bitmap), walks the SCD history bucket by bucket, ANDs each bucket's host-set
against the mask, and returns the population count per bucket. Everything is set
math on bitmaps; a "value" on the chart is always a distinct-host count.
```
collection cron (cmd/fleet/cron.go)
└─ Service.CollectDatasets(scope)
└─ Dataset.Collect() ──reads hosts──▶ DatasetStore (FindOnlineHostIDs, AffectedHostIDsByCVE, …)
└─ store.RecordBucketData(strategy, map[entity]*bitmap) ──writes──▶ host_scd_data
HTTP GET /api/_version_/fleet/charts/{metric}
└─ getChartDataEndpoint ─▶ Service.GetChartData()
├─ ViewerProvider.ViewerScope() → team scope for authz + data
├─ authz.Authorize() → fail closed
├─ GetHostIDsForFilter() → mask → cached per filter (hostFilterCache)
└─ GetSCDData(...) walks buckets, AND mask, popcount ─▶ []DataPoint
```
## Package layout
| Path | Role | May depend on |
|------|------|---------------|
| `server/chart` (root) | Bitmap helpers (`blob.go`), `Dataset` implementations (`datasets.go`), shared constants | `api` only |
| `server/chart/api` | **Public surface.** `Service`, `Dataset`, `DatasetStore`, `ViewerProvider` interfaces; request/response types | nothing in Fleet |
| `server/chart/api/http` | HTTP request/response DTOs (wire tags, e.g. `fleet_id`) | `api` only |
| `server/chart/internal/types` | Internal `Datastore` interface, `HostFilter` | `api` only |
| `server/chart/internal/mysql` | MySQL `Datastore` implementation, SQL, SCD read/write | chart, types, platform |
| `server/chart/internal/service` | `Service` implementation, authz, host-filter cache, bucket math | chart, platform |
| `server/chart/bootstrap` | `New(...)` wires datastore + service + routes; entry point for `serve.go` | mysql, service, api, platform |
| `server/chart/internal/testutils` | Test helpers | — |
External code only ever touches `bootstrap.New` and the `api` package. The
anti-corruption layer that bridges to legacy Fleet types lives **outside** this
tree in `server/acl/chartacl` (the only place that imports both chart `api` and
`server/contexts/viewer`).
## Dependency rules
`arch_test.go` runs `archtest` assertions on every package. The rules in plain
English:
- `api` has **zero** Fleet dependencies (it's the contract).
- `api/http`, `internal/types`, and the root `chart` package depend on **`api`
only**.
- `internal/mysql` and `internal/service` may additionally use `server/platform/...`
and `server/contexts/...`, plus other chart packages.
- **No chart package may import `server/fleet` or `server/contexts/viewer`.**
If you need something from legacy Fleet (the current user, a fleet config value),
do not import it — add a narrow interface to `api` (see `ViewerProvider`) and
implement it in `server/acl/chartacl`. Then wire it through `bootstrap.New`.
## Data model: SCD type-2 + bitmaps
Everything lives in one table, `host_scd_data`:
```sql
CREATE TABLE host_scd_data (
id bigint unsigned AUTO_INCREMENT,
dataset varchar(50) NOT NULL, -- "uptime", "cve", …
entity_id varchar(100) NOT NULL DEFAULT '', -- "" for single-dimension; CVE id for cve
host_bitmap mediumblob NOT NULL, -- serialized host-id set
valid_from datetime NOT NULL,
valid_to datetime NOT NULL DEFAULT '9999-12-31 00:00:00', -- sentinel = "still open"
encoding_type tinyint NOT NULL DEFAULT 0, -- 0 = dense, 1 = roaring
PRIMARY KEY (id),
UNIQUE KEY uniq_entity_bucket (dataset, entity_id, valid_from),
KEY idx_dataset_range (dataset, valid_from, valid_to),
KEY idx_valid_to_dataset (valid_to, dataset, entity_id)
);
```
A **row is a host-set that was valid over `[valid_from, valid_to)`**. The
`9999-12-31` sentinel means "currently open." This is a textbook
[slowly-changing-dimension type-2](https://en.wikipedia.org/wiki/Slowly_changing_dimension#Type_2:_add_new_row)
table: state changes append a new row and close the old one rather than mutating
in place, so history is preserved.
`entity_id` is the sub-dimension. Single-dimension datasets (uptime) use the empty
string. Multi-dimension datasets (cve) write one row per entity (per CVE) and the
read path ORs across entities to get a distinct-host union.
### Bitmap encoding (`blob.go`)
`host_bitmap` stores a set of host IDs. Two on-disk formats, discriminated by
`encoding_type`:
- **`EncodingDense` (0)** — legacy raw bit-array, `bit n set ⇔ host n in set`. Only
read, never written anymore. Old rows decode transparently and age out via
retention.
- **`EncodingRoaring` (1)** — portable [RoaringBitmap](https://github.com/RoaringBitmap/roaring)
serialization. **All new writes use this.**
Two in-memory representations, and the distinction matters:
- **`Blob{Bytes, Encoding}` — storage form.** Only at the DB I/O boundary. Built by
`HostIDsToBlob` / `BitmapToBlob`; consumed by INSERT/UPDATE.
- **`*roaring.Bitmap` — op form.** Everything else: all set math
(`BlobAND/OR/ANDNOT`, `BlobPopcount`) and change detection. Built by `NewBitmap`
or `DecodeBitmap`.
Encoding-awareness is confined to `DecodeBitmap` (storage→op) and `BitmapToBlob`
(op→storage). The rest of the code works in op form and never thinks about bytes.
Change detection compares op-form bitmaps with `roaring.Equals` — never bytes,
because a dense row and a roaring row can represent the same set with different
bytes.
## Sample strategies
A dataset declares a `SampleStrategy` that governs how observations combine within
a write-bucket and how rows collapse across buckets. **All collectors write at 1h
granularity** regardless of the *display* resolution requested at read time.
### `SampleStrategyAccumulate`
*"hosts observed doing the thing at any point during the bucket."* Used by
**uptime**.
- **Write:** every row is born *closed* (`valid_to = bucketStart + bucketSize` at
insert). Repeated samples within the same bucket OR-merge into the existing row
(ODKU on `uniq_entity_bucket`). A sample in a new bucket starts a fresh row. No
explicit close step, no cross-bucket collapse.
- **Read:** a bucket's value = OR of every row whose interval overlaps the bucket.
### `SampleStrategySnapshot`
*"state as of the end of the bucket."* Used by **cve**.
- **Write:** rows align to 1h boundaries. The latest sample in a write-bucket
overwrites via ODKU (last-write-wins). Across buckets, **unchanged** state keeps
the row open (`valid_to` stays sentinel); a **changed** sample closes the prior
row at the new boundary and opens a new one. An entity that disappears from the
input has its open row closed.
- **Read:** for each entity, pick the row active at `bucketEnd`, then OR across
entities.
> **Snapshot collectors must call `RecordBucketData` even with an empty map.** An
> empty input is meaningful — it means "no entities are in the tracked state right
> now," which must close any still-open rows. Accumulate short-circuits on empty
> (nothing to merge, no state to reconcile). See the comments in `datasets.go`
> (`CVEDataset.Collect`) and `data.go` (`RecordBucketData`).
The read-side aggregation for both strategies lives in `aggregateBucket`
(`internal/mysql/data.go`).
## The write path (collection)
1. A cron (`newChartDataCollectionSchedule` in `cmd/fleet/cron.go`, default 1h)
calls `Service.CollectDatasets(ctx, now, scope)`.
2. `scope` is a `CollectScopeFn` built fresh each tick from AppConfig + team
configs (`buildChartScopeResolver`). For each dataset it returns `(skip,
disabledFleetIDs)` — whether the dataset is globally off, and which fleets opted
out.
3. For each registered dataset, `Collect(ctx, store, now, disabledFleetIDs)` runs.
A failure is logged and the loop continues — one dataset can't block the others.
4. `Collect` reads host state through the narrow `DatasetStore` interface
(`FindOnlineHostIDs`, `AffectedHostIDsByCVE`, `TrackedCriticalCVEs`), builds
`map[entityID]*roaring.Bitmap`, and calls `store.RecordBucketData(...)` with its
strategy.
5. `RecordBucketData` dispatches to `recordAccumulate` or `recordSnapshot`, which
serialize via `BitmapToBlob` and upsert.
Retention: a separate cleanup cron calls `CleanupData(days)`
`CleanupSCDData`, which deletes *closed* rows older than the cutoff in batches.
Open rows (sentinel `valid_to`) are never deleted.
## The read path (`GetChartData`)
`internal/service/service.go::GetChartData` is the heart of the read side:
1. **Resolve scope.** `ViewerProvider.ViewerScope(ctx)` returns `(isGlobal,
teamIDs)`. Fails closed if there's no viewer (requests sit behind authenticated
middleware; absence means misconfiguration).
2. **Authorize.** Explicit `team_id` → `Host{TeamID}` + `ActionRead` (Rego enforces
team-role match). No `team_id` → `Host{}` + `ActionList` (global users pass;
team users are scoped by data below).
3. **Validate** metric exists, `1 ≤ days ≤ 31`, resolution is 0 or a positive
divisor of 24.
4. **Build the filter mask.** `effectiveTeamIDs` collapses the team scope, then
`GetHostIDsForFilter` resolves team/label/platform/include/exclude into a host-id
list → `NewBitmap`. This is memoized per canonicalized filter by `hostFilterCache`
(60s TTL, singleflight-collapsed). The mask encodes "currently visible hosts,"
which incidentally drops hosts deleted since the SCD rows were written.
5. **Walk buckets.** `GetSCDData` selects every row overlapping the range,
decodes each once, then for each bucket: `aggregateBucket` (per strategy) → AND
the mask → `popcount` → one `DataPoint`. Zero buckets are emitted as `0`, not
omitted.
6. **Respond** with metric, visualization, `TotalHosts` (popcount of the mask),
resolution label, applied filters, and the data points.
Bucket boundaries are aligned to the client's local time via `tz_offset`
(`computeBucketRange`), so an "hourly" or "daily" chart lines up with the user's
day.
### Key invariant: nil vs empty
Several layers depend on the difference between a `nil` slice and an empty non-nil
slice. **Do not "normalize" one to the other.**
- `HostFilter.TeamIDs`: `nil` = no team filter (all hosts); `[]uint{}` = team user
with zero teams → SQL emits `1=0` (see nothing); `[]uint{0}` = no-team hosts
(`team_id IS NULL`).
- `GetSCDData` `entityIDs`: `nil` = match every entity; `[]uint{}` non-nil = match
nothing (zero-valued buckets), avoiding an `IN ()` syntax error.
- `TrackedCriticalCVEs` returns a non-nil empty slice when nothing matches so the
caller can tell "filter resolved to empty" from "no filter."
## Scoping, config gating, and scrubbing
Whether a dataset collects at all is gated by `HistoricalDataSettings` in AppConfig
(global) and per-team config (`Features.HistoricalData`). The cron's scope resolver
translates these into `skip` / `disabledFleetIDs`.
When an admin **disables** a dataset, already-collected data must be removed. That
flip is handled by `fleet.OnHistoricalDataChanged` (in `server/fleet/historical_data.go`),
which enqueues a worker job *after* the config commit:
- **Global disable** → `chart_scrub_dataset_global` job →
`Service.ScrubDatasetGlobal` → `DeleteAllForDataset` (batched delete of all rows
for the dataset).
- **Per-fleet disable** → `chart_scrub_dataset_fleet` job →
`Service.ScrubDatasetFleet` → resolve the fleets' host IDs into a mask, then
`ApplyScrubMaskToDataset` walks every row and `BlobANDNOT`s the mask out. Both are
idempotent.
The worker jobs live in `server/worker/chart_scrub.go`. Note the dataset-name
strings (`"uptime"`, `"cve"`) are mirrored in three places — the `Dataset.Name()`
return, the scrub job payloads, and `OnHistoricalDataChanged`'s change list. They
must stay in sync, since `host_scd_data.dataset` is the join key for all of it.
## How to add a new dataset
Worked example: a "battery health" dataset showing how many hosts had a healthy
battery.
1. **Implement `api.Dataset`** in `server/chart/datasets.go`:
```go
type BatteryDataset struct{}
func (b *BatteryDataset) Name() string { return "battery" }
func (b *BatteryDataset) DefaultResolutionHours() int { return 24 }
func (b *BatteryDataset) SampleStrategy() api.SampleStrategy { return api.SampleStrategySnapshot }
func (b *BatteryDataset) DefaultVisualization() string { return "line" }
func (b *BatteryDataset) Collect(ctx context.Context, store api.DatasetStore, now time.Time, disabledFleetIDs []uint) error {
hostIDs, err := store.FindHealthyBatteryHostIDs(ctx, disabledFleetIDs) // new store method
if err != nil {
return err
}
bucketStart := now.UTC().Truncate(time.Hour)
// Snapshot: always record, even when empty, so open rows close.
return store.RecordBucketData(ctx, b.Name(), bucketStart, time.Hour, b.SampleStrategy(),
map[string]*roaring.Bitmap{"": chart.NewBitmap(hostIDs)})
}
```
Pick the strategy deliberately: *accumulate* for "seen doing X at any point"
(uptime-like), *snapshot* for "in state X as of now" (inventory-like). Use a
non-empty `entity_id` only if you need a sub-dimension you'll OR across (like
per-CVE).
2. **Add the collection query** to the store. Define the method on **both**
`api.DatasetStore` (`api/chart.go`) and `internal/types.Datastore`
(`internal/types/chart.go`) — `Collect` only sees `DatasetStore`, but the
concrete MySQL type must satisfy `types.Datastore` — then implement it in
`internal/mysql/charts.go`. Keep these read-only and bounded; stream large joins
(see `streamCVEHostPairs`).
3. **Register the dataset** in `cmd/fleet/serve.go::createChartBoundedContext`:
```go
chartSvc.RegisterDataset(&chart.BatteryDataset{})
```
An unregistered metric returns a 400 from `GetChartData`.
4. **Wire config gating** (if the dataset is opt-in/out): add a sub-key to
`HistoricalDataSettings`, teach `Enabled(name)` about it, and add a row to the
change list in `fleet.OnHistoricalDataChanged` so disabling it triggers a scrub.
The scrub workers are dataset-agnostic and need no changes.
5. **Test** the collector and the read aggregation. MySQL-backed tests live in
`internal/mysql/*_test.go`; service-level behavior in `internal/service/*_test.go`.
No migration is needed — new datasets reuse `host_scd_data`, keyed by the new
`dataset` string. A migration is only needed if you change the table shape (e.g. a
new column or index).
## How charts reach the frontend
- Route: `GET /api/_version_/fleet/charts/{metric}` (registered in
`internal/service/handler.go`).
- Query params: `days`, `resolution` (hours), `tz_offset` (minutes, from JS
`Date.getTimezoneOffset()`), `fleet_id` (note the teams→fleets rename — the wire
name is `fleet_id`, the Go field stays `TeamID`), `label_ids`, `platforms`,
`include_host_ids`, `exclude_host_ids` (comma lists).
- The response carries `visualization` (from `DefaultVisualization()`), so the
frontend learns how to render each metric from the backend rather than hardcoding
it.
## Testing
```bash
# Fast, no external deps (bitmap helpers, arch test):
go test ./server/chart/...
# MySQL-backed datastore + service tests:
MYSQL_TEST=1 go test ./server/chart/...
# A single test:
MYSQL_TEST=1 go test -run TestName ./server/chart/internal/mysql/...
```
`arch_test.go` is part of `go test ./server/chart/...` and will fail the build if a
package grows a forbidden dependency (e.g. an accidental `server/fleet` import).
When you add a store method, run `go test ./server/service/` too — uninitialized
mocks elsewhere can crash if an interface method is missing.
## File reference
| File | What's in it |
|------|--------------|
| `blob.go` | Bitmap encode/decode, storage-form vs op-form, set ops |
| `datasets.go` | `UptimeDataset`, `CVEDataset` — the `Dataset` implementations |
| `api/service.go` | `Service`, `ViewerProvider`, `CollectScopeFn` |
| `api/chart.go` | `Dataset`, `DatasetStore`, `SampleStrategy`, request/response types |
| `api/http/types.go` | HTTP wire DTOs |
| `internal/types/chart.go` | `Datastore` interface, `HostFilter` (nil/empty semantics) |
| `internal/service/service.go` | `GetChartData`, scope/authz, scrub, bucket range math |
| `internal/service/host_cache.go` | Per-filter mask cache (TTL + singleflight) |
| `internal/service/handler.go` | Route registration + endpoint decode |
| `internal/mysql/data.go` | SCD read/write: `RecordBucketData`, `GetSCDData`, cleanup, scrub |
| `internal/mysql/charts.go` | Host-filter SQL, online-host query, CVE collection + tracked-CVE filter |
| `bootstrap/bootstrap.go` | `New(...)` — wires the context together |
| `arch_test.go` | Enforces the dependency rules above |
Related code outside this tree:
- `server/acl/chartacl/` — anti-corruption layer (viewer adapter).
- `cmd/fleet/serve.go` — `createChartBoundedContext`, dataset registration.
- `cmd/fleet/cron.go` — collection schedule, scope resolver, cleanup.
- `server/worker/chart_scrub.go` — scrub worker jobs.
- `server/fleet/historical_data.go` — config-flip → scrub/activity orchestration.
- `server/datastore/mysql/migrations/tables/20260423161823_AddHostSCDData.go` — the table migration.
- `tools/charts-backfill/`, `tools/charts-collect/` — dev tools (see below).
## Dev tools: populating chart data
In dev you usually don't have hours of real collection history to chart. Two
standalone tools under `tools/` write directly to `host_scd_data` so you have
something to render. Each has its own README with the full flag reference; this is
the orientation.
### `tools/charts-backfill` — synthetic history
Generates **fake but realistically-shaped** history for a dataset. This is the one
you want for frontend work or eyeballing a chart — point it at your local DB and it
fabricates days of data in seconds. Safe to re-run (ODKU merge), always writes
roaring encoding.
Crucially, it backfills in the mode that **matches the dataset's
`SampleStrategy`** so the data looks like what production would eventually produce:
- **Accumulate datasets** (uptime) → 24 independent hourly rows per day,
each a fresh random sample, each bounded to its single hour.
- **Snapshot datasets** (cve) → per-entity state-segment rows: most entities get one
long open row, a small fraction "flip" on day boundaries to produce closed
segments. CVE cardinality follows a long-tail distribution (most CVEs touch a
handful of hosts, a few are browser/kernel-wide) so unioning hundreds of entities
doesn't saturate at fleet size. The final segment per entity stays open
(`valid_to` = sentinel) so the live collector compares against it on its next
tick rather than stacking a row on top.
```bash
# 30 days of uptime for all hosts in the local DB:
go run ./tools/charts-backfill --dataset uptime --days 30
# CVE data using the same CVE set production would track:
go run ./tools/charts-backfill --dataset cve --days 30 --use-tracked-cves
# Scope to specific hosts / entities / a custom DSN:
go run ./tools/charts-backfill --dataset uptime --days 7 --host-ids 1,2,3
go run ./tools/charts-backfill --dataset cve --days 30 --entity-ids CVE-2024-1,CVE-2024-2
go run ./tools/charts-backfill --mysql-dsn "fleet:fleet@tcp(localhost:3306)/fleet"
```
Key flags: `--dataset` (default `uptime`), `--days` (30), `--start-date`
(`YYYY-MM-DD`, defaults to `now - days`), `--host-ids` (default: all hosts),
`--entity-ids`, `--use-tracked-cves` (cve only — auto-discovers entity IDs via the
production tracked-CVE query; needs vuln data populated), `--mysql-dsn`. Full table
in `tools/charts-backfill/README.md`.
If you add a snapshot-strategy dataset, add its name to `snapshotDatasets` in
`tools/charts-backfill/main.go` (and a density range in `densityRange`) so the tool
generates it in the right shape; otherwise it defaults to the accumulate/hourly
model.
### `tools/charts-collect` — real data from a live Fleet
The other tool pulls **real** state from a running Fleet instance over the REST API
(currently uptime + CVE) and writes it into a local DB. It's the out-of-process
stand-in for the in-server collection cron — designed to run hourly against e.g.
dogfood. Use this when you want a chart backed by real fleet data rather than
synthetic noise.
```bash
go run ./tools/charts-collect --fleet-url https://dogfood.fleetdm.com --fleet-token <token>
```
Targets are also configurable via `FLEET_URL` / `FLEET_TOKEN` / `MYSQL_DSN`, and
the DSN falls back to the standard `FLEET_MYSQL_*` env vars. See
`tools/charts-collect/README.md`.
> Both tools duplicate a few storage constants (the `9999-12-31` open sentinel,
> upsert batch size, roaring encoding) because they write `host_scd_data`
> out-of-process and can't import the internal mysql package. If you change the
> storage format or those constants in `internal/mysql/data.go`, update the tools to
> match.