Skip to Content
Internal docs are powered by Nextra Docs Theme.
SystemsPlatformAPI2Rails to API2 migrationPackagesNative call-recording attachment

Native call-recording attachment

API2 can download provider call recordings, write the Rails-compatible ConversationPartCallDetail recording Active Storage rows, and enqueue call transcription. The call_recording_attachment_native PostHog flag is default off and fail-closed to the existing Rails internal endpoint.

Contract

The native path validates the organization and call detail with an explicit organization fence, returns the existing recording attachment without remote work, validates Zendesk recording hosts and encrypted access tokens, downloads with a 60-second deadline, 256 MiB body cap, and five manual redirects, then writes the object and Rails Active Storage rows in the shared cloudflare service. Remote storage and queue work never runs inside a database transaction. A FOR UPDATE re-check makes mixed-version races idempotent.

Automatic-policy calls enqueue the deterministic BullMQ call job using the conversation_part_call_detail and conversation prefix IDs. An enqueue failure after commit is logged and reported as attached_transcription_enqueue_failed; it does not roll back the attachment. Structured native_call_recording_attachment logs never include recording URLs, credentials, headers, or bodies.

Object lifecycle / orphan recovery

API2 inserts a call_recording_upload_intents row before writing the recording object. The intent therefore remains the durable owner if the process stops after upload or an inline cleanup attempt fails. uploading means the upload has not completed, attached means the Active Storage blob owns the object, cleanup_pending means the object must be deleted, cleaned means deletion succeeded, and abandoned means the five-attempt retry budget was exceeded and the row needs reconciliation.

A maintenance worker sweeps these intents every five minutes. Uploads older than 15 minutes are considered stale; if no matching Active Storage blob exists, the object is deleted. Cleanup retries run up to five attempts, and all storage calls remain outside database transactions.

The following read-only query lists orphan candidates for reconciliation:

SELECT id, rails_organization_id, conversation_part_call_detail_id, bucket, object_key, status, attempts, last_error, created_at, updated_at FROM public.call_recording_upload_intents WHERE status IN ('cleanup_pending', 'abandoned') OR (status = 'uploading' AND created_at < now() - interval '15 minutes') ORDER BY created_at;

Deviations

  • Blob metadata is {identified:true, analyzed:false} and no Rails analyze job is scheduled.
  • Native downloads are bounded to 60 seconds, 256 MiB, and five redirects; Rails/Faraday was unbounded. Empty responses are rejected.
  • Transcription always uses the API2 BullMQ call queue. The Rails api2_conversation_transcription gate is 100%, so the GoodJob fallback is not reproduced.
  • Post-commit enqueue failure relies on existing untranscribed-recorded-call reconciliation rather than a GoodJob retry.

Rollback

The flag call_recording_attachment_native defaults off and fails closed to Rails. CALL_RECORDING_ATTACHMENT_OWNER=rails forces Rails per deployment. The Rails route/controller is untouched. Rollback is flag-off (within the approximately 20-second flag cache), with no data migration because both writers produce identical Active Storage rows in the same cloudflare bucket.

During mixed versions, both owners may handle one call detail. The native FOR UPDATE re-check and Rails attached? check make the loser report already_attached; the deterministic BullMQ job ID (conversation_transcription_call_<runId>_<callDetailId>) collapses duplicate transcription dispatch.

Production rollout plan

Baseline (rolling 24 hours ending 2026-09-17T23:18Z): 2,741 Rails Internal:: ConversationPartCallDetailRecordingsController#create calls (2,577 201, 162 200, 2 502), concentrated in EU; approximately 16,146 calls / 7 days. Callers are Aircall import, provider-sync native effects, and Zendesk/Aircall matching (QA recovery reaches this path through provider sync).

  1. Deploy with the flag off.
  2. Enable for one internal EU organization.
  3. Reconcile 24 hours per region, then widen gradually.
  4. Hold zero-Rails windows for 24 hours and seven days before proposing a separate package to remove the Rails route.

Compare native_call_recording_attachment outcome distributions by region to the Rails baseline (201 ≈ attached, 200 ≈ already_attached, 502 ≈ download_failed).

Read-only reconciliation queries

-- Native vs Rails attachment volume proxy: recording attachments created per hour (both owners write the same rows) SELECT date_trunc('hour', a.created_at) AS hour, count(*) AS attachments FROM public.active_storage_attachments a WHERE a.record_type = 'ConversationPartCallDetail' AND a.name = 'recording' AND a.created_at >= now() - interval '24 hours' GROUP BY 1 ORDER BY 1; -- Orphaned recording blobs (object written, no attachment): must stay at zero SELECT b.id, b.key, b.created_at FROM public.active_storage_blobs b WHERE b.filename LIKE 'call_recording_%' AND b.created_at >= now() - interval '24 hours' AND NOT EXISTS (SELECT 1 FROM public.active_storage_attachments a WHERE a.blob_id = b.id) ORDER BY b.created_at; -- Duplicate recording attachments on one call detail: must be zero SELECT a.record_id, count(*) FROM public.active_storage_attachments a WHERE a.record_type = 'ConversationPartCallDetail' AND a.name = 'recording' GROUP BY a.record_id HAVING count(*) > 1; -- Attached under automatic policy but never transcribed (enqueue gap candidates) SELECT cd.id, cd.organization_id, a.created_at FROM public.active_storage_attachments a JOIN public.conversation_part_call_details cd ON cd.id = a.record_id JOIN public.organizations o ON o.id = cd.organization_id WHERE a.record_type = 'ConversationPartCallDetail' AND a.name = 'recording' AND a.created_at BETWEEN now() - interval '24 hours' AND now() - interval '1 hour' AND o.call_transcription_policy = 'automatic' AND cd.transcribed_at IS NULL AND cd.transcription_failed_at IS NULL ORDER BY a.created_at;

Local checks

cd rulebase-web/rulebase-api2 pnpm vitest run src/lib/call-recordings src/lib/provider-conversation-sync/native-effects-call-recordings.test.ts src/lib/aircall src/lib/zendesk-aircall-call-matching src/lib/qa-sampling-recovery pnpm typecheck
Last updated on