Author and validate a metric spec for the data-path suite (tests/datapath/metrics/<class>/<name>.test.yaml fixture + test_<name>.py module, run against a compose test-stand instance). Use when asked to write/scaffold/validate a test for a metric, seed bronze data for a test, add a fixture for a dashboard metric, or check a spec. Covers schemas/, templates/, $ref+sibling composition, bronze records with duplicates, account bindings, POST /v1/metric-results, and the assertion helpers (row/equals/contains, one/some over a view, approx).
This skill writes and validates metric specs: a <name>.test.yaml fixture that
drives the full bronze → dbt silver → gold view → analytics path on a compose
test-stand instance, and the test_<name>.py module beside it that asserts the
result as a seeded persona.
This skill is self-contained for authoring. For the precise algorithm, read the
library itself: tests/lib/insight_datapath/ — fixture_loader, ref_resolver,
schema_validator, metric_expect and spec_runner live there, and the committed
specs are the worked examples (tests/datapath/metrics/ai/test_ai_cost.py with
ai_cost.test.yaml beside it is the reference pair). The stand client the suite
authenticates through is tests/lib/insight_stand/.
/metric-test create <name> --metric <key> --tables <t1,t2> — scaffold a new <class>/<name>.test.yaml (+ any missing schemas/ and templates/)./metric-test validate <path> — resolve refs, schema-validate records, lint the module; no stand needed.(Plain prose like "write a test for the emails-sent metric" triggers the same flow.)
tests/datapath/
metrics/
schemas/<db>.<table>.yaml # one JSON schema per bronze table (all real columns)
templates/<group>.yaml # reusable records (people, m365_email, …)
conftest.py # the module-scoped `spec` fixture + the completeness rule
<class>/<name>.test.yaml # what to seed: description + bronze
<class>/test_<name>.py # what it proves: SPEC = "<name>", one test per case
identity/ # the identity lane (a connector's bronze → persons-seed); not this skill
meta/ # pure unit tests of the library; no stand
conftest.py # the instance, the caller persona, dbt, the session floor
tests/lib/insight_datapath/ # the library every spec runs through<class> is one of ai, ci, collab, git, tasks, wiki — the family the
metric belongs to, and the CI shard it runs in. A spec is the pair; files under
schemas/ and templates/ are neither. A spec sits one directory below them, so a
$ref from a spec reads ../templates/people.yaml#/templates/alice.
Name the spec for the metric under test. collab/collab_emails_sent.test.yaml
is about collab.emails_sent. A module may read several keys off one seed when
they share the bronze under test (git/test_git_metrics.py reads the git keys off
one seeded set of commits and pull requests; ai/test_ai_cost.py pairs ai.cost with
ai.extra_usage_cost to prove a subset is served beside it, never summed), but
it asserts the keys it requests and never a fixed positive count of unrelated
entries. A module may hold many tests (one per date window, say) over the one seed.
$ref, and overridesA record is a field map. It may carry $ref: "<file>#/<json-pointer>" to inherit
from another record; sibling keys override the base (closest wins). Paths are
relative to the file the $ref is written in; a $ref resolves in the context of
its own file (a #/... ref inside templates/people.yaml stays local to it).
# templates/m365_email.yaml
templates:
m365_email: # base — carries EVERY schema column (unused = null)
_airbyte_raw_id: "00000000-0000-0000-0000-000000000000"
_airbyte_extracted_at: "2026-01-05T00:00:00"
_airbyte_meta: "{}"
_airbyte_generation_id: 0
tenant_id: "{{ tenant }}"
source_id: m365-test
sendCount: null
# … every other column …
alice_email:
$ref: "#/templates/m365_email"
userPrincipalName: alice@example.comPlaceholders. {{ tenant }}, {{ supervisor_email }} and {{ supervisor_name }}
are filled by the run from the instance — the manifest's tenant, and the seeded
lead the caller signs in as — after $ref resolution, so they work inside a shared
template. An unsupplied placeholder fails the load; a spec never hard-codes a tenant
or a supervisor.
description — metric + bronze→silver→gold formulaA folded > block stating WHAT the metric is and HOW it's computed, in plain
language — not dbt model / silver-column names. Keep it short. Shape:
description: >
Metric: <metric_key> — <bullet name> (…0012), #<issue>.
How it's computed (bronze → silver → gold):
• bronze: <the raw source report(s) that arrive>
• silver: <how they're deduped / normalized to per-person/day counts>
• gold: <the metric rule — the aggregation, exclusions, cross-source sums>
Team (median/range = the person's department):
<one-line member distribution> → median <m>, range [<lo>, <hi>].
Cases: <one-line list of what each case proves>.m365__collab_*, *_count) — the layer flow is the point, not the
artifacts. (To trace the real artifacts, read the staging dbt models + the gold
migration; see "Source of truth".)equals(...) numbers without reading
every case. For date-windowed metrics (no single Team), drop the Team line and let
the Cases line enumerate the window kinds (see collab/collab_emails_read.test.yaml).collab/collab_activity.test.yaml.bronze — what to seedKeyed by table name (the key IS the table + which schema validates it). Each row =
$ref to a record + the fields under test. After resolution the row is padded to
the full schema (missing columns → null) and validated (additionalProperties:false
catches typos). Two identical rows = a real Airbyte re-sync duplicate (must dedup).
bronze:
bronze_bamboohr.employees:
- $ref: ../templates/people.yaml#/templates/alice
bronze_m365.email_activity:
- $ref: ../templates/m365_email.yaml#/templates/alice_email
reportRefreshDate: "2026-01-05"
unique_key: m365-alice-20260105
sendCount: 40
- $ref: ../templates/m365_email.yaml#/templates/alice_email # duplicate → must NOT double
reportRefreshDate: "2026-01-05"
unique_key: m365-alice-20260105
sendCount: 40Every person a spec asserts on needs a bronze_bamboohr.employees row. People
are minted by persons-seed from the HR records the spec seeds — never stubbed. A
person with activity rows and no employment record is an address the product is
right to leave unresolved; a spec that seeds an employment record for someone the
product then fails to mint is an error at run time, not a null in the response.
A source whose models read the whole report row (raw_data) gets that payload
derived from the record's own fields; state raw_data: null only when a
payload-less row is what the case is about.
A top-level skip: <reason> makes the module skip instead of run — for a metric
blocked on an external fix.
Resolution by email cannot bind a second address to a person, nor an account whose profile email was never collected. A spec states either as an operator decision, and the run applies it through the operator API as the admin persona:
identity_aliases: # every listed email → the canonical email's person
alice@example.com: [alice.alpha@example.org]
identity_accounts: # a source account → a named person, whatever its email
- {source_type: github, source_id: git-test, account_id: "9001", person: alice@example.com}
- {source_type: github, source_id: git-test, account_id: "9002", person: excluded}source_id is the RAW connector source id (the warehouse hashes it); person: excluded is the reserved bot person. Worked examples:
ai/identity_alias_collapse.test.yaml, git/git_pr_account_attribution.test.yaml.
"""<What the spec proves — the description block's gold line, in prose.>"""
from __future__ import annotations
import pytest
from insight_datapath.metric_expect import approx, one, some
from insight_datapath.spec_runner import SpecRun
pytestmark = pytest.mark.fixture
SPEC = "collab_emails_sent"
ALICE = "alice@example.com"
def test_emails_sent_over_january(spec: SpecRun) -> None:
"""The month window takes both rows; the re-synced duplicate does not double the count."""
r = spec.call(
{
"url": "/v1/metric-results",
"method": "POST",
"body": {
"entity": {"type": "person", "ids": [ALICE]},
"period": {"from": "2026-01-01", "to": "2026-01-31"},
"metrics": [{"metric_key": "collab.emails_sent", "views": [{"view": "period"}, {"view": "peer"}]}],
},
}
)
assert r.status == 200
r.row("collab.emails_sent", "period", entity_id=ALICE).equals(value=40)
r.row("collab.emails_sent", "peer", entity_id=ALICE).equals(
target_value=40, p25=10, median=20, p75=40, min=10, max=40, n=3
)SPEC names the fixture; the module-scoped spec fixture (metrics/conftest.py) seeds it,
builds its models, mints its people and builds gold once per module.spec.call(request) posts the request as the seeded lead persona through the gateway and
returns a MetricResponse. Entity ids in requests and rows are the fixture's emails — the
runner translates person ids both ways.r.row(metric_key, view, **find) — exactly one row must match. Then equals(**fields) (exact;
None for a served null), contains(field={...}) (one element of a list field),
nonempty(*fields), check(field, predicate, describe). By the end of the test every selected
row must have its view's required fields asserted: period value; peer target_value p25 median p75 min max n; timeseries points; breakdown value; rollup value contributing_entity_count;
histogram bins.r.series(key), r.breakdown(key), r.histogram(key), r.rows(key, view).
Select inside them with one(entries, **selector) / some(entries, **selector); a dict
selector matches any element of a list field:
one(r.breakdown(k), entity_id=ALICE, dimensions={"key": "tool", "value": "cursor"}).value: null — coerce only the point you selected:
float(one(points, bucket_start="2026-01-05")["value"]) == approx(2.0). approx() is the
module's tolerance (rel 1e-9 / abs 1e-6) for a served number read outside equals.row and whole-view call is recorded in .artifacts/metric_assertions.json, and every
requested key in the same ledger, for the coverage gate.The request carries the entity, the period and the metric under test with its views; the backend computes the peer (org_unit / department) distribution itself. Assert what you request and never a fixed positive count of unrelated entries. (The team shown in the UI comes from a separate identity service and can disagree with the analytics team (org_unit) — irrelevant to these assertions.)
metric_date bounds are inclusive on both ends: metric_date ge '<lo>' and metric_date le '<hi>'
includes rows ON <lo> and ON <hi>. When a metric is date-windowed, prove the bounds
with a dedicated spec (see collab/collab_emails_read.test.yaml):
equals.ge == le — a one-day window still matches.ge '2025-12-31' le '2026-01-01'), both bounds inclusive.r.row(key, "period", entity_id=ALICE).equals(value=None).ge/le ranges.Resolve the metric key and its shape. The key you request and select rows by is
the registry key (ai.cost, collab.emails_sent —
src/backend/services/analytics/src/domain/metric_definitions/registry.yaml); copy
the literal VERBATIM. An unknown key is a 400, and a requested key the registry lacks
fails the coverage gate. Note which views the metric serves (period, peer, timeseries,
breakdown, histogram) and what its distribution is scoped to. For the collaboration
bullets the median/range is DEPARTMENT/org_unit-scoped for BOTH the Team bullet
(…0005) and the IC bullet (…0012) — median/range_* come from
quantileExact/min/max over the person's own org_unit_id (department) team (live query
m20260604_000002_collab_bullet_distribution.rs, GROUP BY metric_key, org_unit_id
joined ON c.org_unit_id = p.org_unit_id). The two bullets differ only in value
(Team = team average avg(p.v_period)/avg(c.team_*); IC = the requested member
any(c.team_*)), NOT in median scope. Ignore the seed catalog's range=company
description — it is stale (the older 20260518 company-wide query was replaced; that
shape now lives only in down()/old_*_query for rollback).
Ensure a schema file per table. If schemas/<db>.<table>.yaml is missing, write it
from the table's CREATE TABLE in src/ingestion/scripts/connectors-ddl/<connector>.sql
— the snapshot the suite applies to the instance, so the two cannot disagree. Do not
invent columns. Map CH types → JSON-schema: Nullable(String)→[string,"null"],
Decimal/Float/Int→[number,"null"] (UInt* non-null →integer), Bool→[boolean,"null"],
DateTime*→{string, format: date-time}, JSON→[object,"null"]. Set
additionalProperties: false and list every column (incl. _airbyte_*). An
existing file such as schemas/bronze_claude_team.claude_team_code_metrics.yaml is the
shape to copy.
Ensure base + variant templates. The base record must contain every schema
column (incl. _airbyte_* — transforms depend on them) with tenant_id: "{{ tenant }}";
variants $ref the base and override identity only. People come from
templates/people.yaml: its base employee record reports to {{ supervisor_email }},
which is how a spec's cast lands beneath the caller.
Seed the department, not a UUID. In the GOLD/served layer org_unit_id is the
BambooHR department STRING — insight.people.org_unit_id = argMax(department, …),
keyed person_id = lower(workEmail); it is a UUID only in silver/person.persons. So
for a team/department (org_unit) metric set department: "Engineering" on the bamboohr
employees base record (people sharing a department form one team), and if you scope
a team-view request use the string (org_unit_id eq 'Engineering'), never a UUID.
Identity match is load-bearing (silent NULL trap). Team/department attribution is a LEFT
JOIN: collab_bullet_rows joins insight.people ON lower(silver.email) = person_id,
where person_id = lower(workEmail). There is no email column on bronze — bamboohr
carries workEmail, M365 carries userPrincipalName, and silver email derives from
userPrincipalName. So a seeded person's userPrincipalName must equal their bamboohr
workEmail case-insensitively; any mismatch → org_unit_id resolves NULL, the
person silently drops out of the team/department (no error), and the median/range is computed
over the wrong roster. Set the SAME email on both workEmail and userPrincipalName.
Write the description (metric + bronze→silver→gold formula + Team/Cases —
see § description; a spec implementing a tracked feature scenario cites it
here — see § Feature-scenario traceability), then bronze with $ref+overrides; include a duplicate
row when the metric should dedup.
Write test_<name>.py: one test per window or behaviour, each calling
/v1/metric-results for the metric under test; assert the target metric's rows via
r.row(...).equals(...), and counts or inequalities over a whole view through one/some.
Pick numbers that distinguish behaviors — e.g. for a median test use values
where median ≠ mean ([40,20,10] → median 20, mean 23.33) so the test actually
pins the aggregation. Use an odd-size team (an odd number of members with data): ClickHouse quantileExact(0.5)
(which both collab bullets use) is NOT the average of the two middle values on an
EVEN team — it returns the UPPER middle element (index floor(n/2)): {100,200} →
200, not 150. An even team whose median you compute as the mean of the middles will
produce a wrong equals(...) (this bit the live specs twice), so prefer odd teams.
$ref resolves (file + pointer exist); no cycles.schemas/<table>.yaml
(additionalProperties:false).tenant, supervisor_email, supervisor_name).All three are what load checks, so run it with the run's own substitutions:
uv run --project tests --frozen --group datapath --no-group dev python -c "from pathlib import Path; from insight_datapath.fixture_loader import load; load(Path('tests/datapath/metrics/<class>/<name>.test.yaml'), substitutions={'tenant': 't', 'supervisor_email': 'lead@example.com', 'supervisor_name': 'Lead'})"uv run --project tests --frozen --group datapath --no-group dev python - <<'PY'
import yaml
s=set(yaml.safe_load(open("tests/datapath/metrics/schemas/<db>.<table>.yaml"))["schemas"]["<db>.<table>"]["properties"])
t=set(yaml.safe_load(open("tests/datapath/metrics/templates/<group>.yaml"))["templates"]["<base>"]); t.discard("$ref")
print("missing", sorted(s-t), "extra", sorted(t-s))
PYSPEC to the fixture's stem and every test takes spec: SpecRun.
Lint it with uv run --project tests --frozen ruff check tests/datapath/metrics/<class>/test_<name>.py
— ruff is in the default dependency group, so run this only while no data-path suite is
running (see Gotchas: shared venv).uv run --project tests --frozen --group datapath --no-group dev pytest tests/datapath/meta -q.The suite seeds and clears a warehouse, so it needs an instance of its own, raised with
minimal (identity and the realm seeded, the warehouse empty):
./dev-compose.sh test-stand minimal --instance=datapath # once
./dev-compose.sh test-stand test --tree=tests/datapath/metrics/<class> --instance=datapath -q
./dev-compose.sh test-stand test --tree=tests/datapath/metrics/<class> --instance=datapath -q -k <name>
./dev-compose.sh test-stand down --instance=datapath # removes volumes: full resetTrees go ONLY through --tree= (repeatable); everything after the leading options is
pytest's (-q, -k, --tb=short). <name> is the spec's stem (collab_emails_sent for
collab/collab_emails_sent.test.yaml + collab/test_collab_emails_sent.py). The verb picks
the suite's own dependency group for a tests/datapath tree and aims the run at the instance
through three environment variables; running pytest directly means setting them yourself:
INSIGHT_STAND_ENV_FILE=.env.compose.test-stand-datapath \
INSIGHT_STAND_MANIFEST=src/ingestion/tools/seed/manifest-datapath.json \
INSIGHT_STAND_REALM_EXPORT=deploy/compose/keycloak/realm-insight.generated-datapath.json \
uv run --project tests --frozen --group datapath --no-group dev pytest tests/datapath/metrics/<class>/test_<name>.py -qWarm re-runs are fine. Isolation is per spec: before a module seeds, the run empties
every relation the previous spec wrote — its bronze tables and every staging and
silver model the build materialized (the ledger is filled as dbt is invoked, so a build
that failed partway still leaves its targets registered). Staging matters: silver reads
staging through the union_by_tag macro, so an un-cleared staging row would contaminate the
silver rebuild. On top of that, a session-start floor empties every bronze_*, staging
and silver relation that holds rows, because those models are incremental behind a
watermark and rows from an earlier session would keep the fixture's own out. The identity
database is never cleared — it holds the persona the suite signs in as.
Per module the run goes: seed bronze → staging (with ancestors) → enrich steps → silver →
identity inputs (full refresh) → persons-seed → account bindings → identity map → gold →
the spec's requests as the seeded lead. The coverage inputs are written at session end:
.artifacts/metric_definitions.json (the builtin catalogue, read from the instance's
analytics MariaDB) and .artifacts/metric_assertions.json (the ledger). The gate over them:
python3 tests/lib/insight_datapath/metric_coverage.py --universe-file .artifacts/metric_definitions.json --ledger .artifacts/metric_assertions.jsonEvery builtin metric owes period and timeseries; peer when it has a peer cohort,
breakdown when it has dimensions, histogram when its computation is median. A builtin
metric with no spec fails the required check. In CI (.github/workflows/e2e-bronze-to-api.yml)
the job e2e-datapath runs one leg per shard (ai, git, tasks, rest, identity), each
raising a minimal stand on its runner; metric-coverage-gate unions the legs' ledgers, and
the umbrella Run E2E suite is the required check. It runs on merge_group and
workflow_dispatch.
To create a new test, use /metric-test create or hand-author <class>/<name>.test.yaml
and <class>/test_<name>.py as above.
At session start the suite gives the instance the schema a deployment has: it applies the
src/ingestion/scripts/connectors-ddl/*.sql snapshot and scripts/migrations/*.sql. The
seeder then requires every column a fixture declares to exist in the real table with a
compatible type, and fails on drift. A table the snapshot lacks is created from the schema
YAML as a bare MergeTree — enough to seed, but not the shape a connector leaves, so a
spec green on that table proves nothing about a deployment. The snapshot is generated, not
hand-edited; a table added by hand to connectors-ddl/*.sql does not survive the next
regeneration. To seed a connector whose bronze tables aren't in the snapshot yet:
Make sure the connector is listed in bootstrap-db/connectors-config.yaml,
then regenerate and commit the snapshot (see
src/ingestion/scripts/bootstrap-db/README.md):
cd src/ingestion/scripts/bootstrap-db
set -a; source pins.env; [ -f .env ] && source .env; set +a
./bootstrap-db.sh connectors-config.yaml
./dump-ddl.sh # writes ../connectors-ddl/*.sqlCommit the resulting connectors-ddl/*.sql diff so the new
bronze_<snake>.<stream> tables ship in the snapshot the suite applies.
Add a matching schemas/bronze_<snake>.<stream>.yaml (every column;
additionalProperties: false) and a base template covering all of them.
up is not minimal. A stand raised with test-stand up is seeded through silver;
the suite refuses it at the door (SeededWarehouseError) because clearing those
relations would delete the roster its caller signs in as. Raise an instance of its own
with minimal.test-stand test tests/datapath/metrics/ai hands the
path to pytest beside the default tree, so tests/stand runs too and the datapath
dependency group is never selected. Trees go through --tree=.tests/.venv holds one dependency set at a time, and datapath
(dbt) conflicts with dev (ruff, mypy). Any uv run --project tests with a different
group set — a plain ruff check included — re-syncs the venv under a running suite.
Lint before or after, never during..test.yaml mid-run corrupts that run. The spec fixture loads the file
when its module starts, so the edit lands in whichever module is next; the ledger and
results then describe two versions of the tree. Let the run finish, then re-run.INSIGHT_STAND_ENV_FILE a direct pytest run resolves
.env.compose.test-stand — the default instance — and seeds whatever stand that is.
Every instance uses the same users and database names and differs only by published
port, so the failure reads as a data mismatch, not a wrong address. The env file names
the instance (.env.compose.test-stand-datapath), and so do the manifest and realm
export it pairs with.minimal instance no suite has touched answers 400 for every key.test; only down
removes it. Credentials are in the instance env file (the MariaDB database is analytics):
set -a; source .env.compose.test-stand-datapath; set +a, then
docker exec insight-datapath-clickhouse clickhouse-client --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" -q "SELECT … FROM silver.class_<X>"
and
docker exec insight-datapath-mariadb mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" analytics -e "SELECT metric_key FROM metric_definitions WHERE origin='builtin'".len(...) of a whole view — bump those in the
same change. A spec that asserts only the keys it requests is immune to this coupling;
only a spec that pins a positive count needs the lockstep bump.When a spec implements a scenario from a feature's Testing section, cite it
inside the spec's description: the FEATURE path, its cpt-…-feature-… ID and
the stable scenario number. The full traceability contract is section 5 of the
quality-vector-tests skill. This suite has no vector marker mechanism, so the
scenario's vector lives in the FEATURE only.
c31d302
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.