Skip to content

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 = true at 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 = false is 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 true on 2026-04-18 via api/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:

  1. Local stitched cache/var/spool/asterisk/monitor/stitched/<linkedid>.wav, built by the hourly cron.
  2. Firebase stitched archivefirebase:<bucket>/astra_pbx/recordings/stitched/<linkedid>.wav, streamed via rclone cat.
  3. On-demand rebuild — resolves each leg locally or via rclone copyto from Firebase, then runs ffmpeg concat filter and 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_cdr for 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:

  1. Pre-stitch multi-leg calls (see above) while source legs are still local.
  2. rclone copy /var/spool/asterisk/monitor/stitched/firebase:<bucket>/astra_pbx/recordings/stitched/. Uses copy (not move) so the local cache stays for fast playback.
  3. rclone move --max-depth 1 per-leg WAVs from the flat monitor dir → firebase:<bucket>/astra_pbx/recordings/. The depth cap keeps rclone out of the stitched/ 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.

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