ASTPP CC/CPS Reporting System — Technical Documentation

ASTPP CC/CPS Reporting System — Technical Documentation

A TimescaleDB-backed real-time reporting system for concurrent calls (CC) and calls-per-second (CPS), built on top of the existing ASTPP/FreeSWITCH/OpenSIPS VoIP platform. Documented 2026-09-09. The architecture diagram below is a static image; the other three diagrams (CPS flow, CC flow, aggregate chain) remain as Mermaid source. A live, interactive (Mermaid-rendered) version of the whole document is published at this Artifact link.

1. Overview

ASTPP (the billing/switch control panel) had no way to answer "how many calls per second / concurrent calls does this customer or carrier have right now." This system adds that, end to end, as a set of small, independent components bolted onto the existing platform rather than a rewrite of anything:

  • A TimescaleDB database (astpp_reports) holds all reporting data, completely separate from ASTPP's own MySQL database.

  • A single Report API (FastAPI service) is the only process that ever touches TimescaleDB.

  • A handful of small collector components — two Python daemons, several cron-driven poller/flush scripts, a few added lines of Lua inside FreeSWITCH, and a small config addition to OpenSIPS — feed data in from every point calls actually pass through.

  • A CC / CPS Report module inside ASTPP's own PHP/CodeIgniter admin panel renders it.

Guiding design principle throughout: never let a slow or failing reporting component affect real call handling. Every collector is isolated (separate ESL connections, separate systemd cgroups, short timeouts, "fail open" semantics), and the ASTPP PHP-FPM workers never make a synchronous call to TimescaleDB — they always go through the Report API with a hard 3-second timeout.

2. Architecture at a glance

Reading this diagram: solid arrows are the call-signaling path (what actually carries a phone call); dotted arrows are the reporting path (what carries data about the call). The two paths only ever touch inside FreeSWITCH's own dialplan (a few lines of Lua) and OpenSIPS's own routing script (a few lines of config) — nothing on the reporting side can ever block or slow down a real call, because every reporting write is either fire-and-forget to a local file, or happens on a completely separate connection/process from call handling.

3. Components — what they are, and how to install them

3.1 TimescaleDB (PostgreSQL 16 + TimescaleDB extension)

What it is: the database of record for all reporting data. Runs on the same box as MySQL (37.27.251.165) but as a fully separate service, deliberately configured not to auto-tune itself against the box's total RAM (which would ignore MySQL's existing 10GB buffer pool reservation).

Install:

# Add the TimescaleDB apt repo, then: apt install postgresql-16 timescaledb-2-postgresql-16 # Register the extension with the server (edits postgresql.conf's # shared_preload_libraries automatically): timescaledb-tune --quiet --yes --conf-path=/etc/postgresql/16/main/postgresql.conf --skip-checks systemctl restart postgresql@16-main

