Views
No views yet
asterisk -rx "core show channels", squint at the output, and hope that the number of active channels looked about right. Maybe check /var/log/asterisk/full when something broke. Maybe not. That stopped being acceptable around the time we crossed 50,000 daily calls across a 4-server cluster. When a SIP trunk goes down at 2 PM on a Tuesday and 300 agents go idle, you need to know in seconds, not whenever someone notices the real-time report looks weird and pings you on Slack. This guide covers...asterisk -rx "core show channels", squint at the output, and hope that the number of active channels looked about right. Maybe check /var/log/asterisk/full when something broke. Maybe not.prometheus-node-exporter on your Asterisk box, write a script that scrapes asterisk -rx output into Prometheus metrics, and call it done. I've done exactly that. It works. It's also fragile, custom, and doesn't scale.res_statsd module pushes metrics via StatsD. AMI events can be forwarded as structured logs. You don't have to write custom parsers — you configure receivers.cluster_name attribute to every metric? Want to sample 10% of traces for non-error calls? All configurable in the collector.┌─────────────────────────────────────────────────────┐
│ Asterisk Server │
│ │
│ res_statsd ──→ OTel Collector ──→ Prometheus │
│ (sidecar) │
│ AMI Events ──→ ami-otel-bridge ──→ OTel Collector │
│ │ │
│ CDR/CEL ──→ MySQL ──→ mysqld_exporter ──→ Prometheus│
│ │
└───────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────────────┐ ┌──────────┐
│Prometheus│ │ Jaeger / Tempo │ │ Loki │
└────┬─────┘ └───────┬────────┘ └────┬─────┘
│ │ │
└──────────┬───────┘──────────────────┘
│
┌────▼─────┐
│ Grafana │
└──────────┘1# Download the latest stable release (check https://github.com/open-telemetry/opentelemetry-collector-releases)
2OTEL_VERSION="0.96.0"
3curl -L "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_VERSION}/otelcol-contrib_${OTEL_VERSION}_linux_amd64.tar.gz" \
4 -o /tmp/otelcol.tar.gz
5tar xzf /tmp/otelcol.tar.gz -C /usr/local/bin/ otelcol-contrib
6chmod +x /usr/local/bin/otelcol-contrib
7
8# Verify
9otelcol-contrib --version1# /etc/systemd/system/otelcol.service
2[Unit]
3Description=OpenTelemetry Collector
4After=network.target
5
6[Service]
7Type=simple
8User=otelcol
9Group=otelcol
10ExecStart=/usr/local/bin/otelcol-contrib --config=/etc/otelcol/config.yaml
11Restart=always
12RestartSec=5
13LimitNOFILE=65536
14
15[Install]
16WantedBy=multi-user.target1useradd --system --no-create-home --shell /usr/sbin/nologin otelcol
2mkdir -p /etc/otelcol
3chown otelcol:otelcol /etc/otelcolres_statsd. It's compiled in by default on most distributions but not loaded by default.1# /etc/asterisk/statsd.conf
2[general]
3enabled = yes
4server = 127.0.0.1:8125 ; OTel Collector's StatsD receiver
5prefix = asterisk ; All metrics will be prefixed with "asterisk."
6add_newline = no1asterisk -rx "module load res_statsd.so"
2# Verify it's loaded
3asterisk -rx "module show like statsd"Module Description Use Count Status
res_statsd.so StatsD client support 0 Running| Metric | Type | Description |
|---|---|---|
asterisk.channels.count | gauge | Current active channel count |
asterisk.channels.by_type.SIP | gauge | Active SIP channels |
asterisk.channels.by_type.PJSIP | gauge | Active PJSIP channels |
asterisk.channels.by_type.Local | gauge | Active Local channels |
asterisk.endpoints.count | gauge | Registered endpoints |
asterisk.endpoints.state.online | gauge | Endpoints in online state |
asterisk.endpoints.state.offline | gauge | Endpoints in offline state |
asterisk.bridges.count | gauge | Active bridges |
asterisk.bridges.channels | gauge | Channels in bridges |
statsd.conf.1# /etc/otelcol/config.yaml
2receivers:
3 # Receive StatsD metrics from res_statsd
4 statsd:
5 endpoint: "0.0.0.0:8125"
6 aggregation_interval: 10s
7 timer_histogram_mapping:
8 - statsd_type: "timer"
9 observer_type: "histogram"
10 histogram:
11 explicit:
12 - 10
13 - 25
14 - 50
15 - 100
16 - 250
17 - 500
18 - 1000
19 - 5000
20 - 10000
21
22 # Scrape host metrics (CPU, memory, disk, network)
23 hostmetrics:
24 collection_interval: 15s
25 scrapers:
26 cpu:
27 metrics:
28 system.cpu.utilization:
29 enabled: true
30 memory:
31 metrics:
32 system.memory.utilization:
33 enabled: true
34 disk: {}
35 network: {}
36 load: {}
37
38 # Receive OTLP from custom instrumentation (ami-otel-bridge)
39 otlp:
40 protocols:
41 grpc:
42 endpoint: "0.0.0.0:4317"
43 http:
44 endpoint: "0.0.0.0:4318"
45
46processors:
47 # Add resource attributes to every metric
48 resource:
49 attributes:
50 - key: service.name
51 value: "asterisk"
52 action: upsert
53 - key: host.name
54 from_attribute: ""
55 action: upsert
56 - key: cluster.name
57 value: "vicidial-prod"
58 action: upsert
59 - key: server.role
60 value: "dialer"
61 action: upsert
62
63 # Batch metrics to reduce export overhead
64 batch:
65 timeout: 10s
66 send_batch_size: 1000
67
68 # Memory limiter to prevent OOM
69 memory_limiter:
70 check_interval: 5s
71 limit_mib: 256
72 spike_limit_mib: 64
73
74exporters:
75 # Export metrics to Prometheus
76 prometheus:
77 endpoint: "0.0.0.0:8889"
78 namespace: "asterisk"
79 resource_to_telemetry_conversion:
80 enabled: true
81
82 # Export traces to Jaeger (or Tempo)
83 otlp/jaeger:
84 endpoint: "jaeger.monitoring.local:4317"
85 tls:
86 insecure: true
87
88 # Export logs to Loki
89 loki:
90 endpoint: "http://loki.monitoring.local:3100/loki/api/v1/push"
91 labels:
92 attributes:
93 service.name: "service_name"
94 host.name: "hostname"
95
96 # Debug output (disable in production)
97 # debug:
98 # verbosity: detailed
99
100service:
101 pipelines:
102 metrics:
103 receivers: [statsd, hostmetrics]
104 processors: [memory_limiter, resource, batch]
105 exporters: [prometheus]
106 traces:
107 receivers: [otlp]
108 processors: [memory_limiter, resource, batch]
109 exporters: [otlp/jaeger]
110 logs:
111 receivers: [otlp]
112 processors: [memory_limiter, resource, batch]
113 exporters: [loki]
114
115 telemetry:
116 logs:
117 level: "warn"
118 metrics:
119 address: ":8888"1systemctl daemon-reload
2systemctl enable otelcol
3systemctl start otelcol
4
5# Verify it's running and receiving StatsD
6curl -s http://localhost:8889/metrics | grep asterisk_channels# HELP asterisk_channels_count Current active channel count
# TYPE asterisk_channels_count gauge
asterisk_channels_count{cluster_name="vicidial-prod",host_name="dialer01",server_role="dialer"} 471#!/usr/bin/env python3
2"""
3ami_otel_bridge.py — Bridge AMI events to OpenTelemetry
4Runs as a daemon alongside Asterisk.
5"""
6
7import socket
8import time
9import re
10import os
11from opentelemetry import metrics, trace
12from opentelemetry.sdk.metrics import MeterProvider
13from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
14from opentelemetry.sdk.trace import TracerProvider
15from opentelemetry.sdk.trace.export import BatchSpanProcessor
16from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
17from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
18
19# OTel setup
20metric_exporter = OTLPMetricExporter(endpoint="localhost:4317", insecure=True)
21metric_reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=10000)
22metrics.set_meter_provider(MeterProvider(metric_readers=[metric_reader]))
23meter = metrics.get_meter("ami-bridge")
24
25trace_exporter = OTLPSpanExporter(endpoint="localhost:4317", insecure=True)
26trace.set_tracer_provider(TracerProvider())
27trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(trace_exporter))
28tracer = trace.get_tracer("ami-bridge")
29
30# Metrics
31calls_total = meter.create_counter("asterisk.calls.total", description="Total calls")
32calls_active = meter.create_up_down_counter("asterisk.calls.active", description="Active calls")
33calls_by_disposition = meter.create_counter("asterisk.calls.by_disposition", description="Calls by disposition")
34sip_registrations = meter.create_up_down_counter("asterisk.sip.registrations", description="SIP registration events")
35queue_callers = meter.create_up_down_counter("asterisk.queue.callers", description="Callers waiting in queue")
36call_duration = meter.create_histogram("asterisk.call.duration_ms", description="Call duration in milliseconds")
37
38# Track active call spans for distributed tracing
39active_spans = {}
40
41AMI_HOST = os.environ.get("AMI_HOST", "127.0.0.1")
42AMI_PORT = int(os.environ.get("AMI_PORT", "5038"))
43AMI_USER = os.environ.get("AMI_USER", "admin")
44AMI_SECRET = os.environ.get("AMI_SECRET", "amp111")
45
46
47def connect_ami():
48 """Connect to AMI and authenticate."""
49 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
50 sock.settimeout(30)
51 sock.connect((AMI_HOST, AMI_PORT))
52
53 # Read banner
54 sock.recv(1024)
55
56 # Login
57 login = (
58 f"Action: Login\r\n"
59 f"Username: {AMI_USER}\r\n"
60 f"Secret: {AMI_SECRET}\r\n"
61 f"Events: call,agent,cdr\r\n"
62 f"\r\n"
63 )
64 sock.sendall(login.encode())
65 response = sock.recv(4096).decode()
66 if "Success" not in response:
67 raise ConnectionError(f"AMI login failed: {response}")
68
69 print(f"[ami-otel] Connected to AMI at {AMI_HOST}:{AMI_PORT}")
70 return sock
71
72
73def parse_event(raw):
74 """Parse an AMI event into a dict."""
75 event = {}
76 for line in raw.strip().split("\r\n"):
77 if ": " in line:
78 key, value = line.split(": ", 1)
79 event[key.strip()] = value.strip()
80 return event
81
82
83def handle_event(event):
84 """Process an AMI event and emit OTel signals."""
85 event_type = event.get("Event", "")
86
87 if event_type == "Newchannel":
88 channel = event.get("Channel", "unknown")
89 calls_total.add(1, {"channel_type": channel.split("/")[0]})
90 calls_active.add(1)
91
92 # Start a trace span for this call
93 uniqueid = event.get("Uniqueid", "")
94 if uniqueid:
95 span = tracer.start_span(
96 "asterisk.call",
97 attributes={
98 "asterisk.channel": channel,
99 "asterisk.uniqueid": uniqueid,
100 "asterisk.caller_id": event.get("CallerIDNum", ""),
101 "asterisk.context": event.get("Context", ""),
102 "asterisk.exten": event.get("Exten", ""),
103 }
104 )
105 active_spans[uniqueid] = {
106 "span": span,
107 "start_time": time.time(),
108 }
109
110 elif event_type == "Hangup":
111 calls_active.add(-1)
112 uniqueid = event.get("Uniqueid", "")
113 cause = event.get("Cause-txt", "Unknown")
114
115 # End the trace span
116 if uniqueid in active_spans:
117 span_data = active_spans.pop(uniqueid)
118 duration_ms = (time.time() - span_data["start_time"]) * 1000
119 span_data["span"].set_attribute("asterisk.hangup_cause", cause)
120 span_data["span"].set_attribute("asterisk.duration_ms", duration_ms)
121 span_data["span"].end()
122 call_duration.record(duration_ms, {"cause": cause})
123
124 elif event_type == "AgentComplete":
125 dispo = event.get("Reason", "unknown")
126 calls_by_disposition.add(1, {"disposition": dispo})
127
128 elif event_type == "PeerStatus":
129 peer = event.get("Peer", "")
130 status = event.get("PeerStatus", "")
131 if status == "Registered":
132 sip_registrations.add(1, {"peer": peer})
133 elif status == "Unregistered":
134 sip_registrations.add(-1, {"peer": peer})
135
136 elif event_type == "Join":
137 queue_callers.add(1, {"queue": event.get("Queue", "unknown")})
138
139 elif event_type == "Leave":
140 queue_callers.add(-1, {"queue": event.get("Queue", "unknown")})
141
142
143def main():
144 while True:
145 try:
146 sock = connect_ami()
147 buffer = ""
148
149 while True:
150 data = sock.recv(4096).decode("utf-8", errors="replace")
151 if not data:
152 raise ConnectionError("AMI connection lost")
153
154 buffer += data
155
156 # AMI events are separated by \r\n\r\n
157 while "\r\n\r\n" in buffer:
158 raw_event, buffer = buffer.split("\r\n\r\n", 1)
159 if raw_event.strip():
160 event = parse_event(raw_event)
161 if "Event" in event:
162 handle_event(event)
163
164 except Exception as e:
165 print(f"[ami-otel] Error: {e}, reconnecting in 5s...")
166 time.sleep(5)
167
168
169if __name__ == "__main__":
170 main()1pip3 install opentelemetry-api opentelemetry-sdk \
2 opentelemetry-exporter-otlp-proto-grpc
3
4# Create systemd unit
5cat > /etc/systemd/system/ami-otel-bridge.service << 'EOF'
6[Unit]
7Description=AMI to OpenTelemetry Bridge
8After=asterisk.service otelcol.service
9
10[Service]
11Type=simple
12User=asterisk
13Environment=AMI_HOST=127.0.0.1
14Environment=AMI_PORT=5038
15Environment=AMI_USER=admin
16Environment=AMI_SECRET=your_ami_password_here
17ExecStart=/usr/bin/python3 /usr/local/bin/ami_otel_bridge.py
18Restart=always
19RestartSec=5
20
21[Install]
22WantedBy=multi-user.target
23EOF
24
25systemctl daemon-reload
26systemctl enable ami-otel-bridge
27systemctl start ami-otel-bridgeres_statsd for Asterisk internals, and the AMI bridge for call-level events and distributed traces.1# /etc/prometheus/prometheus.yml (add to scrape_configs)
2scrape_configs:
3 - job_name: 'asterisk-otel'
4 scrape_interval: 10s
5 static_configs:
6 - targets:
7 - 'dialer01.internal:8889'
8 - 'dialer02.internal:8889'
9 - 'dialer03.internal:8889'
10 labels:
11 environment: 'production'
12
13 # Also scrape the OTel Collector's own health metrics
14 - job_name: 'otel-collector'
15 scrape_interval: 30s
16 static_configs:
17 - targets:
18 - 'dialer01.internal:8888'
19 - 'dialer02.internal:8888'
20 - 'dialer03.internal:8888'1# /etc/prometheus/rules/asterisk.yml
2groups:
3 - name: asterisk_kpis
4 interval: 30s
5 rules:
6 # Calls per minute (cluster-wide)
7 - record: asterisk:calls_per_minute
8 expr: sum(rate(asterisk_calls_total[5m])) * 60
9
10 # Average call duration (5-minute window)
11 - record: asterisk:avg_call_duration_sec
12 expr: |
13 histogram_quantile(0.5,
14 rate(asterisk_call_duration_ms_bucket[5m])
15 ) / 1000
16
17 # 95th percentile call duration
18 - record: asterisk:p95_call_duration_sec
19 expr: |
20 histogram_quantile(0.95,
21 rate(asterisk_call_duration_ms_bucket[5m])
22 ) / 1000
23
24 # Channel utilization per server (active / max)
25 - record: asterisk:channel_utilization
26 expr: |
27 asterisk_channels_count /
28 (asterisk_endpoints_state_online * 2)
29
30 # SIP registration churn rate
31 - record: asterisk:registration_churn_rate
32 expr: |
33 abs(rate(asterisk_sip_registrations[5m]))
34
35 # Queue wait callers (cluster total)
36 - record: asterisk:queue_callers_total
37 expr: sum(asterisk_queue_callers)curl -X POST http://localhost:9090/-/reloadQuery: sum(asterisk_channels_count)
Thresholds: 0-100 green, 100-200 yellow, 200+ redQuery: asterisk:calls_per_minute
Legend: {{host_name}}Query: asterisk:channel_utilization * 100
Legend: {{host_name}}
Min: 0, Max: 100
Thresholds: 0-70 green, 70-85 yellow, 85-100 redQuery: sum(asterisk_endpoints_state_online)Query: sum(rate(asterisk_call_duration_ms_bucket[5m])) by (le)
Format: HeatmapQuery: sum(asterisk_queue_callers) by (queue)
Legend: {{queue}}
Alert: if > 10 for 2 minutesQuery: asterisk_endpoints_state_online
Transform: Labels to fields
Columns: host_name, peer, value
Value mappings: 1 = "Online" (green), 0 = "Offline" (red)Query: rate(asterisk_sip_registrations[5m])
Legend: {{peer}}Query: asterisk_channels_by_type
Legend: {{channel_type}}Variables:
- server: label_values(asterisk_channels_count, host_name)
Panels:
1. CPU Usage: system_cpu_utilization{host_name="$server"}
2. Memory Usage: system_memory_utilization{host_name="$server"}
3. Channels: asterisk_channels_count{host_name="$server"}
4. Load Average: system_cpu_load_average_5m{host_name="$server"}
5. Network I/O: rate(system_network_io_bytes_total{host_name="$server"}[5m])
6. Disk I/O: rate(system_disk_io_bytes_total{host_name="$server"}[5m])1# Enhanced event handling with nested spans
2def handle_dial_begin(event):
3 """Track outbound dial attempts within a call."""
4 uniqueid = event.get("Uniqueid", "")
5 if uniqueid in active_spans:
6 parent_span = active_spans[uniqueid]["span"]
7 ctx = trace.set_span_in_context(parent_span)
8 child = tracer.start_span(
9 "asterisk.dial",
10 context=ctx,
11 attributes={
12 "asterisk.dial.destination": event.get("DestChannel", ""),
13 "asterisk.dial.dialstring": event.get("Dialstring", ""),
14 }
15 )
16 active_spans[uniqueid]["dial_span"] = child
17
18
19def handle_dial_end(event):
20 """Complete the dial span with the result."""
21 uniqueid = event.get("Uniqueid", "")
22 if uniqueid in active_spans and "dial_span" in active_spans[uniqueid]:
23 dial_span = active_spans[uniqueid].pop("dial_span")
24 dial_span.set_attribute("asterisk.dial.status", event.get("DialStatus", ""))
25 dial_span.end()
26
27
28def handle_queue_join(event):
29 """Track time spent in queue."""
30 uniqueid = event.get("Uniqueid", "")
31 if uniqueid in active_spans:
32 parent_span = active_spans[uniqueid]["span"]
33 ctx = trace.set_span_in_context(parent_span)
34 child = tracer.start_span(
35 "asterisk.queue.wait",
36 context=ctx,
37 attributes={
38 "asterisk.queue.name": event.get("Queue", ""),
39 "asterisk.queue.position": event.get("Position", ""),
40 "asterisk.queue.count": event.get("Count", ""),
41 }
42 )
43 active_spans[uniqueid]["queue_span"] = child
44
45
46def handle_queue_leave(event):
47 """End queue wait span."""
48 uniqueid = event.get("Uniqueid", "")
49 if uniqueid in active_spans and "queue_span" in active_spans[uniqueid]:
50 queue_span = active_spans[uniqueid].pop("queue_span")
51 queue_span.end()[asterisk.call] ─── 145.2s total
├── [asterisk.dial] ─── 0.8s (to queue)
├── [asterisk.queue.wait] ─── 12.4s (INBOUND_SALES queue)
├── [asterisk.dial] ─── 1.2s (to agent SIP/agent42)
└── [asterisk.call] ends ─── hangup cause: Normal Clearing1# /etc/prometheus/rules/asterisk_alerts.yml
2groups:
3 - name: asterisk_alerts
4 rules:
5 # Trunk down — no channels for 2 minutes
6 - alert: AsteriskTrunkDown
7 expr: |
8 asterisk_channels_count == 0
9 and on(host_name)
10 (time() - asterisk_channels_count offset 5m) > 300
11 for: 2m
12 labels:
13 severity: critical
14 annotations:
15 summary: "Asterisk server {{ $labels.host_name }} has zero active channels for 2+ minutes"
16 description: "All trunks may be down. Check SIP registrations and carrier connectivity."
17
18 # Channel exhaustion warning
19 - alert: AsteriskChannelExhaustion
20 expr: asterisk:channel_utilization > 0.85
21 for: 5m
22 labels:
23 severity: warning
24 annotations:
25 summary: "Channel utilization above 85% on {{ $labels.host_name }}"
26 description: "Server is approaching channel capacity. Current utilization: {{ $value | humanizePercentage }}"
27
28 # Registration storm — phones flapping
29 - alert: AsteriskRegistrationStorm
30 expr: asterisk:registration_churn_rate > 5
31 for: 3m
32 labels:
33 severity: warning
34 annotations:
35 summary: "High SIP registration churn on {{ $labels.host_name }}"
36 description: "Phones are registering/unregistering rapidly. Possible network instability."
37
38 # Queue backup — callers waiting too long
39 - alert: AsteriskQueueBackup
40 expr: sum(asterisk_queue_callers) by (queue) > 15
41 for: 2m
42 labels:
43 severity: warning
44 annotations:
45 summary: "Queue {{ $labels.queue }} has {{ $value }} callers waiting"
46 description: "More than 15 callers in queue for 2+ minutes. Check agent availability."
47
48 # No calls for 10 minutes during business hours
49 - alert: AsteriskNoCalls
50 expr: |
51 asterisk:calls_per_minute == 0
52 and hour() >= 9
53 and hour() <= 17
54 and day_of_week() >= 1
55 and day_of_week() <= 5
56 for: 10m
57 labels:
58 severity: critical
59 annotations:
60 summary: "Zero calls per minute during business hours on {{ $labels.host_name }}"
61
62 # AMI bridge disconnected
63 - alert: AMIBridgeDown
64 expr: up{job="asterisk-otel"} == 0
65 for: 2m
66 labels:
67 severity: warning
68 annotations:
69 summary: "OTel Collector not reachable for {{ $labels.instance }}"mysqld_exporter — but honestly, for CDR analysis, you're better off with a dedicated query:1-- Calls per hour with quality metrics (run from a cron-based exporter)
2SELECT
3 DATE_FORMAT(calldate, '%Y-%m-%d %H:00:00') AS hour_bucket,
4 COUNT(*) AS total_calls,
5 AVG(duration) AS avg_duration,
6 AVG(billsec) AS avg_billsec,
7 SUM(CASE WHEN disposition = 'ANSWERED' THEN 1 ELSE 0 END) AS answered,
8 SUM(CASE WHEN disposition = 'NO ANSWER' THEN 1 ELSE 0 END) AS no_answer,
9 SUM(CASE WHEN disposition = 'BUSY' THEN 1 ELSE 0 END) AS busy,
10 SUM(CASE WHEN disposition = 'FAILED' THEN 1 ELSE 0 END) AS failed
11FROM cdr
12WHERE calldate >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
13GROUP BY hour_bucket
14ORDER BY hour_bucket;1#!/usr/bin/env python3
2"""
3cdr_exporter.py — Export Asterisk CDR metrics to Prometheus
4Run as a service, scrape on :9101/metrics
5"""
6
7import time
8import pymysql
9from prometheus_client import start_http_server, Gauge, Counter, Histogram
10
11# Metrics
12cdr_calls_total = Counter('asterisk_cdr_calls_total', 'Total CDR records', ['disposition'])
13cdr_duration = Histogram('asterisk_cdr_duration_seconds', 'Call duration from CDR',
14 buckets=[5, 10, 30, 60, 120, 300, 600, 1800])
15cdr_asr = Gauge('asterisk_cdr_asr', 'Answer-seizure ratio (15 min window)')
16
17
18def collect_cdr_metrics():
19 conn = pymysql.connect(
20 host='127.0.0.1',
21 user='cdr_readonly',
22 password='readonly_password',
23 db='asterisk',
24 charset='utf8mb4'
25 )
26 try:
27 with conn.cursor() as cursor:
28 # Recent calls by disposition
29 cursor.execute("""
30 SELECT disposition, COUNT(*), AVG(billsec)
31 FROM cdr
32 WHERE calldate >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
33 GROUP BY disposition
34 """)
35 total_calls = 0
36 answered_calls = 0
37 for row in cursor.fetchall():
38 disposition, count, avg_bill = row
39 cdr_calls_total.labels(disposition=disposition).inc(count)
40 total_calls += count
41 if disposition == 'ANSWERED':
42 answered_calls += count
43
44 if total_calls > 0:
45 cdr_asr.set(answered_calls / total_calls)
46
47 finally:
48 conn.close()
49
50
51if __name__ == '__main__':
52 start_http_server(9101)
53 while True:
54 collect_cdr_metrics()
55 time.sleep(60)asterisk -rx "sip show peers", stare at the output, grep through logs, try to figure out when it broke and why, call the carrier, wait on hold.res_statsd adds roughly 0.1% CPU overhead on a server handling 100 concurrent channels. The AMI bridge script uses about 15MB of RAM and negligible CPU — it's event-driven, not polling. The OTel Collector uses 50-100MB of RAM depending on pipeline complexity.res_statsd and AMI events flowing through OTel Collector to Prometheus