All posts

Engineering velocity tracking without scoring: a practical system

A hands-on plan to instrument GitHub, issues, and deploys to track flow using bands, checks, and baselines—no subjective scoring, no vendor lock-in.

Instrument a minimal event stream: PRs, deploys, incidents

Track events, not opinions. Use one append-only record shape: type, entity_id, repo or service, actor, created_at, optional merged_at or resolved_at, and source_ids.

0

Ingest code and deploy events

From GitHub, subscribe to pull_request and deployment webhooks, or fetch them through REST. Normalize merged_at to mergedAt, keep head and base refs, and store the deployment environment as a first-class field (source: GitHub REST API v3 documentation (consulted 2026-07)).

0

Ingest issue lifecycle events

From Jira or Linear, capture issue creation and completion timestamps. Jira exposes issue fields through its Cloud REST API; Linear exposes createdAt and completedAt through GraphQL objects (source: Jira Cloud REST API documentation (consulted 2026-07); source: Linear GraphQL API docs (consulted 2026-07)).

0

Link work to code

Link issues to PRs through branch-name conventions such as ENG-421-cache-timeout, or by matching an issue key in the PR title. Store the matched issue ID in source_ids, not as free text (source: Jira Cloud REST API documentation (consulted 2026-07); source: Linear GraphQL API docs (consulted 2026-07)).

💡

Prefer webhooks when the source supports them. Use short-interval scheduled fetches only as a fallback, and make the fetcher idempotent so repeated API reads do not create duplicate events.

Keep raw events immutable. Derive lead_time, review_wait, and time_between_deploys from timestamps downstream, then band those fields later. Do not emit a composite velocity score.

Replace composite scores with bands and rolling baselines

Use DORA’s delivery-performance signals as the metric family, then define local baselines rather than a cross-team score (source: DORA Accelerate State of DevOps 2023 (Google Cloud)). A practical setup is a rolling baseline for lead time and review wait. Store the median and p90, then compare the latest weekly view against that local baseline.

Use bands instead of points. Green means the signal is within its baseline range. Yellow means drift that needs inspection. Red means sustained drift or a p90 breach. Keep these labels separate; never average them into one velocity score.

CheckPass condition
PR ageNo open PR is older than the baseline p90 age.
Deploy intervalNo deploy gap exceeds the baseline p90 interval.
Incident MTTRIncident MTTR stays within the baseline p90.
💡

Plot run charts for each signal with median and p90 overlays. Mark yellow and red points as annotations, not rankings.

Use the charts to inspect within-team change over time. A team recovering from red to yellow has useful signal; a cross-team leaderboard does not.

Queries you can run today: GitHub + Jira/Linear wiring

Run these queries into the same Events table. Keep raw timestamps and compute durations outside the source systems.

Use GitHub pull request data for merged PRs and keep this field shape:

pullRequests(states: MERGED) {
  nodes {
    createdAt
    mergedAt
    author { login }
    baseRefName
    headRefName
  }
}

Compute PR lead time as mergedAt - createdAt. Store baseRefName and headRefName so release branches and hotfix branches can be banded separately.

Use REST deployments to infer production deploys: GET /repos/{owner}/{repo}/deployments, then fetch each status with GET /repos/{owner}/{repo}/deployments/{id}/statuses (source: GitHub REST API v3 documentation (consulted 2026-07)).

Write one deploy event per production status. Keep the deployment environment, status timestamp, repository, and commit SHA.

Use JQL for recently completed work:

project=XYZ AND status CHANGED TO Done DURING (startOfDay(-30d), now())

For each issue, compute cycle time as resolutiondate - created. Keep issue key, project, issue type, assignee, created, and resolutiondate (source: Jira Cloud REST API documentation (consulted 2026-07)).

Use Linear GraphQL for completed issues:

issues(filter: { completedAt: { gt: ... } }) {
  nodes {
    createdAt
    completedAt
    branchName
  }
}

Compute issue cycle time as completedAt - createdAt. Link issues to PRs through branchName, or parse the Linear issue key from the PR title (source: Linear GraphQL API docs (consulted 2026-07)).

Use GitHub search syntax to flag open PRs waiting on review (source: GitHub REST API v3 documentation (consulted 2026-07)):

is:pr is:open -draft review:required updated:<YYYY-MM-DD

Map each result into Events as pr_opened with last_updated. This keeps stale review queues visible without scoring authors or teams.

Non-scoring velocity signals to track weekly

Weekly
Review signals as bands against the rolling baseline, never as a team score.

Flow signals

Track lead time for changes as the weekly median and p90 from issue_started_at to pr_merged_at; DORA treats lead time for changes as a software delivery performance signal (source: DORA Accelerate State of DevOps 2023 (Google Cloud)).

Count production deploys per week, then derive time-between-deploys from adjacent deploy_succeeded events. DORA treats deployment frequency as an outcome signal, not a subjective speed rating (source: DORA Accelerate State of DevOps 2023 (Google Cloud)).

Queue signals

Measure review flow with two fields: time to first review, and PRs awaiting review. Band both against the baseline p90, so the action is clearing the queue, not ranking reviewers.

Track WIP aging as open PR counts in three bands: ≤p50, p50–p90, and >p90 versus the current baseline. This exposes tail risk when the median PR still looks healthy.

Resilience signal

Link incidents to the last production deploy when timestamps overlap your incident window. Report median time to restore as a trend, because DORA includes recovery from failures in software delivery performance (source: DORA Accelerate State of DevOps 2023 (Google Cloud)).

When linked incidents are sparse, use pass/fail checks instead of percentages: “incident linked to last deploy” and “restore time worse than baseline” are enough for weekly review.

Make it visible: a one‑page velocity ledger and alerts

Keep the ledger as one weekly CSV row per team or service. Use these starter columns: week_start, merges, deploys, lead_time_p50, lead_time_p90, review_age_p90, wip_over_p90, incidents, mttr_minutes, and notes.

0

Write the ledger from code

Run a scheduled script that reads GitHub, Jira, Linear, and incident APIs, then writes velocity-ledger.csv into a repo. Commit generated rows so every calculation is reviewable, reproducible, and diffable.

0

Render without hiding formulas

Render the CSV into a static dashboard or connected sheet. Keep band logic in versioned code or visible formulas, not inside a vendor-only widget.

0

Open issues only for sustained red

When the same signal stays red across back-to-back weekly rows, open an action-item issue with the affected service, signal name, recent rows, and owner. Do not page on single-week noise.

Publish metric definitions and band rules next to the chart. Aggregate only at team or service level; never publish person-level rows, because individual visibility turns measurement into target-chasing.

💡

Ship a small adoption pack with velocity-ledger.csv, a webhook receiver, the ingestion script, and dashboard renderer. Put it under the MIT license so teams can fork it, edit bands, and keep their history portable.

Continue reading