DevOps · lesson 6 of 8
Intermediate
7- 2 Choosing a CI/CD Toolchain: The Same Weighted Framework as Choosing a Cloud
- 3 CI/CD Pipeline Patterns: Sequential, Parallel, and Staged
- 4 Deployment Strategies Compared: Rolling, Blue-Green, and Canary
- 5 GitOps in Practice: Git as the Source of Truth
- 6 Computing DORA Metrics From Your Own Git History
- 7 Terraform vs. Ansible: A Weighted Decision Framework
- 8 Observability Synthesis: Logs, Metrics, Traces, and Alerts as One Signal
Also part of DevOps
12- + PR Workflow: Rebasing, Squash Merge, and Merge Commits from Git & GitHub
- + GitHub Actions: Workflows, Jobs, and a Real Failing Step from GitHub Actions
- + Jenkins CI: Pipeline Basics from Jenkins
- + Bash DevOps Scripts from Bash
- + Introduction to Docker from Docker
- + Deployments and Rollouts from Kubernetes
- + Terraform Fundamentals (IaC) from Terraform
- + Terraform CI/CD with GitHub Actions (plan/apply) from Terraform
- + Ansible Idempotency, Verified: Run It Twice and Watch changed=0 from Ansible
- + Prometheus Scrapes You — It's Pull, Not Push from Monitoring & Observability
- + A Firing Alert Doesn't Mean Anyone Was Notified from Monitoring & Observability
- + A CI Security Gate: Gitleaks, Bandit and pip-audit in One Workflow from DevSecOps (AppSec in the pipeline)
Computing DORA Metrics From Your Own Git History
Compute all four DORA metrics — deployment frequency, lead time for changes, change failure rate, and MTTR — from real git log and tag data instead of a dashboard.
By SkillByExample · Published
Four numbers, one git log, no dashboard.
A repo with a deploy history
Four commits and four annotated deploy tags, one of them marked failed, built with explicit timestamps so the metrics below come out to round numbers:
# .scratch/build_dora_repo.py (excerpt)
commit("feature A: add checkout retry", "2026-01-01T09:00:00")
tag("v1", "deploy v1", "2026-01-02T09:00:00", failed=False)
commit("feature B: new pricing table", "2026-01-03T09:00:00")
tag("v2", "deploy v2", "2026-01-04T09:00:00", failed=True)
commit("hotfix: revert pricing table bug", "2026-01-04T15:00:00")
tag("v3", "deploy v3", "2026-01-04T18:00:00", failed=False)
commit("feature C: add search filters", "2026-01-06T09:00:00")
tag("v4", "deploy v4", "2026-01-07T09:00:00", failed=False)
Each deploy is a real annotated tag with a [status:success] or [status:FAILED] marker in
its message — a convention, not a git feature, but enough for a script to parse:
$ git for-each-ref --format='%(refname:short) %(creatordate:iso-strict) %(contents:subject)' refs/tags
v1 2026-01-02T09:00:00+05:30 deploy v1 [status:success]
v2 2026-01-04T09:00:00+05:30 deploy v2 [status:FAILED]
v3 2026-01-04T18:00:00+05:30 deploy v3 [status:success]
v4 2026-01-07T09:00:00+05:30 deploy v4 [status:success]
Deployment frequency
Deploys per week, from the span between the first and last deploy tag:
# .scratch/compute_dora.py (excerpt)
span_days = (tags[-1][1] - tags[0][1]).total_seconds() / 86400
deploys_per_week = len(tags) / span_days * 7
print(f"Deployment frequency: {len(tags)} deploys over {span_days:.1f} days = {deploys_per_week:.2f}/week")
Deployment frequency: 4 deploys over 5.0 days = 5.60/week
Lead time for changes
For each deploy, find the earliest commit made since the previous deploy, and measure how long it waited before shipping:
lead_times = []
prev_deploy_time = commits[0][1]
for name, deploy_time, _ in tags:
new_commits = [c for c in commits if prev_deploy_time <= c[1] < deploy_time or c[1] == prev_deploy_time]
first_new = min((c[1] for c in new_commits), default=deploy_time)
lead_hours = (deploy_time - first_new).total_seconds() / 3600
lead_times.append(lead_hours)
prev_deploy_time = deploy_time
Lead time for changes: ['24.0h', '24.0h', '3.0h', '24.0h'] -> avg 18.75h
The 3-hour outlier is the hotfix — committed at 15:00 and deployed at 18:00 the same day, dragging the average down from what three ordinary 24-hour cycles alone would show. Lead time is sensitive to exactly this kind of urgent, fast-tracked change, which is part of why it’s tracked as an average rather than read from any single deploy.
Change failure rate
A direct read of the [status:FAILED] markers:
failed = [t for t in tags if t[2]]
print(f"Change failure rate: {len(failed)}/{len(tags)} = {len(failed) / len(tags):.1%}")
Change failure rate: 1/4 = 25.0%
MTTR
For each failed deploy, the gap until the next deploy — the point at which the failure was actually resolved in production:
restore_times = []
for i, (name, deploy_time, is_failed) in enumerate(tags):
if is_failed and i + 1 < len(tags):
restore_hours = (tags[i + 1][1] - deploy_time).total_seconds() / 3600
restore_times.append(restore_hours)
MTTR: ['9.0h'] -> avg 9.00h
v2 deployed at 09:00 and broke pricing; v3 — the hotfix deploy — landed at 18:00 the same day. Nine hours from a failed deploy to its fix is the entire MTTR calculation, read straight off two tag timestamps.
What the numbers say together
| Metric | Value | Reading it |
|---|---|---|
| Deployment frequency | 5.60/week | Shipping roughly every 1.25 days |
| Lead time for changes | 18.75h avg | Most changes ship within a day of being committed |
| Change failure rate | 25% | 1 in 4 deploys broke something in this window |
| MTTR | 9.00h | The one failure took under half a day to resolve |
None of these numbers means anything alone — a 25% failure rate next to a 9-hour MTTR reads very differently than the same 25% next to a 9-day MTTR. DORA’s own research groups performance by combinations of all four, not any single metric in isolation.
Practice
1. Add a fifth deploy, v5, three days after v4, also successful. Recompute deployment frequency.
Add commit("feature D: ...", "2026-01-09T09:00:00") and tag("v5", "deploy v5", "2026-01-10T09:00:00", failed=False) to build_dora_repo.py, rebuild the repo, and rerun compute_dora.py. The span grows to 8 days across 5 deploys, giving 5 / 8 * 7 = 4.375/week — adding a slower deploy at the end pulls the weekly rate down even though total deploy count went up, since frequency is about pace, not raw count.
2. Mark v4 as failed too, with no fix deploy after it. What does the MTTR calculation do?
Change tag("v4", ..., failed=True) and rerun. The MTTR loop’s if is_failed and i + 1 < len(tags) guard means v4 (the last tag, with no tag after it) contributes nothing to restore_times — an unresolved failure with no subsequent deploy is invisible to this MTTR calculation, which is a real limitation: it can only measure incidents that have already been fixed by a later deploy, not ones still ongoing.
3. Change v2's failure marker to `[status:FAILED-partial]` instead of `[status:FAILED]`. What happens to the change failure rate?
Since the failure check is "FAILED" in subject, FAILED-partial still contains the substring FAILED, so it’s still counted — the change failure rate stays at 25%. This is worth noticing as a real fragility: the parser matches on a substring, not an exact status value, so a differently-formatted marker could either be silently miscounted or silently ignored depending on wording.
4. Compute lead time using each deploy's *latest* new commit instead of its earliest. How do the numbers change?
Replace min((c[1] for c in new_commits), ...) with max(...). For v1, v2, and v4 — each with exactly one commit in their window — the answer doesn’t change. For v3, whose window only contains the hotfix commit itself, it also doesn’t change. The two calculations only diverge when a single deploy window contains multiple commits, which none of these four windows do — a good reason to add a second commit to one window and see the two calculations actually produce different numbers.
5. What lead time would this repo show if `feature B` and `feature C` were both committed on 2026-01-01 (batched), with deploys still on their original dates?
Change feature B’s commit date to "2026-01-01T09:00:00" — the same day as feature A — and rerun. v2’s lead time doesn’t rise to reflect the longer real wait; it drops to 0.0h. The script’s window filter only looks for commits at-or-after the previous deploy’s timestamp (2026-01-02), so a commit dated before that boundary falls outside every window and min(..., default=deploy_time) silently falls back to the deploy time itself. This is a real bug the naive script has: it can’t attribute a commit to the deploy that shipped it if that commit predates the window it’s searching, and it fails silently (a suspiciously-low 0.0h) rather than raising an error — worth checking for in any lead-time script before trusting its output.