Bug Triage Template
A template for classifying and routing bug reports by severity and impact.
Overview
Not all bugs are equal. A cosmetic issue in a dark-mode toggle is not a data-loss bug in a payment flow. Without a triage system, critical issues sit in backlogs while engineers chase low-priority noise. This template creates a repeatable classification and routing system so the right bugs reach the right teams at the right priority. Pair it with an escalation policy for the “who do we wake up” side and a runbook for the “what do we do once it’s routed” side.
When to Use
Use this resource when:
- Your bug backlog is growing faster than your team can close items
- Critical production issues are buried under minor feature requests
- Multiple teams share a bug queue and ownership is unclear
- Severity debates repeat in every standup because “critical” means something different to each person
Skip it when the whole backlog fits one person and one page — a solo maintainer with twenty open issues needs a queue, not a process. The template earns its weight once classification disagreements cost real time, or once reports arrive faster than anyone can read them.
Solution
The triage pipeline is short: every report gets classified, routed and logged — in that order.
# Bug Triage: `<Project / Product>`
## 1. Severity Classification
| Severity | Impact | Examples | Response Time | Resolution Target |
|----------|--------|----------|---------------|-------------------|
| S1 — Critical | Service unusable or data loss | Payment failing, login broken, data corruption | 15 min | 4 hours |
| S2 — High | Major feature broken; workaround exists | Search down, report export fails | 2 hours | 24 hours |
| S3 — Medium | Feature degraded but functional | Slow page loads, incorrect sorting | 24 hours | 1 week |
| S4 — Low | Cosmetic or minor inconvenience | Misaligned button, typo, color mismatch | 1 week | Next sprint |
## 2. Triage Questions
Answer these questions for every incoming bug report:
1. **Reproducibility**
- [ ] Can the bug be reproduced consistently?
- [ ] If intermittent, what is the approximate frequency?
- [ ] Does it affect a specific user segment, device, or browser?
2. **Impact**
- [ ] Does it block a core user journey (signup, purchase, login)?
- [ ] Does it affect a single user, a subset, or all users?
- [ ] Is there a workaround? How difficult is it?
3. **Regulatory / Security**
- [ ] Does it expose PII or sensitive data?
- [ ] Does it violate a compliance requirement (PCI, SOC 2, GDPR)?
4. **Recency**
- [ ] Did this bug appear after a recent release?
- [ ] Is it a regression of a previously fixed issue?
## 3. Routing Rules
| Severity | Assignee | Channel | Escalation |
|----------|----------|---------|------------|
| S1 | On-call engineer + Team lead | Page + War room | VP Engineering after 1 hour |
| S2 | Team lead | Slack #incidents | Manager after 4 hours |
| S3 | Next available engineer | JIRA / Linear | Team lead if unresolved after 3 days |
| S4 | Backlog | JIRA / Linear | Re-evaluate if duplicates accumulate |
## 4. Triage Log
| Date | Bug ID | Reporter | Initial Severity | Final Severity | Owner | Reason for Change |
|------|--------|----------|------------------|----------------|-------|-------------------|
| | | | | | | |
## 5. Duplicate Detection
| Check | Method |
|-------|--------|
| Keyword search | Search JIRA with error message / component |
| Stack trace match | Compare stack trace signatures |
| User impact overlap | Check if multiple reports reference same flow |
| Release correlation | Filter bugs reported within 48h of a deployment |
- Companion code: the bug-triage-template companion repo packages the automation scripts from this page as runnable files.
Explanation
The template forces structured classification before routing. Many teams skip triage and assign bugs to whoever is available, which means critical issues wait behind low-priority tickets. The severity matrix uses user impact and business risk as primary axes, not just “how hard is the fix.” A one-line CSS change that blocks checkout is S1; a complex memory leak that affects 0.1% of users is S3. The routing rules prevent high-severity bugs from being treated as normal backlog work.
Handling Reports That Aren’t Bugs
A third of most queues isn’t a bug at all — it’s misconfiguration, expected behavior, duplicates, or feature requests wearing a bug costume. Give those a path that isn’t the severity scale: a “not a bug” resolution with a required reason code, and the closing template from the review section below. The distinction matters for metrics — mixing invalid reports into your bug SLA numbers makes triage look slower and less effective than it is, and makes the backlog look bigger than the real work.
Feature requests deserve special care. Users file “X doesn’t work” when they mean “X doesn’t exist” — the reporter experiences both identically, so re-labeling is a normal part of the job, not an admission of failure. What matters is closing the loop: tell the reporter their item moved to the product queue, so they don’t file it again next month as a “still broken” bug.
Variants
| Context | Classification Focus | Routing Approach |
|---|---|---|
| Mobile app | OS version, device model, app store reviews | Crashlytics auto-groups by stack trace |
| API / backend | Endpoint, error rate, latency spike | Alert manager routes by service owner |
| B2B SaaS | Tenant size, contract value, SLA | Customer success flags high-value customer bugs |
| Game / consumer | Monetization impact, player segment | Live ops team triages during events |
| Security bug | CVSS score, exploitability, exposure | Direct to security team; bypass standard queue |
What works
- Triage every new bug within 24 hours of report; stale triage is failed triage
- Use a single source of truth (JIRA, Linear, GitHub Issues) so duplicates are detectable
- Require a reproducibility test case before accepting S2 or higher; unconfirmed critical bugs waste engineering time
- Re-evaluate severity if new information emerges (e.g., “affects all users” not “some users”)
- Close “won’t fix” bugs explicitly with a rationale; silence creates backlogs of zombie tickets
Common Mistakes
- Classifying bugs by effort instead of impact (easy fix ≠ high priority)
- Letting reporters set their own severity; users always think their bug is critical
- Not tracking triage decisions, leading to the same debates every week
- Routing security bugs through the standard queue instead of directly to security
- Ignoring duplicates; ten reports of the same bug look like ten separate problems
Severity in Practice
The matrix is easy to apply in theory and hard in the moment. A few worked examples that keep calibrations consistent across the team:
| Report | Severity | Why |
|---|---|---|
| Checkout 500s for all users since the 14:00 deploy | S1 | Core journey broken for everyone; regression from a release |
| Export to CSV silently drops rows on Safari | S2 | Major feature broken, but a workaround exists (Chrome) |
| Dashboard date picker defaults to yesterday | S3 | Degraded but functional; users can pick the right date |
| Logo renders 2px off-center on IE11 | S4 | Cosmetic, shrinking user segment |
| API token leaked in an error response | S1 | Security/compliance override — skip the normal queue |
Two rules of thumb anchor most disputes. First, severity follows user impact, never fix difficulty — a one-line CSS change that blocks checkout is S1 and a week-long memory leak affecting 0.1 % of users is S3. Second, when a report sits between two levels, pick the lower one and re-evaluate after reproduction; over-classifying desensitizes the whole scale.
Rolling Out Triage on a Team
Start smaller than you think. The first month is about calibration, not coverage:
- Week 1: name the rotating triage owner and define the four severity levels with 2-3 real examples from your own backlog — borrowed examples never calibrate as well.
- Weeks 2-3: triage every incoming bug daily, log each decision in the triage log table, and compare notes on the disagreements at the end of each week.
- Week 4: review the log. The disagreements are your calibration data — write down the borderline cases and how you ruled on them, then fold them back into the template as new rows or worked examples.
- Month 2+: add the automation below (labels, fingerprinting, SLA alerts) once the manual loop is stable. Automating an uncalibrated process just produces confidently wrong routing at scale.
Keep the log even when it feels bureaucratic — it’s the only artifact that turns “we all know what critical means” into something a new hire can audit.
Triage Metrics That Matter
Metrics exist to answer one question: is the process working, or is it just keeping people busy? Review them in the same meeting where you review the triage log — numbers without the decisions behind them invite gaming.
Track these four numbers on a monthly cadence; everything else is vanity:
- Time-to-triage: median hours from report to a severity + owner. Target < 24 h.
- Duplicate rate: % of incoming reports closed as duplicates. High rates mean the deduplication step isn’t running early enough.
- Severity adjustment rate: % of bugs whose initial severity changed after reproduction. Consistently high numbers mean the questions in section 2 aren’t being answered.
- SLA adherence: % of bugs resolved inside their severity target, per the routing table. Break it down by severity — a 90 % aggregate can hide a 50 % S1.
A Week of Triage, Worked Example
Abstract rules stick better once you’ve seen a queue processed. Monday morning, seven reports are waiting:
- “Payment page returns a blank screen” — no steps, no browser info. Before classifying, you ask for environment details; the reporter replies that it happens on every device. You reproduce it on your side, see a JS bundle failing after last night’s deploy, and mark it S1 — it pages the on-call and lands in the release-notes war room.
- “Search results look stale” — reproducible, affects one team plan, and a refresh fixes it. S3, routed to the search team; the owner asks for the query that triggered it and finds a cache TTL misconfiguration two hours later.
- Three reports of “export button does nothing” — identical symptoms filed by different customers. The deduplication check catches them as one bug; S2 on the exports service. The duplicate count, not any single reporter, is what pushes it up from S3.
- “Dark mode icon has wrong tooltip” — cosmetic, S4, straight to the backlog.
- “API returns my customer’s data when I pass an invalid token” — wrong on every level of the normal scale, because it belongs in the security queue instead. Routed out-of-band with the S1 clock running under confidentiality rules.
By Thursday the triage log shows nine decisions — five kept their initial severity, three were adjusted after reproduction, one was closed as a duplicate. That log is what lets Friday’s reviewer audit the week without re-litigating every call: each row says what was decided, by whom, and why it changed when it did.
Two months in, the pattern to watch is which adjustments keep repeating. If “intermittent” reports keep starting at S3 and graduating to S2, your repro questions aren’t capturing frequency well — fix the template’s questions, not the people applying them.
Advanced Solutions
Automated bug triage with GitHub Issues and labels
Use GitHub Actions to auto-label and route incoming bug reports based on content analysis:
# .github/workflows/auto-triage.yml
name: Auto-Triage Bug Reports
on:
issues:
types: [opened, labeled]
jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Check for severity keywords
uses: actions/github-script@v7
with:
script: |
const body = context.payload.issue.body || "";
const title = context.payload.issue.title || "";
const text = (title + " " + body).toLowerCase();
const severityRules = [
{ label: "s1-critical", keywords: ["data loss", "payment", "login broken", "security", "production down"] },
{ label: "s2-high", keywords: ["broken", "crash", "error 500", "not working", "failing"] },
{ label: "s3-medium", keywords: ["slow", "incorrect", "wrong", "unexpected"] },
{ label: "s4-low", keywords: ["typo", "cosmetic", "alignment", "color"] },
];
for (const rule of severityRules) {
if (rule.keywords.some(kw => text.includes(kw))) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: [rule.label, "needs-triage"],
});
break;
}
}
Bug deduplication with stack trace fingerprinting
Group similar crash reports by normalizing and hashing stack traces:
import hashlib
import re
from collections import defaultdict
from typing import List, Dict
def normalize_stack_trace(trace: str) -> str:
"""Normalize a stack trace by removing line numbers and memory addresses."""
# Remove file paths, keeping only filenames
trace = re.sub(r'/[\w/.-]+/', '', trace)
# Remove line numbers
trace = re.sub(r':\d+', '', trace)
# Remove memory addresses
trace = re.sub(r'0x[0-9a-fA-F]+', '0xADDR', trace)
# Remove thread IDs
trace = re.sub(r'Thread-\d+', 'Thread-N', trace)
return trace.strip()
def fingerprint_stack_trace(trace: str) -> str:
"""Generate a hash fingerprint from a normalized stack trace."""
normalized = normalize_stack_trace(trace)
return hashlib.sha256(normalized.encode()).hexdigest()[:16]
def group_bug_reports(reports: List[Dict]) -> Dict[str, List[Dict]]:
"""Group bug reports by stack trace fingerprint."""
groups = defaultdict(list)
for report in reports:
trace = report.get("stack_trace", "")
if trace:
fp = fingerprint_stack_trace(trace)
groups[fp].append(report)
else:
groups["no-trace"].append(report)
return groups
# Example usage
bug_reports = [
{"id": "BUG-001", "stack_trace": "TypeError at /app/src/handlers.py:42\n File /app/src/utils.py:128"},
{"id": "BUG-002", "stack_trace": "TypeError at /app/src/handlers.py:45\n File /app/src/utils.py:131"},
{"id": "BUG-003", "stack_trace": "ValueError at /app/src/models.py:67"},
]
groups = group_bug_reports(bug_reports)
for fp, reports in groups.items():
print(f"Fingerprint {fp}: {len(reports)} reports - {[r['id'] for r in reports]}")
Bug aging dashboard with Slack notification
Track bugs that exceed their SLA resolution target and alert the team:
import os
from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import List
SLA_TARGETS = {
"S1": timedelta(hours=4),
"S2": timedelta(hours=24),
"S3": timedelta(days=7),
"S4": timedelta(days=14),
}
@dataclass
class BugTicket:
id: str
severity: str
created_at: datetime
status: str
assignee: str
def find_aged_bugs(tickets: List[BugTicket]) -> List[BugTicket]:
"""Find bugs that have exceeded their SLA resolution target."""
now = datetime.now()
aged = []
for ticket in tickets:
if ticket.status in ("closed", "resolved"):
continue
sla = SLA_TARGETS.get(ticket.severity)
if not sla:
continue
age = now - ticket.created_at
if age > sla:
aged.append(ticket)
return aged
def format_slack_alert(aged_bugs: List[BugTicket]) -> str:
"""Format aged bugs for a Slack alert message."""
if not aged_bugs:
return "No bugs have exceeded their SLA target."
lines = [":warning: *SLA Violation Alert* — Bugs exceeding resolution target:\n"]
for bug in sorted(aged_bugs, key=lambda b: b.created_at):
age_hours = (datetime.now() - bug.created_at).total_seconds() / 3600
sla_hours = SLA_TARGETS[bug.severity].total_seconds() / 3600
lines.append(
f"• {bug.severity} {bug.id} — {age_hours:.0f}h old "
f"(SLA: {sla_hours:.0f}h) — Assigned: {bug.assignee}"
)
return "\n".join(lines)
# Example usage
tickets = [
BugTicket("BUG-100", "S1", datetime.now() - timedelta(hours=6), "open", "alice"),
BugTicket("BUG-101", "S2", datetime.now() - timedelta(hours=30), "open", "bob"),
BugTicket("BUG-102", "S3", datetime.now() - timedelta(days=2), "open", "charlie"),
]
aged = find_aged_bugs(tickets)
print(format_slack_alert(aged))
Reviewing Stale Bugs
Low-severity bugs accumulate fast and quietly become irrelevant. Two habits keep the backlog honest:
- A monthly “bug bash” — an hour where the team reviews open S3/S4 tickets older than 90 days and either reproduces, re-prioritizes, or closes them with a stated reason:
#!/bin/bash
# Find S3/S4 bugs older than 90 days with no activity
gh issue list \
--label "s3-medium,s4-low" \
--state open \
--search "created:<$(date -d '90 days ago' +%Y-%m-%d) updated:<$(date -d '30 days ago' +%Y-%m-%d)" \
--json number,title,createdAt,updatedAt \
--jq '.[] | "#\(.number) \(.title) (created: \(.createdAt[:10]))"'
- A fixed closing template for reports that turn out not to be bugs — consistent wording keeps closures polite and searchable:
Thank you for reporting this issue. After investigation, this appears to be:
- [ ] A configuration error on the user side
- [ ] Expected behavior, not a bug
- [ ] A duplicate of #{existing_issue}
- [ ] A feature request, not a bug
Closing as: {reason}. If you believe this is incorrect, please reopen with additional context. Frequently Asked Questions
What if a bug report is vague or missing reproduction steps?
Request the standard information: steps to reproduce, expected vs actual behavior, environment (browser, OS, version), screenshots or screen recordings, and error logs. If the reporter cannot provide this within 48 hours, downgrade to S4 or close as "needs info." Do not let incomplete reports block triage of useful bugs.
How do I prevent triage from becoming a bottleneck?
Rotate a "triage duty" engineer each week. This person reviews all incoming bugs for 30 minutes every morning, classifies, routes, and requests missing info. Triage duty should not be the same person as on-call. Over time, automate: use crash reporters to pre-classify by stack trace, and bot rules to auto-route known component owners.
Should feature requests be triaged alongside bugs?
No. Keep bugs and feature requests in separate queues with separate SLA targets. Feature requests require product input; bugs require engineering input. Mixing them creates confusion about ownership and priority. If a user reports a bug that is actually a missing feature, re-label it and move it to the product backlog with a clear explanation.
When should severity be re-evaluated?
Whenever new information changes the impact assessment — a "some users" report that turns out to affect everyone, a workaround that stops working, or a cosmetic bug that starts generating duplicate reports. The triage log's "Reason for Change" column exists exactly for this: severity is a working hypothesis, not a verdict.
Do security bugs go through this template?
No — not the standard queue, anyway. Any report with security or data-exposure implications goes straight to the security team channel and gets its own confidentiality rules. The template's S1 still applies for response speed, but visibility stays restricted until the fix ships.
Related Resources
Runbook Template
A reusable template for operational runbooks: incident response, deployment procedures, and routine tasks.
DocEscalation Policy Template
A template for defining incident severity levels and on-call escalation paths.
DocOn-Call Runbook Template
A template documenting common alerts and step-by-step response procedures for on-call engineers.
DocDowntime Communication Template
A template for internal and external outage messaging during service downtime.
DocPerformance Regression Template
A template for comparing benchmarks and creating action plans when performance degrades.
DocService Level Objective Template
A template for defining SLOs, SLIs, and error budgets for reliable service management.