Configure (deliberately conservative — do NOT accept timescaledb-tune's auto-detected memory settings on a box already running MySQL):

# /etc/postgresql/16/main/postgresql.conf shared_buffers = 2GB effective_cache_size = 6GB max_connections = 50
# systemd override: hard ceiling so Postgres can never encroach on MySQL # /etc/systemd/system/postgresql@16-main.service.d/override.conf [Service] MemoryMax=4G CPUQuota=400%

Create the database, extension, and least-privilege app role:

CREATE DATABASE astpp_reports; \c astpp_reports CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE ROLE report_api LOGIN PASSWORD '...'; GRANT CONNECT ON DATABASE astpp_reports TO report_api; GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA public TO report_api; ALTER ROLE report_api SET statement_timeout = '5s';

Then create the hypertables, plain table, and continuous aggregates listed in Section 6 — each raw table is created as an ordinary CREATE TABLE, then converted with SELECT create_hypertable('table_name', 'time').

3.2 Report API (FastAPI service)

What it is: the only process that ever reads or writes TimescaleDB. Everything else in this system — the PHP portal, every collector — talks to this service, never to Postgres directly. This isolation specifically avoids repeating a real earlier incident on this platform where a synchronous external DB call from PHP-FPM (via mod_json_cdr) took the whole platform down.

Location: /opt/astpp-report-api/main.py, host 37.27.251.165.

Install:

mkdir -p /opt/astpp-report-api cd /opt/astpp-report-api python3 -m venv venv ./venv/bin/pip install fastapi "uvicorn[standard]" asyncpg pydantic # main.py placed here

Configure (/opt/astpp-report-api/.env, referenced by path only — never hardcoded in the script):

REPORT_API_DB_DSN=postgresql://report_api:...@127.0.0.1/astpp_reports REPORT_API_KEY=<read key, given to the PHP portal> REPORT_API_INGEST_KEY=<write key, given only to collector scripts>

Run as a systemd service (see full unit in Section 7) bound only to the WireGuard address 10.100.0.1:8090 — never a public interface.

Two separate API keys are the core access-control decision here: a leaked read key (held by the PHP portal, which is a bigger attack surface) can never be used to write fake CC/CPS data, because it isn't accepted on any /internal/ingest/* route.

3.3 WireGuard tunnel

What it is: the only network path the Report API is reachable on. No private network existed between these Hetzner boxes before this project — only public IPs — so this tunnel was purpose-built to let collectors on media-1/media-2/web1 reach the Report API without exposing Postgres or the API to the public internet at all.

Install (repeat per peer, adjusting the address):

apt install wireguard wg genkey | tee privatekey | wg pubkey > publickey
# /etc/wireguard/wg0.conf on the hub (37.27.251.165) [Interface] Address = 10.100.0.1/24 PrivateKey = <hub private key> ListenPort = 51820 [Peer] # media-1 PublicKey = <media-1 public key> AllowedIPs = 10.100.0.2/32 [Peer] # media-2 PublicKey = <media-2 public key> AllowedIPs = 10.100.0.3/32 [Peer] # web1 PublicKey = <web1 public key> AllowedIPs = 10.100.0.4/32
systemctl enable --now wg-quick@wg0

Host

WireGuard IP

Host

WireGuard IP

Report host (37.27.251.165)

10.100.0.1 (hub)

media-1 (65.109.142.26)

10.100.0.2

media-2 (46.62.246.2)

10.100.0.3

web1 (95.217.8.48)

10.100.0.4

Note: systemctl status wg-quick@wg0 normally shows inactive shortly after boot — that unit runs once to bring the interface up, then exits; it does not mean the tunnel is down. Verify with wg show / ip addr show wg0 instead.

3.4 CC sampler daemon (cc_sampler_daemon.py)

What it is: a persistent Python daemon, running only on media-1, that asks FreeSWITCH directly (via ESL — the Event Socket Library) "what's true right now" once a second, for both per-account and platform-wide concurrency, and once every 5 seconds for per-trunk concurrency. It talks to media-2 the same way, just over the WireGuard tunnel instead of locally.

Why a persistent daemon and not a cron job: a fresh fs_cli process every second would mean a process spawn + TCP connect + auth handshake every second, forever, across two servers — wasteful and adds latency. One long-lived ESL connection per concern is far cheaper.

Why three separate ESL connections, not one: the minimal ESL client here has no request/reply correlation ID. A single shared connection means a slow reply to one query can be silently consumed as the reply to the next, unrelated query — this exact bug was found and fixed in production (see Section 11). So each concern gets its own connection: self.conn (per-account limits), self.total_conn (platform total), self.trunk_conn (per-trunk, heavier query, longer timeout).

Install:

# script placed at /usr/local/bin/cc_sampler_daemon.py chmod +x /usr/local/bin/cc_sampler_daemon.py
# /etc/systemd/system/cc-sampler.service [Unit] Description=ASTPP live CC sampler (per-account, platform, per-trunk) After=network.target freeswitch.service [Service] Type=simple ExecStart=/usr/bin/python3 /usr/local/bin/cc_sampler_daemon.py Restart=always RestartSec=5 MemoryMax=256M CPUQuota=50% [Install] WantedBy=multi-user.target
systemctl enable --now cc-sampler.service

3.5 Trunk attempt watcher daemon (trunk_attempt_watcher.py)

What it is: a persistent daemon, media-1 only (same local/remote dual-server pattern as the CC sampler), that subscribes to FreeSWITCH's ESL event bus (CHANNEL_EXECUTE, CHANNEL_ANSWER, CHANNEL_HANGUP, CHANNEL_DESTROY) to derive per-trunk attempted/accepted call rates. Event-bus subscription is acceptable here because short-window rate counts tolerate some event loss; it was found unsuitable for concurrency tracking specifically (see Section 11), which is why concurrency uses the sampler's direct polling instead.

Install: identical pattern to 3.4 — script at /usr/local/bin/trunk_attempt_watcher.py, unit trunk-attempt-watcher.service.

3.6 Poller & flush scripts (cron-driven)

What they are: small, single-purpose scripts that move data from a local source (a queue file written by Lua, or a MySQL table) into the Report API. All follow the same shape — read, POST, mark as sent — and none of them ever write to Postgres directly.

Script

Host

Reads

Cadence

Script

Host

Reads

Cadence

admissions_poller.py

report host

local MySQL cdrs (watermark)

every 1 min

trunk_daily_totals_poller.py

report host

local MySQL cdrs

every 5 min

reject_cdr_flush.py/.sh

media-1 & media-2

reject_cdr_queue.log

every 1 min (loops ~10s internally)

call_attempt_flush.py/.sh

media-1 & media-2

call_attempt_queue.log

every 1 min

call_accepted_flush.py/.sh

media-1 & media-2

call_accepted_queue.log

every 1 min

trunk_attempt_flush.py/.sh

media-1 & media-2

trunk_attempt_queue.log

every 1 min

trunk_accepted_flush.py/.sh

media-1 & media-2

trunk_accepted_queue.log

every 1 min

trunk_channel_event_flush.py/.sh

media-1 & media-2

trunk_channel_event_queue.log

every 1 min (legacy, Section 9)

opensips_invite_stat_poll.py/.sh

OpenSIPS host

OpenSIPS MI stats

every 1 min

Install pattern (same for all of them):

# script placed under /usr/local/bin/, made executable chmod +x /usr/local/bin/<script>.sh crontab -e # * * * * * /usr/local/bin/<script>.sh >> /var/log/astpp/<script>.log 2>&1

Each flush script does an atomic mv of the queue file to a per-run work file before reading it, so a write from Lua landing mid-flush is never lost or double-counted.

3.7 FreeSWITCH Lua integration

What it is: small additions to FreeSWITCH's existing dialplan/rate-limiting Lua scripts (not new call-handling logic — these scripts already run on every call; this just adds a few lines that log an event to a local file). Files live at /opt/ASTPP/freeswitch/scripts_deb9/astpp/scripts/ on both media-1 and media-2.

"Install" here means editing already-deployed, business-critical files — always with a timestamped backup first (cp astpp.xml.lua astpp.xml.lua.bak-cc-fix-<epoch>), and always verified on the next real call rather than by restarting FreeSWITCH (Lua files are re-read per call, no restart needed/wanted).

File

Addition

File

Addition

rate_limit.lua

write_attempt_event(), write_accepted_event(), resolve_account_id_for_logging() — see Section 4

rl_cache_sync.lua

new astpp_acct_ids mod_hash realm (account number → id, for every account, not just rate-limited ones)

astpp.xml.lua

tags each outbound leg with trunk_id, writes a "start" row + sets api_hangup_hook (legacy, Section 9)

trunk_hangup_hook.lua (new file)

writes the "end" row when api_hangup_hook fires (legacy, Section 9)

3.8 OpenSIPS statistics module

What it is: a one-line config addition turning on OpenSIPS's built-in statistics module, plus a counter increment in the routing script, so the true entry-point call rate can be measured — before any FreeSWITCH-side accept/reject decision happens at all.

Install/configure (/usr/local/etc/opensips/opensips.cfg):

loadmodule "statistics.so" modparam("statistics", "variable", "invite_attempts")

In route{}:

if (is_method("INVITE") && !has_totag()) { update_stat("invite_attempts", 1); }
opensips -f /usr/local/etc/opensips/opensips.cfg -C -w /tmp # validate first systemctl restart opensips # then apply

Read via opensips-cli -x mi get_statistics script: (polled by opensips_invite_stat_poll.py, Section 3.6).

3.9 PHP / CodeIgniter reporting module

What it is: the cc_cps_report module inside ASTPP's existing admin panel — a controller, a view, no new database of its own. It never talks to TimescaleDB; every data call goes through the Report API over HTTPS with a hard 3-second timeout.

Location: application/modules/cc_cps_report/ on the web host (95.217.8.48) — dropped into ASTPP's existing HMVC module directory, picked up automatically by the framework's module autoloader (no separate install step beyond placing the files and clearing any object cache).

Key files:

  • controllers/Cc_cps_report.php — account-scoping, proxying to the Report API, the superadmin-only top_customers()/top_carriers() actions.

  • views/view_cc_cps_report.php — the live panel, drill-down charts, and the two statistics tables.

4. How CPS data flows, end to end

Mermaid diagram
sequenceDiagram participant C as Caller participant OS as OpenSIPS participant FS as FreeSWITCH participant RL as rate_limit.lua participant Q as local queue files participant Cron as flush scripts (cron) participant API as Report API participant DB as TimescaleDB participant UI as Report page C->>OS: INVITE OS->>OS: update_stat(invite_attempts) OS->>FS: relay INVITE FS->>RL: rate_limit.lua runs (every call) RL->>Q: write_attempt_event() -> call_attempt_queue.log alt account is metered and over CC/CPS limit RL->>Q: write_reject_cdr() -> reject_cdr_queue.log else admitted (unmetered, or passed limits) RL->>Q: write_accepted_event() -> call_accepted_queue.log end Note over Cron,API: every ~10s within each 1-min cron tick Cron->>Q: read + atomically rotate queue file Cron->>API: POST /internal/ingest/attempts | accepted | rejections API->>DB: INSERT call_attempts / call_accepted / call_rejections Note over OS,API: every 1 min, independently OS->>API: entry_point_cps_samples (OpenSIPS MI stat delta) API->>DB: INSERT entry_point_cps_samples Note over FS,API: every 1 min, independently -- hangup-time, backdated FS->>API: admissions_poller.py (from MySQL cdrs) API->>DB: INSERT call_admissions UI->>API: GET /live, /cps/day|hour|minute API->>DB: SELECT (raw for live/minute/hour, continuous aggregate for day) API-->>UI: CPS numbers

Why there are three independent CPS sources feeding different parts of the report, not one:

  1. call_attempts/call_accepted (from rate_limit.lua) — the live number shown on the dashboard's top panel. Fires the instant FreeSWITCH first sees a call, for every account, metered or not.

  2. call_admissions (from MySQL cdrs, via admissions_poller.py) — feeds the 24-hour/historical drill-down charts. cdrs only gets a row at hangup, with its timestamp backdated to when the call started — fine for a chart you look at after the fact, useless as a live number.

  3. entry_point_cps_samples (from OpenSIPS) — the true call-attempt rate at the actual SIP entry point, before FreeSWITCH ever sees anything. Needed because roughly half of genuine attempts were found to never reach FreeSWITCH at all (auth/routing failures at the SIP proxy).

5. How CC data flows, end to end

Mermaid diagram
sequenceDiagram participant FS1 as FreeSWITCH media-1 participant FS2 as FreeSWITCH media-2 participant CCS as cc_sampler_daemon.py participant API as Report API participant DB as TimescaleDB participant UI as Report page loop every 1s, per metered account (~13 accounts) CCS->>FS1: limit_usage hash rl_cc <account> CCS->>FS2: (same, over WireGuard) end loop every 1s, isolated connection CCS->>FS1: show calls count CCS->>FS2: show calls count Note over CCS: summed as account_id = 0 sentinel = true platform-wide CC end loop every 5s, isolated connection CCS->>FS1: show channels as delim | CCS->>FS2: (same, over WireGuard) Note over CCS: regex-extract trunk_id=NNN from raw line, dedupe both legs via call_uuid end CCS->>API: POST /internal/ingest/cc_samples CCS->>API: POST /internal/ingest/trunk_cc_samples API->>DB: INSERT cc_samples / trunk_cc_samples UI->>API: GET /live (polled every few sec, never cached) API->>DB: SELECT most recent sample per account / trunk / platform API-->>UI: current_cc (dash when no sample exists at all)

Why CC uses direct polling instead of counting start/end events: an event-log approach (count a "start" on call setup, an "end" on hangup, concurrency = starts with no matching end yet) was tried first and abandoned — under real load, a meaningful fraction of "start" events never got a matching "end" (channel UUID reassignment during transfers, and separately, raw ESL event loss measured at ~73% under load). Both failure modes make an event-counted concurrency figure drift upward forever without ever correcting itself. Directly asking FreeSWITCH "what's live right now" every few seconds has no such drift — it's always a fresh, self-correcting snapshot. Full story in Section 11.

Why per-account CC is null/dash for almost every account: FreeSWITCH's own rl_cc counter (read via limit_usage) only exists for accounts with a configured CC/CPS limit — about 13 of ~120,000. For everyone else, there is no concurrency counter anywhere in FreeSWITCH to poll; the dashboard shows "—" rather than fabricate a "0" that would look like real data.

6. Database schema reference

Database: astpp_reports, PostgreSQL 16 + TimescaleDB, host 37.27.251.165. All hypertables partition on time.

6.1 Hypertables

Table

Columns

Fed by

Table

Columns

Fed by

call_admissions

time, account_id, server, duration_sec, disposition

admissions_poller.py

call_rejections

time, account_id, server, reason, destination, caller_id

reject_cdr_flush.py

cc_samples

time, account_id, server, current_cc, cc_limit

cc_sampler_daemon.py

call_attempts

time, account_id

call_attempt_flush.py

call_accepted

time, account_id

call_accepted_flush.py

entry_point_cps_samples

time, count

opensips_invite_stat_poll.py

trunk_attempts

time, trunk_id

trunk_attempt_flush.py

trunk_accepted

time, trunk_id

trunk_accepted_flush.py

trunk_channel_events

time, unique_id, trunk_id, event_type

trunk_channel_event_flush.py (legacy, Section 9)

trunk_cc_samples

time, trunk_id, server, current_cc

cc_sampler_daemon.py (sample_trunks())

6.2 Plain table

Table

Columns

Fed by

Table

Columns

Fed by

trunk_daily_totals

date, trunk_id, attempted, connected, updated_at (PK date, trunk_id)

trunk_daily_totals_poller.py (upsert)

6.3 Continuous aggregates — refresh bottom-up

Mermaid diagram
flowchart LR ca[call_admissions] --> cps1[cps_1s] cps1 --> cpsh[cps_hourly] cpsh --> cpsd[cps_daily] cc[cc_samples] --> cch[cc_hourly] cch --> ccd[cc_daily] cr[call_rejections] --> rh[rejections_hourly] rh --> rd[rejections_daily]

Aggregate

Refresh policy

Aggregate

Refresh policy

cps_1s

every 10s

cps_hourly

every 10 min

cps_daily

hourly

cc_hourly

every 10 min

cc_daily

hourly

rejections_hourly

every 10 min

rejections_daily

hourly

Refreshing a top-level aggregate does nothing if an intermediate one it depends on is stale — always refresh in the order shown above when manually catching up after a backfill.

7. Cron & systemd inventory

Systemd services:

Service

Host

Runs

Service

Host

Runs

astpp-report-api.service

37.27.251.165

Report API (uvicorn, 2 workers)

cc-sampler.service

media-1

cc_sampler_daemon.py

trunk-attempt-watcher.service

media-1

trunk_attempt_watcher.py

All three: Restart=always, RestartSec=5, CPU/memory caps so a runaway collector can never meaningfully compete with real call-routing threads.

Cron — media-1 and media-2 (identical):

*/15 * * * * fs_cli ... -x "luarun .../rl_cache_sync.lua" * * * * * /usr/local/bin/reject_cdr_flush.sh * * * * * /usr/local/bin/call_attempt_flush.sh * * * * * /usr/local/bin/trunk_attempt_flush.sh * * * * * /usr/local/bin/call_accepted_flush.sh * * * * * /usr/local/bin/trunk_accepted_flush.sh * * * * * /usr/local/bin/trunk_channel_event_flush.sh # legacy, Section 9

Cron — 37.27.251.165 (report host):

* * * * * /usr/local/bin/admissions_poller.py */5 * * * * /usr/local/bin/trunk_daily_totals_poller.py

Cron — 95.217.244.97 (OpenSIPS host):

* * * * * /usr/local/bin/opensips_invite_stat_poll.sh

8. Security model

  • Read vs. ingest keys are separate secrets. The portal holds only the read key; a leak of it can never be used to write fake data.

  • resolve_account_id() in the PHP controller is the single choke point for account scoping, re-validated against the session on every request — never cached, never trusts a client-supplied account_id directly.

  • Superadmin-only aggregate views. "All Customers" and the top-5 statistics tables have no account-override path at all.

  • The Report API is the only process that ever touches TimescaleDB, and binds only to the WireGuard interface — never a public IP.

9. Known limitations

  1. Per-account live CC exists for only ~13 of ~120,000 accounts — FreeSWITCH itself has no concurrency counter for unmetered accounts, so this isn't fixable at the reporting layer alone; it would need the same "ask FreeSWITCH directly" polling pattern used for trunks, extended to resolve an account_id per live channel (not yet built — see Section 10).

  2. Per-carrier CC will never sum close to the platform total, by design — only outbound legs routed to an external carrier ever carry a trunk_id at all; a live sample found only ~18% of channels do. Expected, not a bug.

  3. Historical/drill-down CPS is hangup-time data — a call still in progress has no call_admissions row yet.

  4. trunk_channel_events and its whole write path are dead code from the Report API's perspective — nothing reads it anymore, but the write path is still running. Harmless, but see Section 10.

10. Improvement roadmap

Ideas for follow-up work, not yet built or scheduled — in rough order of value-to-effort:

  1. Remove the dead trunk_channel_events path. Delete the hangup-hook lines from astpp.xml.lua, delete trunk_hangup_hook.lua, stop the flush cron entry, and eventually drop the table. Zero risk since nothing reads this data — but touches a live routing script, so should be confirmed with the user first.

  2. Extend direct-polling CC coverage to all accounts, not just the ~13 metered ones. The same show channels-based technique that drives per-trunk CC could resolve an account_id per live channel and give every account a real live CC number instead of "—". Highest-value remaining gap in the system.

  3. Historical backfill. No cdrs data before the system went live was ever backfilled — worth doing once if long-term trend charts become a priority.

  4. Alerting on collector health. Nothing currently pages anyone if cc-sampler.service or trunk-attempt-watcher.service silently stalls (this already happened once for ~39 hours, see Section 11). A simple staleness check would catch this class of bug far sooner.

  5. Redis-backed cache instead of in-process TTL cache for the Report API, if it's ever scaled beyond a single worker process pair.

  6. Formal retention/compression policy on the raw hypertables — nothing currently compresses or drops old raw rows.

11. Historical notes: bugs found, approaches tried and superseded

Kept here deliberately, not just in commit messages — these explain why the current design looks the way it does, and re-attempting a "simpler" version of an already-tried approach is a real risk without this context.

  • cc-sampler.service silently stale for ~39 hours. A per-account exception handler caught a dead-connection error in the same except clause as "this one account's reading was garbage," so it looped past a dead connection forever instead of reconnecting. Fixed by splitting connection-level errors from per-value parse errors. (Direct motivation for the alerting idea in Section 10.)

  • ESL response desync. Sharing one ESL connection across queries meant a slow reply under load left a stale reply in the buffer, silently consumed by the next unrelated command. Fixed with isolated connections per concern.

  • Admissions poller pointed at 127.0.0.1 after the Report API was locked to the WireGuard-only address — failed silently for hours; fixed URL, added batch chunking.

  • Continuous aggregate refresh order. Refreshing cps_hourly directly did nothing when the intermediate cps_1s was itself stale — must refresh bottom-up.

  • Exact-match disposition filter never matched real values (NORMAL_CLEARING vs. NORMAL_CLEARING [16]) — fixed to a LIKE match.

  • Browser caching of the /live poll — fixed with cache: no-store client-side and explicit no-cache headers server-side.

  • CPS window anchored to wall-clock now() almost-always read 0 once ingestion had any lag. Anchored to the freshest ingested timestamp instead.

  • Only 13 of ~120,056 accounts are covered by the rate-limiter cache at all — every other account's calls pass through completely untracked by design. Platform-wide totals had to be sourced from FreeSWITCH's own show calls count instead.

  • OpenSIPS entry-point gap. True entry-point CPS read roughly double what was reaching FreeSWITCH — about half of genuine attempts were absorbed (auth/routing failures) before FreeSWITCH ever saw them.

  • Trunk CC via ESL event subscription — tried, measured, and abandoned. The first implementation derived live per-trunk concurrency from an event log. Two failures were found, in order: (1) ESL event loss — a direct measurement using FreeSWITCH's own Event-Sequence numbers proved ~73% of relevant events never reached the watcher under real load. (2) Fixing reliability (in-process file write + native api_hangup_hook, not event-bus-based) still wasn't enough — the majority of "start" events never got a matching "end" in further testing, most likely from FreeSWITCH reassigning a channel's UUID mid-call during transfer/park/diversion flows. Resolution: abandoned event-correlation for this metric entirely — cc_sampler_daemon.py now directly polls FreeSWITCH's live channel list every 5 seconds instead.