Call Recording¶
Recording is mandatory by default for every organisation and every call path. This page documents the policy, the generator-level wiring, the multi-leg stitching pipeline, and the Google Cloud archive.
Policy¶
- Every new organisation gets
settings.recording_enabled = trueat creation time — for both self-serve and admin-direct-create paths. - Every new outbound route, DID, queue, and user row defaults to
recording_enabled = 1. - Per-row
recording_enabled = falseis still a valid opt-out, but the default is ON. Tenants who want to disable recording for a specific queue/DID/user must flip the flag explicitly. - Existing orgs were backfilled to
trueon 2026-04-18 viaapi/database/migrations/20260418-recording-mandatory-default.sql.
Where recording fires in the dialplan¶
The dialplan generator (api/src/services/asterisk/dialplanGenerator.js) emits a MixMonitor() at four points:
| Call path | Context | Generator line (approx) |
|---|---|---|
| User-extension ring (inbound to ext) | [<prefix>__internal] | user.recording_enabled !== false && orgRecording |
| DID inbound leg | [<prefix>__incoming_sub] | did.recording_enabled !== false && orgRecording |
Outbound route _X. pattern | [<prefix>__outbound] | route.recording_enabled !== false && orgRecording |
| Queue leg | [<prefix>__queue] | queue.recording_enabled !== false && orgRecording |
All four paths gate on org.settings.recording_enabled !== false — flipping the org master to false disables recording across every path in one shot.
Each MixMonitor call writes to /var/spool/asterisk/monitor/<timestamp>-<src>-<ext>.wav and sets CDR(recordingfile) so the Call Logs page can render the player.
Multi-leg calls: stitching¶
A single real-world call typically produces 2–3 CDR rows because Asterisk emits one per channel leg:
| Leg | Example file |
|---|---|
| DID inbound | 20260418-144004-919944421125-08065978002.wav |
| Queue | 20260418-144009-919944421125-queue-5001.wav |
| Internal dial to ext | 20260418-144021-919944421125-1003.wav |
The /api/v1/calls endpoint collapses legs to one row per linkedid (longest duration wins). The UI's audio player previously served only that one leg — a fragment of the full conversation.
What now happens: GET /api/v1/calls/:callId/recording resolves the call's linkedid, finds every sibling leg with the same accountcode, orders them chronologically, and concatenates them into one playable WAV via ffmpeg -filter_complex concat.
3-tier fallback¶
The endpoint picks the cheapest viable source in this order:
- Local stitched cache —
/var/spool/asterisk/monitor/stitched/<linkedid>.wav, built by the hourly cron. - Firebase stitched archive —
firebase:<bucket>/astra_pbx/recordings/stitched/<linkedid>.wav, streamed viarclone cat. - On-demand rebuild — resolves each leg locally or via
rclone copytofrom Firebase, then runsffmpeg concat filterand caches the result.
Response headers expose which path fired:
X-Recording-Legs: <n>X-Recording-Source: stitched-local | stitched-remote | stitched-ondemand
Hourly pre-stitching cron¶
api/scripts/stitch-recordings.js runs at the top of each hour as the first step of scripts/move-recordings.sh. It:
- Scans
asterisk_cdrfor linkedids in the last 25 h that have 2+ recordingfile rows. - Skips linkedids whose stitched output is already newer than every leg (idempotent).
- Writes the result to
/var/spool/asterisk/monitor/stitched/<linkedid>.wav.
Source-leg cache (when fetching from Firebase) lives in /var/spool/asterisk/stitch-src/ — a sibling of the monitor directory so the flat rclone move in step 3 of the cron can't accidentally sweep it up.
Google Cloud archival¶
api/scripts/move-recordings.sh runs hourly via cron. Order matters:
- Pre-stitch multi-leg calls (see above) while source legs are still local.
rclone copy/var/spool/asterisk/monitor/stitched/→firebase:<bucket>/astra_pbx/recordings/stitched/. Usescopy(notmove) so the local cache stays for fast playback.rclone move --max-depth 1per-leg WAVs from the flat monitor dir →firebase:<bucket>/astra_pbx/recordings/. The depth cap keeps rclone out of thestitched/subdir.
Retention: per-leg files are moved (deleted from local after upload); stitched files stay on local disk indefinitely as a cache.
Single-leg calls¶
Unchanged behaviour — no ffmpeg cost. The endpoint short-circuits if only one leg has a recording.
Self-healing config (related)¶
The same deploy pipeline runs pruneStaleIncludesFromFile before adding the per-org #include, so a deleted org's stale reference can never cause sorcery parse errors that would block a new org's endpoints from loading. See configDeploymentService.js:ensureIncludesInMainConfigs.
Key files¶
| File | Role |
|---|---|
api/src/services/asterisk/dialplanGenerator.js | Emits MixMonitor on all four paths |
api/src/server.js (create-org, POST /dids, POST /queues, POST /outbound-routes) | Defaults recording_enabled=true on new rows |
api/src/models/{Organization,DidNumber,Queue,OutboundRoute}.js | Model defaults set to true |
api/database/migrations/20260418-recording-mandatory-default.sql | Backfill existing rows |
api/scripts/stitch-recordings.js | Hourly pre-stitcher |
api/scripts/move-recordings.sh | Orchestrates stitch → Firebase copy → Firebase move |
api/src/server.js GET /calls/:callId/recording | 3-tier stitched playback endpoint |