Skip to content
ExponaBLOG
PROMETHEUS, GRAFANA & MONITORING · DAY 092 / 100

Alert routing by team: a Monitoring guide for developers

Get alerts to the right people.

Expona Admin9 min read

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.

yaml
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:

yaml
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:

yaml
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:

yaml
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:

yaml
- 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 team label 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles