Alert routing by team: a Monitoring guide for developers
When an alert fires at 2 AM, the last thing you need is the wrong team waking up. We've all experienced the chaos of alerts bouncing between teams, critical issues reaching people without the context to fix them, and on-call engineers drowning in noise that belongs to someone else. This guide shows you how to build a routing system that ensures every alert reaches the right people—the ones who own that service and can actually do something about it.
Why alert routing matters in practice
Without proper routing, monitoring becomes a liability rather than an asset. Consider a typical company with backend, frontend, platform, and data teams. A database connection pool exhaustion alert should wake up the platform team, not the frontend engineers. A JavaScript bundle size regression belongs on the frontend team's board, not in the backend Slack channel. When alerts are routed poorly, on-call engineers waste time context-switching, escalating to other teams, or worse—ignoring alerts because they've tuned out the noise.
Proper alert routing reduces mean time to resolution (MTTR) because the right expertise is engaged immediately. It also prevents alert fatigue by ensuring each team only receives alerts they can act on.
Adding team labels to every alert
The foundation of alert routing is consistent labeling. Every alert rule must carry a team label that identifies the owning team. This is not optional metadata—it's as critical as the alert name itself.
In your Prometheus alert rules, add the team label at rule definition time. This ensures consistency and makes it impossible to accidentally create an alert without team ownership.
groups:
- name: backend_alerts
interval: 30s
rules:
- alert: DatabaseConnectionPoolExhausted
expr: pg_stat_activity_count{service="checkout"} > 90
for: 2m
labels:
team: platform
severity: critical
service: checkout-db
annotations:
summary: "Connection pool exhausted on {{ $labels.service }}"
description: "{{ $value }} active connections detected"
- alert: APILatencyHigh
expr: rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m]) > 1
for: 5m
labels:
team: backend
severity: warning
service: api-gateway
annotations:
summary: "High API latency detected"
Define team labels at the rule level rather than relying on generic service labels. This keeps ownership explicit. When you're writing alert rules, ask: "Which team should own this?" If you're not sure, that's a sign you need clearer service ownership boundaries.
For larger organizations, consider adding a second layer of detail: team_owner for the primary team and team_escalation for the secondary team that handles overflow or escalations. This becomes crucial when teams work across multiple services.
Configuring Alertmanager routes by team
Alertmanager's routing tree is where the magic happens. Routes determine how alerts flow to notifications channels based on their labels. By organizing routes around team ownership, you create a clear signal path from alert firing to the right people.
Here's a practical alertmanager.yml configuration that routes by team:
global:
resolve_timeout: 5m
route:
receiver: default
group_by: ['alertname', 'cluster', 'service', 'team']
group_wait: 10s
group_interval: 10s
repeat_interval: 12h
routes:
# Backend team gets all backend alerts
- match:
team: backend
receiver: backend_team
group_by: ['alertname', 'service']
continue: false
routes:
- match:
severity: critical
receiver: backend_oncall_pagerduty
repeat_interval: 5m
# Platform team gets database and infra alerts
- match:
team: platform
receiver: platform_team
group_by: ['alertname', 'service']
continue: false
routes:
- match:
severity: critical
receiver: platform_oncall_pagerduty
repeat_interval: 5m
# Frontend team
- match:
team: frontend
receiver: frontend_team
group_by: ['alertname', 'service']
continue: false
# Data team with custom grouping
- match:
team: data
receiver: data_team
group_by: ['alertname', 'pipeline']
continue: false
receivers:
- name: default
slack_configs:
- api_url: https://hooks.slack.com/services/YOUR/DEFAULT/WEBHOOK
channel: '#alerts-general'
- name: backend_team
slack_configs:
- api_url: https://hooks.slack.com/services/YOUR/BACKEND/WEBHOOK
channel: '#backend-alerts'
title: 'Backend Alert'
text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
- name: backend_oncall_pagerduty
pagerduty_configs:
- service_key: YOUR_BACKEND_ONCALL_SERVICE_KEY
description: '{{ .GroupLabels.alertname }} on {{ .GroupLabels.service }}'
- name: platform_team
slack_configs:
- api_url: https://hooks.slack.com/services/YOUR/PLATFORM/WEBHOOK
channel: '#platform-alerts'
email_configs:
- to: platform-oncall@company.com
from: alertmanager@company.com
smarthost: smtp.company.com:587
- name: platform_oncall_pagerduty
pagerduty_configs:
- service_key: YOUR_PLATFORM_ONCALL_SERVICE_KEY
- name: frontend_team
slack_configs:
- api_url: https://hooks.slack.com/services/YOUR/FRONTEND/WEBHOOK
channel: '#frontend-alerts'
- name: data_team
slack_configs:
- api_url: https://hooks.slack.com/services/YOUR/DATA/WEBHOOK
channel: '#data-pipeline-alerts'
The key insight here is using group_by strategically. The root route groups by team, which ensures alerts from different teams never get bundled together. Then each team-specific route can override grouping—the platform team might group by service, while the data team groups by pipeline name. This prevents alert fatigue from mixed concerns.
Notice that critical alerts route to PagerDuty for teams with on-call rotations, while warnings stay in Slack. This is a common pattern: high-severity alerts wake people up, lower-severity alerts inform during business hours.
Escalation policies per team
Different teams have different escalation needs. A backend API issue might need the on-call engineer immediately, then escalate to the tech lead if unacknowledged after 15 minutes. A frontend CSS bug might just need a Slack notification—no page required.
Define escalation policies in your monitoring configuration or directly in PagerDuty. Here's an example structure:
| Team | Severity: Critical | Severity: Warning | Escalation |
|---|---|---|---|
| Backend | PagerDuty (page immediately) | Slack #backend-alerts | Unacked after 15min → Tech Lead |
| Platform | PagerDuty (page immediately) | Email + Slack | Unacked after 10min → Manager |
| Frontend | PagerDuty (page if user-facing) | Slack #frontend-alerts | Unacked after 30min → Team Slack |
| Data | Slack + Email | Slack #data-pipeline | No escalation (batch processes) |
Implement this in Alertmanager by nesting routes with different receivers and repeat intervals:
routes:
- match:
team: backend
receiver: backend_team
routes:
- match:
severity: critical
receiver: backend_oncall_pagerduty
repeat_interval: 5m # Re-page every 5 minutes until acknowledged
group_wait: 0s # Don't wait, page immediately
- match:
severity: warning
receiver: backend_team
repeat_interval: 1h # Remind once per hour
group_wait: 30s # Wait to batch similar warnings
The repeat_interval controls how often Alertmanager re-sends an alert to its receiver. Short intervals (5 minutes) for critical alerts ensure the on-call engineer sees it even if they miss the initial notification. Longer intervals (1 hour) for warnings prevent notification spam.
Communication with your PagerDuty or on-call system is critical. Ensure escalation policies in those systems match your Alertmanager routing. If Alertmanager routes to a PagerDuty service but that service has no escalation policy configured, you've created a single point of failure.
Ownership metadata in services
Static alert rules define ownership at rule-write time, but services evolve. A microservice might change teams, responsibilities might shift, or you might spin up new services frequently. To scale alert routing, embed ownership metadata in your services themselves.
Add ownership information to your Prometheus service labels:
global:
external_labels:
cluster: production
region: us-east-1
environment: prod
scrape_configs:
- job_name: checkout-service
static_configs:
- targets:
- checkout-1:9090
- checkout-2:9090
relabel_configs:
- source_labels: [__address__]
target_label: instance
- target_label: service
replacement: checkout
- target_label: team
replacement: backend
- target_label: owner_slack
replacement: '@backend-team'
- target_label: owner_email
replacement: backend-oncall@company.com
- job_name: payment-service
static_configs:
- targets:
- payment-1:9090
relabel_configs:
- target_label: service
replacement: payment
- target_label: team
replacement: backend
- target_label: owner_slack
replacement: '@backend-team'
Then use these labels in alert annotations to dynamically fill in contact details:
- alert: ServiceHighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
for: 2m
labels:
team: "{{ $labels.team }}"
severity: critical
annotations:
summary: "High error rate on {{ $labels.service }}"
description: "{{ $labels.service }} error rate is {{ $value | humanizePercentage }}"
runbook: "https://runbooks.company.com/{{ $labels.service }}/high-error-rate"
slack_handle: "{{ $labels.owner_slack }}"
When this alert fires, the annotation includes the right Slack handle. Your notification webhook can then read this and @mention the correct team automatically.
For services registered in a service mesh or service registry (Consul, Kubernetes service discovery), pull ownership from your source of truth. Update your Prometheus configuration to query these systems periodically.
Common mistakes
- Forgetting the team label on some rules. If 90% of alerts have a team label but 10% don't, those unlabeled alerts either get lost or trigger the default receiver. Audit your alert rules regularly. Use a linter or validation script to enforce that every alert has a team label.
- Using service names as team labels. The temptation is strong: just put the service name in the label and let the notification system figure it out. But services move between teams. Use actual team names (backend, platform, data, frontend) as labels, not service names.
- Static routing without service metadata. If you hardcode alert routing without tying it to service ownership metadata, you'll maintain two systems that drift apart. When a service changes teams, you have to remember to update both the alert rules and the Alertmanager config.
- Overcomplicating the routing tree. More than 3–4 levels of nested routes becomes hard to reason about. Keep the structure shallow: root groups by team, then team routes split by severity. Anything deeper usually indicates you need to rethink how you're grouping alerts.
- Ignoring repeat intervals. If you set the same repeat interval for critical and warning alerts, you either spam your on-call engineer or miss re-notifications. Use shorter repeat intervals for critical alerts (5–15 minutes) and longer for warnings (1+ hour).
- Not testing routing before production. Deploy Alertmanager changes to staging first. Fire test alerts and verify they route to the right channel. Many teams discover routing issues after an actual incident.
Key takeaways
- Add a
teamlabel to every alert rule at definition time. This is foundational—without it, no routing system works. - Structure your Alertmanager routes so the root route groups by
team, preventing alerts from different teams from being bundled together. - Define escalation policies per team and implement them consistently in both Alertmanager repeat intervals and your on-call system (PagerDuty, Opsgenie, etc.).
- Embed ownership metadata in your service configurations and use dynamic labels in alert annotations to reduce maintenance overhead as services change ownership.
- Audit your alert rules regularly to ensure all alerts have team ownership. Use automation to enforce this constraint.
- Test routing changes in a staging Alertmanager before deploying to production. Fire test alerts and trace them through to their receivers.
- Keep routing trees shallow and easy to reason about. More than 3–4 nesting levels usually signals you need to restructure your alerting strategy.






Comments
Loading comments…