数据对账系统 Runbook

last-verified: 2026-07-14(对照 infra PR #1459;发现和代码不符以代码为准)

⚠️ 两处以代码为准的关键更正(见对应 section 内联说明):

  1. Metric 已改名(#775):ReconciliationCase{1..4,D}CallsMissingInDB / CallsRecordingNotDownloaded / CallsTranscriptionNotStarted / CallsTranscriptionNotCompleted / CallsOrphaned。下文旧名保留作历史对照,查 CloudWatch 用新名
  2. 对账不用 AWS Transcribe:所有修复都是「重置 flag + 往 transcribe-queue 重投 synthetic webhook」,走正常 Deepgram 路径(reconciliation-worker 无任何 transcribe:* SDK 调用)。旧文档里 aws transcribe get/delete-transcription-job 命令已废弃。
  3. Schedule 当前不是自动运行:live CDK 在所有 environments 都配置 state: 'DISABLED'。本页下方的 “Every 3 hours / auto-enabled” 是历史设计说明,不得当作当前 safety net。另见 RingCentral Call Log、录音就绪与 Rate Limit 调查

System Overview

Purpose

The reconciliation system detects and fixes data inconsistencies between RingCentral (source of truth) and DynamoDB (local state) by periodically scanning for missing or incomplete call records.

What It Does:

  • Identifies calls that exist in RingCentral but are missing from DynamoDB
  • Detects calls stuck at various processing stages (recording download, transcription)
  • Fixes issues automatically by replaying the processing pipeline
  • Prevents data loss from webhook delivery failures or transient errors

When It Runs:

  • Automated: Every 3 hours via EventBridge schedule
  • Manual: On-demand via Lambda test event or CLI
  • Emergency: After major incidents or data integrity concerns

Architecture Evolution

V2.5.0 (November 2025): SQS Fan-Out Architecture

Before (V2.4): Orchestrator processed all actions in single invocation

  • Problem: 37+ RingCentral API calls per client in single Lambda
  • Impact: Rate limit storms, 429 errors, failed reconciliations

After (V2.5.0): SQS fan-out with wide-spread retry

EventBridge (Every 3h)

Orchestrator Lambda
  ├─ Scan DynamoDB for active clients
  ├─ Fetch RingCentral calls (1 API call per client)
  └─ Generate SQS messages (1 message per action)

SQS Queue (reconciliation-worker-queue)
  ├─ Random delays: 0-10 min (State 1)
  └─ Random delays: 5-15 min (State 2-4)

Worker Lambda (Concurrency: 2)
  ├─ Process 1 action at a time
  └─ Send synthetic webhooks to transcribe-queue

Performance Impact:

  • 97% API Call Reduction: 37+ calls → 1 call per client
  • Rate Limit Prevention: Wide-spread retry (2-15 min random delays)
  • Independent Retry: Each action can retry independently via SQS
  • Concurrency Control: Reserved concurrency = 2 prevents API storms

V2.5.3 (November 2025): Auto-Enable in Dev/Test

Schedule State:

  • Production: Disabled by default (manual enable required for safety)
  • Dev/Test: Auto-enabled (no manual CLI step needed)
  • Benefit: Eliminates "enable schedule after deploy" operational step

How to Override:

# Disable in dev/test (emergency stop)
aws scheduler update-schedule \
  --name call-analytics-pre-reconciliation \
  --state DISABLED

# Enable in prod (after validation)
aws scheduler update-schedule \
  --name call-analytics-prod-reconciliation \
  --state ENABLED

Reconciliation States (V2.4.1 Migration)

The system uses a 4-state model to classify call record issues:

StateConditionActionMetric NameDashboard Widget
State 1Call exists in RingCentral but missing in DynamoDBCreate DB record + download recordingReconciliationCase1Row 9 Widget 1
State 2Call in DB but recording not downloaded (>15 min)Download recording from RingCentralReconciliationCase2Row 9 Widget 2
State 3Recording downloaded but transcription not started (>30 min)Start AWS Transcribe jobReconciliationCase3Row 9 Widget 3
State 4Transcription started but not completed (>24 hours)Check status, retry if failedReconciliationCase4Row 9 Widget 4
Case DRecord in DynamoDB but deleted from RingCentral (>90 days)Mark as orphaned (no action)ReconciliationCaseDRow 9 Widget 5

Legacy Model (Deprecated in V2.4.1):

  • Case A → State 1 (missing in DB)
  • Case B + C → State 4 (transcription stuck)
  • Case D → Unchanged (orphaned records)

Migration Timeline:

  • Code Updated: V2.4.1 (November 28, 2025)
  • Metrics Changed: CallAnalyticsCallAnalytics/RingCentral namespace
  • Dashboard Updated: Row 9 added for per-client case breakdown

Key Characteristics

Orchestrator Lambda

  • Runtime: Node.js 20
  • Memory: 256 MB
  • Timeout: 1 minute
  • Concurrency: Unreserved (auto-scales with schedule)
  • Trigger: EventBridge schedule (cron: 0 */3 * * ? *)
  • Schedule State: Auto-enabled (dev/test), Disabled (prod)
  • Metrics Namespace: CallAnalytics/RingCentral

Worker Lambda

  • Runtime: Node.js 20
  • Memory: 512 MB
  • Timeout: 15 minutes
  • Concurrency: Reserved 2 (prevents RingCentral 429 errors)
  • Trigger: SQS (reconciliation-worker-queue)
  • Batch Size: 1 message at a time (sequential processing)
  • DLQ: reconciliation-worker-dlq (3 retries before DLQ)
  • Metrics Namespace: CallAnalytics/RingCentral

Reconciliation Window

  • Default: 6 hours (configurable via RECONCILIATION_WINDOW_HOURS env var)
  • Rationale:
    • 2x processing SLA headroom (normal processing: ~3 hours)
    • Minimizes duplicate API calls (50% reduction vs 7-day window)
    • Balances freshness vs API cost
  • Frequency: Every 3 hours (2 overlapping scans per window)
  • Maximum: 90 days (RingCentral retention period)

Dashboard Integration

Primary Monitoring (Row 9: Per-Client Reconciliation)

Dashboard Row 9 provides per-client visibility into reconciliation case breakdown:

WidgetMetricWhat It MeasuresHealthy StateWarning ThresholdCritical Threshold
Row 9 Widget 1ReconciliationCase1Calls missing in DynamoDB0 or <5 per client>10 per client>50 per client
Row 9 Widget 2ReconciliationCase2Recordings not downloaded0>5 per client>20 per client
Row 9 Widget 3ReconciliationCase3Transcriptions not started0>5 per client>20 per client
Row 9 Widget 4ReconciliationCase4Transcriptions stuck/failed0 or <3 per client>5 per client>15 per client
Row 9 Widget 5ReconciliationCaseDOrphaned records0>10 per client>20 per client
Row 9 Widget 6Total (all states)Total issues across all clients0>50 total>100 total

Dashboard Features:

  • Stacked Area Charts: Visualize trends over time (last 3 hours by default)
  • ClientId Dimension: Drill down to specific clients (e.g., orangeTheory-2c9fc00886d14b9a9a24a12d337c438c)
  • Color Coding: GREEN (State 1-3), BROWN (State 4, Case D)
  • Auto-Refresh: Updates every minute

Supporting Metrics (Other Dashboard Rows)

RowWidgetMetricPurpose
Row 11LeftLambda ErrorsOrchestrator/Worker errors
Row 11RightLambda ThrottlesConcurrency limit reached
Row 12LeftRateLimitCapacityRingCentral API headroom
Row 12RightApiCallsPerMinuteAPI call trending

Health Check Guide

✅ Healthy System

Row 9 Widget 6: Total issues = 0 or trending down
Row 11 Left: No errors in last 3 hours
Row 12 Left: RateLimitCapacity >5
Row 12 Right: ApiCallsPerMinute stable (<10/min)

⚠️ Warning (Investigate)

Row 9 Widget 1: ReconciliationCase1 >10 (backlog forming)
Row 9 Widget 4: ReconciliationCase4 >5 (transcription failures)
Row 12 Left: RateLimitCapacity <3 (approaching rate limit)
Row 11 Left: 1-2 errors in last hour (isolated failures)

Actions:

  1. Check CloudWatch Logs Insights for error patterns
  2. Review per-client breakdown (click widget → "View in metrics")
  3. Monitor for trend continuation (is it getting worse?)
  4. Prepare for manual intervention if critical threshold reached

🚨 Critical (Immediate Action Required)

Row 9 Widget 1: ReconciliationCase1 >50 (major sync gap)
Row 9 Widget 6: Total issues >100 (widespread problem)
Row 9 Widget 5: CaseD >20 (data integrity issue)
Row 11 Left: >3 errors in 1 hour (systemic failure)
Row 12 Left: RateLimitCapacity <1 (rate limit imminent)

Actions:

  1. Disable schedule immediately (prevent more failures)
  2. Check RingCentral service status (outage?)
  3. Review last deployment (recent change?)
  4. Follow runbook for specific error pattern
  5. Escalate to engineering if root cause unclear

Per-Client Drill-Down

Dashboard Row 9 widgets include ClientId dimension for client-specific investigation:

Step 1: Identify Affected Clients

1. Navigate to CloudWatch Dashboard → Row 9
2. Click any widget (e.g., ReconciliationCase1)
3. Click "View in metrics"
4. Add dimension filter: ClientId
5. Sort by "Maximum" value descending

Example Output:

ClientId: orangeTheory-2c9fc00886d14b9a9a24a12d337c438c → 23 issues
ClientId: planetFitness-5f8a7b9c0d1e2f3g4h5i6j7k8l9m0n1o → 12 issues
ClientId: golds-gym-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 → 5 issues

Step 2: Check Time-Series Trend

1. Select specific ClientId dimension
2. Change statistic to "Sum" (total issues)
3. Adjust time range to "Last 24 hours"
4. Look for patterns:
   - Sudden spike? → Recent incident
   - Gradual increase? → Slow degradation
   - Flat/decreasing? → Self-healing

Step 3: Cross-Reference with Logs

Use CloudWatch Logs Insights to investigate specific client:

fields @timestamp, reconciliationCase, action, reason
| filter client_id = "orangeTheory-2c9fc00886d14b9a9a24a12d337c438c"
| filter @message like /Reconciliation completed/
| sort @timestamp desc
| limit 20

告警语义(谁 page 人)

日志级别 ≠ 告警级别。PROD 页人靠 CloudWatch alarm → SNS → alarm-dispatcher Lambda → Lark,不靠 Lambda logger.errorlogger.error 只到 CloudWatch + Sentry,不发 Lark;只有 logger.alert 直接发 Lark)。以 lib/stacks/monitoring-stack.ts 为准。

Reconciliation 相关 alarm:

AlarmTriggerPages 人?说明
ReconciliationDLQAlarmreconciliation-dlq-alarmDLQ ≥5 条client 处理 3 次重试后仍失败才进 DLQ
ReconciliationQueueAgeAlarmreconciliation-queue-age-alarm队列最老消息 >2hworker 卡住但没报错(并发耗尽 / Neon 慢 / AI 限流)—— DLQ 告警只抓「失败」,这个抓「停滞」
ReconciliationWorkerErrorRateAlarmerror rate >10%(30min)❌ 否仅 dashboard/ticket 诊断信号,不 page(源码:queue age 和 DLQ 才是 pageable 症状)

收到告警怎么办 → DLQ / queue-age 的响应 step 统一在 infra repo 的 Pipeline Monitoring Runbook + Queue age 维护,这里不重复。


Common Issues

Issue 1: ReconciliationCase1 Increasing (Missing Calls)

Symptoms

  • Dashboard: Row 9 Widget 1 showing ReconciliationCase1 >10
  • Dashboard: Row 9 Widget 6 total issues trending up
  • Metrics: ReconciliationCase1 dimension ClientId identifies specific clients
  • Logs: Messages like State 1: Missing in DB in worker logs

Investigation

Step 1: Identify Affected Clients

# CloudWatch Logs Insights - Find clients with State 1 issues
fields @timestamp, client_id, reconciliationCase
| filter @message like /State 1: Missing/
| stats count() as MissingCallCount by client_id
| sort MissingCallCount desc
| limit 20

Expected Output:

client_id                                            MissingCallCount
orangeTheory-2c9fc00886d14b9a9a24a12d337c438c       23
planetFitness-5f8a7b9c0d1e2f3g4h5i6j7k8l9m0n1o      12

Step 2: Check RingCentral API Health

# Dashboard Row 12: Rate limit capacity
# Look for: RateLimitCapacity trending down or <3

# CloudWatch Logs Insights - Check for 429 errors
fields @timestamp, client_id, error
| filter @message like /429/ or @message like /rate limit/
| stats count() by client_id, bin(1h)

Step 3: Check Webhook Delivery

# TranscribeProcessor logs - Verify webhooks are being received
fields @timestamp, client_id
| filter @message like /webhook received/
| stats count() as WebhookCount by client_id, bin(1h)
| sort @timestamp desc

Root Cause Analysis

Scenario A: Webhook Delivery Failure

  • Symptoms: Calls made but webhooks not received → Reconciliation detects and backfills
  • Dashboard: Row 1 Left (TranscribeProcessor) shows low throughput
  • Investigation: Check API Gateway metrics for 4xx/5xx errors on webhook endpoint
  • Expected Behavior: Reconciliation self-heals by creating missing records
  • Fix: Usually self-heals within 3 hours. If persistent, contact RingCentral support for webhook delivery investigation.

Scenario B: TranscribeProcessor Errors

  • Symptoms: Webhooks received but processing failed → DLQ accumulation
  • Dashboard: Row 1 Left shows DLQ depth >0
  • Investigation:
    # Check TranscribeProcessor errors
    fields @timestamp, client_id, error
    | filter ispresent(error)
    | filter @message like /TranscribeProcessor/
    | stats count() by error
  • Fix: Follow transcribe-processor.md#dlq-recovery

Scenario C: Calls Without Recordings

  • Symptoms: RingCentral calls exist without recordings (e.g., voicemail, abandoned calls)
  • Dashboard: ReconciliationCase1 count matches calls without recordings
  • Investigation:
    # Check hasRecording field in logs
    fields @timestamp, client_id, hasRecording
    | filter @message like /Call exists.*without recording/
    | stats count() by hasRecording
  • Expected Behavior: V2.5.1+ creates audit records with hasRecording: false (not an error)
  • Fix: No action needed - these are processed correctly

Scenario D: EventBridge Schedule Disabled

  • Symptoms: ReconciliationCase1 increasing over multiple days (no automatic cleanup)
  • Investigation: Check schedule state
    aws scheduler get-schedule \
      --name call-analytics-prod-reconciliation \
      --query 'State'
  • Fix: Enable schedule (see Operational Procedures)

Resolution

Automatic (Recommended):

  • Monitor Row 9 Widget 1 for trend reversal (should decrease within 3-6 hours)
  • Reconciliation runs every 3 hours and auto-fixes State 1 issues
  • If trending down, no action needed

Manual (Emergency):

# Trigger immediate reconciliation for specific client
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"execute"}' \
  /tmp/response.json

# Monitor progress
tail -f /tmp/response.json

Preventive:

  • Ensure EventBridge schedule is enabled (auto-enabled in dev/test as of V2.5.3)
  • Monitor webhook delivery success rate
  • Set up alerts for ReconciliationCase1 >10 per client

Issue 2: ReconciliationCase4 Persistent (Transcription Stuck)

Symptoms

  • Dashboard: Row 9 Widget 4 showing ReconciliationCase4 >5
  • Metrics: ReconciliationCase4 indicates transcriptions not completing
  • Duration: Issues persisting >24 hours
  • Logs: Messages like State 4: Transcription not completed

Investigation

Step 1: Identify Stuck Transcriptions

# CloudWatch Logs Insights - Find State 4 issues
fields @timestamp, client_id, transcriptionJobName, reason
| filter @message like /State 4: Transcription not completed/
| sort @timestamp desc
| limit 50

Expected Output:

@timestamp                    client_id                  transcriptionJobName              reason
2025-11-30T14:30:00Z         orangeTheory-abc123        transcribe-job-xyz123             Stuck for >24h

Step 2: 确认转录卡在哪一步(不是查 AWS Transcribe job)

⚠️ 转录早已不是 AWS Transcribe,没有 aws transcribe get-transcription-job 可查。State 4 的修复方式是 worker 重置 flag + 往 transcribe-queue 重投 synthetic webhook,重新走 Deepgram(handleCaseBreconciliation-worker/src/handler.ts:752)。State 4 的 transcriptionJobName 只是内部标识符。

# CloudWatch Logs Insights — 看这个 session 在 transcribe-processor 侧的转录记录
fields @timestamp, session, provider, @message
| filter session = "xyz123"
| filter @message like /Transcription/ or @message like /Deepgram/ or @message like /Gemini/
| sort @timestamp desc

Step 3: Check DynamoDB State

# CloudWatch Logs Insights - DB 里 transcriptionStarted / transcriptionCompleted 状态
fields @timestamp, telephonySessionId, transcriptionStarted, transcriptionCompleted
| filter @message like /transcriptionJobName/
| filter transcriptionJobName = "xyz123"

Root Cause Analysis

Scenario A: 转录反复失败(Deepgram)

  • Symptoms: transcribe-processor 侧持续 Deepgram failed / DEEPGRAM_SUSTAINED_FAILURE
  • Dashboard: CallsTranscriptionNotCompleted(旧名 ReconciliationCase4)与失败量对应
  • Common Failure Reasons:
    • Deepgram API key 失效 / secret 缺失
    • 录音无法访问(S3 IAM)或音频损坏
    • Deepgram 服务 outage
  • Fix:

Scenario B: DynamoDB State Desync(transcript 已产出但 DB 没更新)

  • Symptoms: S3 里已有 transcript,但 transcriptionCompleted = false in DB
  • Investigation: Check ai-analysis-processor (result processor) logs
    fields @timestamp, telephonySessionId, error
    | filter telephonySessionId = "xyz123"
    | filter @message like /ResultProcessor/ or @message like /ai-analysis/
  • Fix: Manual DB update or re-trigger downstream processing
    # Option A: Manual DB update (emergency only)
    aws dynamodb update-item \
      --table-name call-analysis \
      --key '{"telephonySessionId": {"S": "xyz123"}}' \
      --update-expression "SET transcriptionCompleted = :true" \
      --expression-attribute-values '{":true": {"BOOL": true}}'
    
    # Option B: Re-inject webhook 让 transcribe-processor 重跑(safer)

Scenario C: Deepgram 服务 outage

  • Symptoms: 多个 session 同时卡住,transcribe-processor 侧 DEEPGRAM_SUSTAINED_FAILURE
  • Dashboard: CallsTranscriptionNotCompleted 跨多 client spike
  • Investigation: 查 Deepgram 服务状态(https://status.deepgram.com)
  • Fix: 等服务恢复;reconciliation 会自动重投重试

Resolution

Automatic (Recommended):

  • Reconciliation Worker 每 3 小时重试 State 4(重置 flag + 重投 webhook → 重新 Deepgram 转录)
  • Monitor Row 9 Widget 4(CallsTranscriptionNotCompleted)for trend (should decrease within 6-9 hours)

Manual (If persistent >12 hours):

# Re-trigger reconciliation for specific client
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"execute"}' \
  /tmp/response.json

# 无需 cancel AWS Transcribe job(不存在)—— reconciliation 检测到未完成会自动重投 webhook 重跑

Issue 3: CaseD Spike (Orphaned Records)

Symptoms

  • Dashboard: Row 9 Widget 5 showing ReconciliationCaseD >20
  • Metrics: ReconciliationCaseD indicating orphaned records
  • Logs: Messages like Case D: Orphaned Record

Investigation

Step 1: Identify Orphaned Records

# CloudWatch Logs Insights - Find Case D issues
fields @timestamp, client_id, telephonySessionId, reason
| filter @message like /Case D: Orphaned/
| stats count() by client_id
| sort count desc

Step 2: Verify RingCentral Deletion

# Check if call still exists in RingCentral (should return 404)
aws lambda invoke \
  --function-name ReconciliationWorker \
  --payload '{"client_id": "orangeTheory-abc123", "mode": "dry_run"}' \
  /tmp/response.json

# Check logs for RingCentral API response
# Expected: "Call not found" or 404 error

Step 3: Check Record Age

# CloudWatch Logs Insights - Check how old orphaned records are
fields @timestamp, telephonySessionId, lastUpdated, callStartTime
| filter @message like /Case D: Orphaned/
| sort @timestamp desc

Root Cause Analysis

Scenario A: Normal RingCentral Retention (Expected)

  • Symptoms: Records >90 days old being marked as orphaned
  • Dashboard: CaseD count stable, not increasing rapidly
  • Explanation: RingCentral deletes calls after 90 days (retention policy)
  • Fix: No action needed - this is expected behavior
  • Note: DynamoDB records are marked as orphaned but NOT deleted (audit trail)

Scenario B: Bulk RingCentral Account Deletion

  • Symptoms: Sudden spike in CaseD across all clients or specific client
  • Dashboard: CaseD increased by >50 in single reconciliation run
  • Investigation: Contact RingCentral support to verify account status
  • Fix: If account was deleted intentionally, mark all records as orphaned (permanent)

Scenario C: DynamoDB Test Data

  • Symptoms: CaseD spike after dev/test environment migration
  • Dashboard: CaseD only in dev/test, not prod
  • Explanation: Test data created manually without corresponding RingCentral calls
  • Fix: Purge test data from DynamoDB
    # List orphaned records
    aws dynamodb scan \
      --table-name call-events \
      --filter-expression "attribute_exists(reconciliationAttempted) AND reconciliationSuccess = :false" \
      --expression-attribute-values '{":false": {"BOOL": false}}' \
      --projection-expression "telephonySessionId"
    
    # Delete in batches (carefully!)
    # Use batch-write-item with DeleteRequest

Resolution

Normal Behavior:

  • CaseD count <20 is normal (gradual accumulation from 90-day retention)
  • No action needed - records are marked as orphaned for audit trail

Abnormal Spike:

1. Investigate sudden increase (check RingCentral account status)
2. Verify records are truly orphaned (cross-check with RingCentral)
3. If confirmed, mark as permanently failed in DynamoDB
4. Update alert threshold if baseline changed

Issue 4: ReconciliationWorker DLQ Messages

Symptoms

  • Dashboard: Row 1 (SQS) shows DLQ depth >0 for reconciliation-worker-dlq
  • Alarms: reconciliation-dlq-alarm triggered (>5 messages)
  • Logs: Messages failing after 3 retries

Investigation

See detailed troubleshooting guide: reconciliation/troubleshooting.md#scenario-1-messages-stuck-in-dlq

Quick Diagnosis:

# Check DLQ depth
aws sqs get-queue-attributes \
  --queue-url https://sqs.{region}.amazonaws.com/{account}/call-analytics-{env}-reconciliation-worker-dlq \
  --attribute-names ApproximateNumberOfMessages

# Read DLQ messages
aws sqs receive-message \
  --queue-url https://sqs.{region}.amazonaws.com/{account}/call-analytics-{env}-reconciliation-worker-dlq \
  --max-number-of-messages 10 > dlq-messages.json

# Analyze error patterns
cat dlq-messages.json | jq '.Messages[].Body | fromjson'

Common Error Patterns:

ErrorCauseFix
Secret not foundMissing secret_name in DynamoDB configAdd field to client config
AccessDeniedExceptionIAM policy missing secret patternUpdate IAM policy
429 Too Many RequestsRate limit exceededAlready handled by 2-10 min retry
Invalid JSON in secretCorrupted OAuth tokenRun token refresher Lambda
Client configuration not foundDynamoDB record missingVerify client in config table

Resolution

Option A: Fix Root Cause and Re-drive

# After fixing root cause, re-drive messages back to main queue
aws sqs start-message-move-task \
  --source-arn arn:aws:sqs:{region}:{account}:call-analytics-{env}-reconciliation-worker-dlq \
  --destination-arn arn:aws:sqs:{region}:{account}:call-analytics-{env}-reconciliation-worker-queue

Option B: Manual Reprocessing

# Trigger reconciliation for specific client
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"execute"}' \
  /tmp/response.json

Option C: Delete Invalid Messages

# CAUTION: Only if messages are truly invalid
aws sqs purge-queue \
  --queue-url https://sqs.{region}.amazonaws.com/{account}/call-analytics-{env}-reconciliation-worker-dlq

Issue 5: Rate Limit Errors During Reconciliation

Symptoms

  • Dashboard: Row 12 Left shows RateLimitCapacity <3
  • Metrics: RateLimitErrors increasing
  • Logs: 429 Too Many Requests errors in worker logs
  • Impact: Reconciliation jobs failing, retrying after 2-10 minutes

Investigation

Step 1: Check Rate Limit Capacity

# CloudWatch Logs Insights - Track rate limit headers
fields @timestamp, client_id, rateLimitRemaining, rateLimitLimit
| filter @message like /RingCentral rate limit/
| stats avg(rateLimitRemaining) as AvgRemaining by client_id, bin(5m)
| sort @timestamp desc

Step 2: Identify High-Volume Clients

# CloudWatch Logs Insights - Count API calls per client
fields @timestamp, client_id
| filter @message like /Fetching.*RingCentral/
| stats count() as ApiCallCount by client_id
| sort ApiCallCount desc

Step 3: Check Concurrency

# Verify worker Lambda reserved concurrency
aws lambda get-function-concurrency \
  --function-name ReconciliationWorker \
  --query 'ReservedConcurrentExecutions'

# Expected: 2 (prevents rate limit storms)

Root Cause Analysis

Scenario A: Reserved Concurrency Too High

  • Symptoms: Multiple workers processing simultaneously → API storm
  • Dashboard: ApiCallsPerMinute >10
  • Investigation: Check Lambda metrics for concurrent executions
  • Fix: Reduce reserved concurrency from 10 → 2
    // lib/stacks/lambda-stack.ts
    reconciliationWorker.addFunctionUrl({
      reservedConcurrentExecutions: 2, // Changed from 10
    });

Scenario B: SQS Batch Size Too Large

  • Symptoms: Worker processing multiple messages → multiple API calls
  • Dashboard: RateLimitCapacity drops sharply during reconciliation runs
  • Investigation: Check SQS event source configuration
    aws lambda list-event-source-mappings \
      --function-name ReconciliationWorker \
      --query 'EventSourceMappings[0].BatchSize'
  • Fix: Ensure batch size = 1 (sequential processing)

Scenario C: Normal High Volume (Not an Error)

  • Symptoms: RateLimitCapacity fluctuates but >3 most of the time
  • Dashboard: Occasional dips to 2-3, then recovers
  • Explanation: V2.5.0 wide-spread retry (2-10 min delays) prevents storms
  • Fix: No action needed - system is self-regulating

Resolution

Automatic (V2.5.0 Feature):

  • Worker extends SQS visibility timeout by 2-10 minutes on 429
  • Lambda throws error → SQS retries after random delay
  • Prevents thundering herd retry storms

Manual (If persistent):

# Temporarily disable schedule to reduce load
aws scheduler update-schedule \
  --name call-analytics-prod-reconciliation \
  --state DISABLED

# Wait for rate limit window to reset (typically 1 minute)

# Re-enable schedule
aws scheduler update-schedule \
  --name call-analytics-prod-reconciliation \
  --state ENABLED

Preventive:

  • Monitor Row 12 Left (RateLimitCapacity) in dashboard
  • Set up alarm for RateLimitCapacity <3 for >5 minutes
  • Review reserved concurrency settings after client onboarding

Metrics Reference

Reconciliation Case Metrics

ReconciliationCase1

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 9 Widget 1
  • Dimensions: ClientId
  • Unit: Count
  • Meaning: Calls exist in RingCentral but missing in DynamoDB
  • Healthy: 0 or <5 per client
  • Warning: >10 per client (backlog forming)
  • Critical: >50 per client (major sync gap)
  • Troubleshoot: Webhook delivery failure, TranscribeProcessor errors, EventBridge schedule disabled

ReconciliationCase2

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 9 Widget 2
  • Dimensions: ClientId
  • Unit: Count
  • Meaning: Call in DB but recording not downloaded (>15 minutes)
  • Healthy: 0
  • Warning: >5 per client
  • Critical: >20 per client
  • Troubleshoot: RingCentral API errors, network issues, IAM permissions for S3 upload

ReconciliationCase3

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 9 Widget 3
  • Dimensions: ClientId
  • Unit: Count
  • Meaning: Recording downloaded but transcription not started (>30 minutes)
  • Healthy: 0
  • Warning: >5 per client
  • Critical: >20 per client
  • Troubleshoot: AWS Transcribe quota limits, IAM permissions, S3 bucket access

ReconciliationCase4

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 9 Widget 4
  • Dimensions: ClientId
  • Unit: Count
  • Meaning: Transcription started but not completed (>24 hours)
  • Healthy: 0 or <3 per client
  • Warning: >5 per client (transcription failures)
  • Critical: >15 per client (systemic issue)
  • Troubleshoot: AWS Transcribe job failures, audio format issues, service backlog

ReconciliationCaseD

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 9 Widget 5
  • Dimensions: ClientId
  • Unit: Count
  • Meaning: Record in DynamoDB but deleted from RingCentral (>90 days)
  • Healthy: <20 total (gradual accumulation from retention policy)
  • Warning: >20 total or sudden spike
  • Critical: >50 total (potential account deletion)
  • Troubleshoot: RingCentral account status, test data cleanup, retention policy changes

Operational Metrics

ProcessingDuration

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Not visualized (use CloudWatch Metrics Explorer)
  • Dimensions: ClientId
  • Unit: Milliseconds
  • Meaning: Time taken to process single reconciliation action
  • Healthy: <30,000 ms (30 seconds)
  • Warning: >60,000 ms (1 minute)
  • Critical: >180,000 ms (3 minutes, approaching timeout)
  • Troubleshoot: Slow RingCentral API, large recording downloads, network latency

RateLimitErrors

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Not visualized (use CloudWatch Metrics Explorer)
  • Dimensions: ClientId, Operation
  • Unit: Count
  • Meaning: Number of RingCentral 429 rate limit errors
  • Healthy: 0
  • Warning: >1 per hour (occasional limits)
  • Critical: >5 per hour (sustained rate limiting)
  • Troubleshoot: High concurrency, large reconciliation windows, insufficient delays

RateLimitCapacity

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 12 Left
  • Dimensions: Source (webhook vs reconciliation)
  • Unit: Count (raw remaining requests)
  • Meaning: RingCentral API requests remaining in current window
  • Healthy: >5
  • Warning: <3 (approaching rate limit)
  • Critical: <1 (rate limit imminent)
  • Troubleshoot: Reduce concurrency, increase delays, disable schedule temporarily

ApiCallsPerMinute

  • Namespace: CallAnalytics/RingCentral
  • Dashboard: Row 12 Right
  • Dimensions: Source (webhook vs reconciliation)
  • Unit: Count
  • Meaning: RingCentral API calls per minute (CloudWatch sums automatically)
  • Healthy: <10 per minute
  • Warning: >10 per minute (high load)
  • Critical: >20 per minute (rate limit storm risk)
  • Troubleshoot: Check reserved concurrency, SQS batch size, reconciliation frequency

CloudWatch Logs Insights Queries

Reconciliation Actions Taken (Last Run)

Purpose: See what actions reconciliation took in last run

fields @timestamp, client_id, reconciliationCase, action, reason
| filter @message like /Reconciliation action/
| sort @timestamp desc
| limit 100

Expected Output:

@timestamp                 client_id              reconciliationCase  action            reason
2025-11-30T14:30:00Z      orangeTheory-abc123    1                   inject_webhook    Missing in DB
2025-11-30T14:31:00Z      planetFitness-xyz789   4                   start_transcription   Stuck >24h

Per-Client Reconciliation Summary

Purpose: Aggregate view of issues per client

fields client_id, reconciliationCase
| filter @message like /Reconciliation completed/
| stats sum(state1) as MissingCalls,
        sum(state2) as RecordingsNeeded,
        sum(state3) as TranscriptionsNeeded,
        sum(state4) as TranscriptionsStuck,
        sum(caseD) as OrphanedRecords
  by client_id
| sort MissingCalls desc

Expected Output:

client_id              MissingCalls  RecordingsNeeded  TranscriptionsNeeded  TranscriptionsStuck  OrphanedRecords
orangeTheory-abc123    23            0                 0                     5                    2
planetFitness-xyz789   12            0                 0                     3                    1

Track State 1 Trend (Missing Calls Over Time)

Purpose: See if State 1 (missing calls) is increasing or decreasing

fields @timestamp, client_id
| filter @message like /State 1: Missing/
| stats count() as MissingCallCount by client_id, bin(3h)
| sort @timestamp desc

Expected Output:

@timestamp                 client_id              MissingCallCount
2025-11-30T12:00:00Z      orangeTheory-abc123    23
2025-11-30T09:00:00Z      orangeTheory-abc123    18  (decreasing - good!)
2025-11-30T06:00:00Z      orangeTheory-abc123    25

Find Rate Limit Errors by Client

Purpose: Identify which clients are hitting rate limits

fields @timestamp, client_id, error
| filter @message like /429/ or @message like /rate limit/
| stats count() as RateLimitCount by client_id
| sort RateLimitCount desc

Expected Output:

client_id              RateLimitCount
orangeTheory-abc123    12
planetFitness-xyz789   5

Reconciliation Processing Duration by Client

Purpose: Identify slow reconciliation runs

fields @timestamp, client_id, elapsedMs
| filter @message like /Reconciliation completed/
| stats avg(elapsedMs) as AvgDuration, max(elapsedMs) as MaxDuration by client_id
| sort MaxDuration desc

Expected Output:

client_id              AvgDuration  MaxDuration
orangeTheory-abc123    45000        180000  (3 minutes - slow!)
planetFitness-xyz789   12000        30000   (30 seconds - normal)

Find Calls Without Recordings (V2.5.1)

Purpose: Distinguish calls without recordings (not an error)

fields @timestamp, client_id, hasRecording
| filter @message like /Call exists.*without recording/
| stats count() by client_id, hasRecording

Expected Output:

client_id              hasRecording  count
orangeTheory-abc123    false         4  (calls without recordings - expected)

Identify DLQ Failure Patterns

Purpose: See why messages are ending up in DLQ

fields @timestamp, client_id, error
| filter ispresent(error)
| filter approximateReceiveCount >= 3
| stats count() as FailureCount by error
| sort FailureCount desc

Expected Output:

error                                          FailureCount
ResourceNotFoundException: Secret not found    8
AccessDeniedException (Secrets Manager)        5
429 Too Many Requests                          2

Compare RingCentral Calls vs DynamoDB Records

Purpose: Identify data sync gaps

fields @timestamp, client_id, ringCentralCalls, dynamoDBStates
| filter @message like /Starting reconciliation engine/
| sort @timestamp desc
| limit 20

Expected Output:

@timestamp                 client_id              ringCentralCalls  dynamoDBStates  Gap
2025-11-30T14:30:00Z      orangeTheory-abc123    150               127             23 missing

Track Reconciliation Success Rate

Purpose: Overall health check

fields @timestamp
| filter @message like /Reconciliation job completed/ or @message like /Reconciliation job failed/
| stats count(@message like /completed/) as Success,
        count(@message like /failed/) as Failed
| eval SuccessRate = Success * 100 / (Success + Failed)

Expected Output:

Success  Failed  SuccessRate
95       5       95.0%  (healthy)

Find Stuck Transcription Jobs

Purpose: Identify transcriptions stuck in State 4

fields @timestamp, telephonySessionId, transcriptionJobName, reason
| filter @message like /State 4: Transcription not completed/
| sort @timestamp desc
| limit 50

Expected Output:

@timestamp                 telephonySessionId  transcriptionJobName      reason
2025-11-30T14:30:00Z      xyz123              transcribe-job-xyz123     Stuck for >24h

Monitor SQS Visibility Timeout Extensions (429 Handling)

Purpose: See how often reconciliation is hitting rate limits and retrying

fields @timestamp, client_id, delaySeconds
| filter @message like /Extending visibility timeout/
| stats count() as RetryCount, avg(delaySeconds) as AvgDelay by client_id
| sort RetryCount desc

Expected Output:

client_id              RetryCount  AvgDelay
orangeTheory-abc123    12          360  (6 minutes avg delay)
planetFitness-xyz789   5           180  (3 minutes avg delay)

Find Orphaned Records (Case D)

Purpose: Investigate orphaned records for data integrity

fields @timestamp, telephonySessionId, client_id, lastUpdated, reason
| filter @message like /Case D: Orphaned/
| sort @timestamp desc
| limit 50

Expected Output:

@timestamp                 telephonySessionId  client_id           lastUpdated           reason
2025-11-30T14:30:00Z      abc123              orangeTheory-123    2025-08-15T10:00:00Z  >90 days old

Reconciliation Window Coverage

Purpose: Verify reconciliation window is appropriate

fields @timestamp, client_id, dateRange.start, dateRange.end
| filter @message like /Fetching RingCentral call logs/
| stats count() by client_id

Expected Output:

client_id              count  start                      end
orangeTheory-abc123    1      2025-11-30T08:30:00Z      2025-11-30T14:30:00Z  (6 hours - good)

Action Execution Success/Failure

Purpose: Track action execution outcomes

fields @timestamp, actionType, callId, success
| filter @message like /Reconciliation action executed/ or @message like /Reconciliation action execution failed/
| stats count(@message like /executed successfully/) as Success,
        count(@message like /execution failed/) as Failed
  by actionType

Expected Output:

actionType            Success  Failed
inject_webhook        45       2
start_transcription   12       1
inject_s3_event       5        0

SQS Message Processing Metrics

Purpose: Understand message retry patterns

fields @timestamp, messageId, approximateReceiveCount
| filter @message like /Processing reconciliation job/
| stats count() as TotalMessages,
        avg(approximateReceiveCount) as AvgRetries,
        max(approximateReceiveCount) as MaxRetries
  by bin(1h)
| sort @timestamp desc

Expected Output:

@timestamp             TotalMessages  AvgRetries  MaxRetries
2025-11-30T14:00:00Z  50             1.2         3
2025-11-30T13:00:00Z  48             1.1         2  (mostly first attempts - healthy)

Operational Procedures

Enable/Disable Reconciliation Schedule

Check Current State

# Get schedule state
aws scheduler get-schedule \
  --name call-analytics-{env}-reconciliation \
  --query 'State' \
  --output text

# Expected output: ENABLED or DISABLED

Enable Schedule

# Production (requires manual approval - safety measure)
aws scheduler update-schedule \
  --name call-analytics-prod-reconciliation \
  --state ENABLED

# Verify
aws scheduler get-schedule \
  --name call-analytics-prod-reconciliation \
  --query 'State'

When to Enable:

  • After successful deployment and testing in dev/test
  • After incident resolution when root cause is fixed
  • When manual reconciliation is no longer needed

Note: V2.5.3 change - dev/test environments auto-enabled, prod disabled by default

Disable Schedule

# Emergency stop (any environment)
aws scheduler update-schedule \
  --name call-analytics-{env}-reconciliation \
  --state DISABLED

# Verify
aws scheduler get-schedule \
  --name call-analytics-{env}-reconciliation \
  --query 'State'

When to Disable:

  • During major incidents (prevent more failures)
  • Before risky deployments (pause reconciliation during change)
  • When investigating data integrity issues (prevent auto-fixes)
  • During RingCentral API outages (prevent rate limit storms)

Manual Reconciliation Run

Trigger Orchestrator (Scan-Only Mode)

Purpose: See what issues exist without fixing them

# Dry run - generates report without taking action
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"dry_run"}' \
  /tmp/response.json

# View results
cat /tmp/response.json | jq

Expected Output:

{
  "statusCode": 200,
  "body": "{\"clients_queued\":5,\"clients_failed\":0,\"failed_clients\":[]}"
}

Trigger Orchestrator (Execute Mode)

Purpose: Actually fix detected issues

# Execute mode - fixes issues immediately
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"execute"}' \
  /tmp/response.json

# View results
cat /tmp/response.json | jq

When to Use:

  • After fixing root cause of reconciliation failures
  • During off-peak hours (minimize RingCentral API load)
  • For emergency data recovery
  • When schedule is disabled but issues need fixing

Caution:

  • Execute mode creates SQS messages with random delays (2-15 min)
  • Actions process asynchronously (check logs for completion)
  • Monitor dashboard Row 9 for case count decreases

Monitor Progress

Step 1: Check Orchestrator Completion

# Watch orchestrator logs
aws logs tail /aws/lambda/ReconciliationOrchestrator \
  --follow \
  --filter-pattern "Reconciliation orchestrator completed"

Step 2: Check SQS Queue Depth

# Monitor worker queue depth (should decrease over time)
aws sqs get-queue-attributes \
  --queue-url https://sqs.{region}.amazonaws.com/{account}/call-analytics-{env}-reconciliation-worker-queue \
  --attribute-names ApproximateNumberOfMessages \
  --query 'Attributes.ApproximateNumberOfMessages'

# Expected: Starts at ~5-50, decreases to 0 over 10-30 minutes

Step 3: Monitor Dashboard

1. Navigate to CloudWatch Dashboard → Row 9
2. Watch case counts decrease over time:
   - ReconciliationCase1: Should drop first (State 1 fixed quickly)
   - ReconciliationCase2-4: Gradual decrease (async processing)
3. Check for errors: Row 11 should show no errors

Adjust Reconciliation Window

Temporary Override (via Environment Variable)

Purpose: Change reconciliation window for single run without code change

# Increase window to 12 hours (default: 6 hours)
aws lambda update-function-configuration \
  --function-name ReconciliationOrchestrator \
  --environment Variables={RECONCILIATION_WINDOW_HOURS=12}

# Verify
aws lambda get-function-configuration \
  --function-name ReconciliationOrchestrator \
  --query 'Environment.Variables.RECONCILIATION_WINDOW_HOURS'

# Trigger manual run with new window
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"execute"}' \
  /tmp/response.json

When to Use:

  • After long outages (need wider window to catch up)
  • For one-time historical data recovery
  • Testing reconciliation with different windows

Caution:

  • Larger windows increase RingCentral API calls (stay under rate limits)
  • Maximum window: 90 days (RingCentral retention period)
  • Revert to default (6 hours) after temporary use

Permanent Change (via CDK)

Purpose: Change default window for all future runs

  1. Edit lambda/reconciliation-worker/src/core/reconciliation-cases.ts

    /** 12 hours in milliseconds (changed from 6 hours) */
    const DEFAULT_WINDOW_MS = 12 * 60 * 60 * 1000;
  2. Deploy change

    npm run build
    cdk deploy --context environment={env}
  3. Verify in logs

    fields @timestamp, dateRange
    | filter @message like /Fetching RingCentral call logs/
    | limit 1
    
    # Check dateRange.start vs dateRange.end = 12 hours apart

When to Use:

  • After changing reconciliation frequency (e.g., every 6 hours instead of 3)
  • Based on observed data patterns (e.g., calls delayed >6 hours)
  • To balance freshness vs API cost

Best Practices:

  • Window should be 2x reconciliation frequency (safety margin)
  • Don't exceed 24 hours (diminishing returns, higher API cost)
  • Test in dev/test before production

View Reconciliation History

Last 10 Runs

Purpose: Quick health check - are reconciliation runs succeeding?

fields @timestamp, totalIssues, state1, state2, state3, state4, caseD
| filter @message like /Reconciliation completed/
| sort @timestamp desc
| limit 10

Expected Output:

@timestamp                 totalIssues  state1  state2  state3  state4  caseD
2025-11-30T14:30:00Z      15           12      0       0       2       1
2025-11-30T11:30:00Z      18           15      0       0       2       1
2025-11-30T08:30:00Z      23           20      0       0       2       1  (improving!)

Trend Analysis (Last 7 Days)

Purpose: Long-term health - is backlog growing or shrinking?

fields @timestamp, totalIssues
| filter @message like /Reconciliation completed/
| stats avg(totalIssues) as AvgIssues, max(totalIssues) as MaxIssues by bin(1d)
| sort @timestamp desc

Expected Output:

@timestamp             AvgIssues  MaxIssues
2025-11-30T00:00:00Z  15.2       23
2025-11-29T00:00:00Z  18.5       30
2025-11-28T00:00:00Z  22.1       35  (trending down - good!)

Success Rate Over Time

Purpose: Reliability check - what percentage of runs succeed?

fields @timestamp
| filter @message like /Reconciliation/ and (@message like /completed/ or @message like /failed/)
| stats count(@message like /completed/) as Success,
        count(@message like /failed/) as Failed
  by bin(1d)
| eval SuccessRate = Success * 100 / (Success + Failed)
| sort @timestamp desc

Expected Output:

@timestamp             Success  Failed  SuccessRate
2025-11-30T00:00:00Z  8        0       100.0%  (perfect!)
2025-11-29T00:00:00Z  7        1       87.5%   (good)

Code Reference

Key Files

Orchestrator:

  • Entry: /lambda/reconciliation-orchestrator/src/handler.ts
  • Config scanner: /lambda/reconciliation-orchestrator/src/infrastructure/config-scanner.ts
  • SQS publisher: /lambda/reconciliation-orchestrator/src/infrastructure/sqs-publisher.ts

Worker:

  • Entry: /lambda/reconciliation-worker/src/handler.ts
  • State detection: /lambda/reconciliation-worker/src/core/reconciliation-cases.ts
  • Reconciliation engine: /lambda/reconciliation-worker/src/core/reconciliation-engine.ts
  • Metrics: /lambda/reconciliation-worker/src/utils/metrics.ts
  • RingCentral API: /lambda/reconciliation-worker/src/infrastructure/ringcentral-client.ts

Shared:

  • Constants: /lambda/shared/constants/reconciliation.ts
  • Models: /lambda/reconciliation-worker/src/core/models.ts

Critical Code Sections

SQS Fan-Out (Orchestrator handler.ts:64-84)

Purpose: Scan DynamoDB for active clients, publish to SQS

// Step 1: Scan DynamoDB for active clients
const scanner = createConfigScanner();
const scanResult = await scanner.scanActiveClients();

logger.info('Active clients found', {
  count: scanResult.count,
  clients: scanResult.clients.map((c) => c.client_id),
});

if (scanResult.count === 0) {
  logger.warn('No active clients found - nothing to reconcile');
  return createResponse(200, {
    clients_queued: 0,
    clients_failed: 0,
    failed_clients: [],
  });
}

// Step 2: Publish reconciliation jobs to SQS
const publisher = createSqsPublisher(mode);
const publishResult = await publisher.publishReconciliationJobs(scanResult.clients);

Why It Matters: V2.5.0 architecture - 1 SQS message per client (enables independent retry)

4-State Detection (reconciliation-cases.ts:48-131)

Purpose: Classify call records into States 1-4 or Case D

// State 1: Missing in DB
const missingInDB = ringCentralCalls.filter(
  (call) => !stateMap.has(call.sessionId)
);

// State 2: Recording not downloaded (>15 min)
const recordingNotDownloaded = dynamoDBStates.filter((state) => {
  if (!state.callStartTime) return false;
  const age = now - new Date(state.callStartTime).getTime();
  return (
    state.callLogFetched === true &&
    state.recordingAvailable === true &&
    state.recordingDownloaded === false &&
    state.transcriptionStarted === false &&
    age > RECORDING_DOWNLOAD_THRESHOLD_MS
  );
});

// State 3: Transcription not started (>30 min)
const transcriptionNotStarted = dynamoDBStates.filter((state) => {
  if (!state.callStartTime) return false;
  const age = now - new Date(state.callStartTime).getTime();
  return (
    state.recordingDownloaded === true &&
    state.transcriptionStarted === false &&
    age > TRANSCRIPTION_START_THRESHOLD_MS
  );
});

// State 4: Transcription not completed (>24 hours)
const transcriptionNotCompleted = dynamoDBStates.filter((state) => {
  if (!state.lastUpdated) return false;
  const age = now - new Date(state.lastUpdated).getTime();
  return (
    state.recordingDownloaded === true &&
    state.transcriptionStarted === true &&
    state.transcriptionCompleted !== true &&
    age > STUCK_THRESHOLD_MS
  );
});

// Case D: Orphaned records (>90 days old, deleted from RingCentral)
const orphanedRecords = dynamoDBStates.filter((state) => {
  if (!state.lastUpdated) return false;
  const age = now - new Date(state.lastUpdated).getTime();
  return !callMap.has(state.callSessionId) && age > ORPHAN_THRESHOLD_MS;
});

Why It Matters: Granular state detection enables targeted fixes (e.g., State 2 = download recording, State 3 = start transcription)

Per-Client Metrics (metrics.ts:120-156)

Purpose: Emit metrics with ClientId dimension for per-client visibility

/**
 * Set the ClientId dimension for per-client metric visibility
 *
 * V2.4.2 Bugfix: Call this ONCE before emitting multiple metrics
 */
export function setClientIdDimension(clientId: string): void {
  metrics.addDimension('ClientId', clientId);
}

/**
 * Track reconciliation case breakdown
 *
 * V2.4.2: Removed dimension setting - caller must call setClientIdDimension() first
 */
export function emitReconciliationCaseMetric(
  caseType: '1' | '2' | '3' | '4' | 'D',
  count: number,
): void {
  metrics.addMetric(`ReconciliationCase${caseType}`, MetricUnit.Count, count);
}

Usage in handler.ts:

// Set ClientId dimension ONCE
setClientIdDimension(jobPayload.client_id);

// Emit 5 metrics (all use same ClientId dimension)
emitReconciliationCaseMetric('1', result.cases.missingInDB.length);
emitReconciliationCaseMetric('2', result.cases.recordingNotDownloaded.length);
emitReconciliationCaseMetric('3', result.cases.transcriptionNotStarted.length);
emitReconciliationCaseMetric('4', result.cases.transcriptionNotCompleted.length);
emitReconciliationCaseMetric('D', result.cases.orphanedRecords.length);

Why It Matters: Enables Dashboard Row 9 per-client drill-down

RingCentral 429 Handling (ringcentral-client.ts:99-130)

Purpose: Extend SQS visibility timeout on rate limit, retry with random delay

// Handle 429 rate limit
if (response.status === 429) {
  logger.warn('RingCentral rate limit exceeded', { telephonySessionId });

  // Extend SQS visibility timeout by 2-10 minutes (random)
  const delaySeconds = Math.floor(Math.random() * 481) + 120; // 120-600 seconds

  await config.sqsClient.send(new ChangeMessageVisibilityCommand({
    QueueUrl: config.queueUrl,
    ReceiptHandle: config.receiptHandle,
    VisibilityTimeout: delaySeconds,
  }));

  logger.info('Extended SQS visibility timeout', { delaySeconds });

  // Throw error to trigger SQS retry
  throw new Error('RingCentral rate limit exceeded (429). Extended visibility timeout for retry.');
}

Why It Matters: V2.5.0 wide-spread retry prevents thundering herd retry storms


Testing & Validation

Test Reconciliation Locally

Purpose: Validate reconciliation logic without deploying

# Run unit tests
cd lambda/reconciliation-worker
npm test

# Run specific test suite
npm test -- reconciliation-cases.test.ts

# Run with coverage
npm run test:coverage

Key Test Files:

  • /lambda/reconciliation-worker/__tests__/unit/reconciliation-cases.test.ts - State detection logic
  • /lambda/reconciliation-worker/__tests__/unit/reconciliation-engine.test.ts - Action generation
  • /lambda/reconciliation-worker/__tests__/unit/webhook-builder.test.ts - Synthetic webhook creation

Validate State Detection Logic

Purpose: Ensure 4-state model correctly classifies records

// Example test from reconciliation-cases.test.ts
describe('detectReconciliationCases', () => {
  it('should detect State 1: Missing in DB', () => {
    const ringCentralCalls = [
      { sessionId: 'call-1', recording: { id: 'rec-1' } },
    ];
    const dynamoDBStates = [];

    const result = detectReconciliationCases(ringCentralCalls, dynamoDBStates);

    expect(result.missingInDB).toHaveLength(1);
    expect(result.missingInDB[0].sessionId).toBe('call-1');
  });

  it('should detect State 4: Transcription stuck >24h', () => {
    const ringCentralCalls = [];
    const dynamoDBStates = [
      {
        callSessionId: 'call-1',
        recordingDownloaded: true,
        transcriptionStarted: true,
        transcriptionCompleted: false,
        lastUpdated: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), // 25 hours ago
      },
    ];

    const result = detectReconciliationCases(ringCentralCalls, dynamoDBStates);

    expect(result.transcriptionNotCompleted).toHaveLength(1);
  });
});

Load Testing

Purpose: Validate reconciliation at scale (production-like volume)

Caution: Only run in dev/test environments to avoid production impact

# Step 1: Create test data (50 clients, 100 calls each = 5000 calls)
aws lambda invoke \
  --function-name TestDataGenerator \
  --payload '{"clientCount":50,"callsPerClient":100}' \
  /tmp/response.json

# Step 2: Trigger reconciliation
aws lambda invoke \
  --function-name ReconciliationOrchestrator \
  --payload '{"mode":"execute"}' \
  /tmp/response.json

# Step 3: Monitor metrics
# - Watch Dashboard Row 9 for case counts
# - Check Row 12 for rate limit capacity
# - Verify no DLQ messages

# Step 4: Measure completion time
# Expected: ~30 minutes for 50 clients (with concurrency=2, random delays)

# Step 5: Cleanup test data
aws lambda invoke \
  --function-name TestDataCleanup \
  --payload '{"prefix":"test-"}' \
  /tmp/response.json

Success Criteria:

  • All 5000 calls processed without DLQ messages
  • Rate limit capacity stays >3 throughout
  • No Lambda errors or throttles
  • Completion time <1 hour

Architecture Decisions

Why SQS Fan-Out? (V2.5.0)

Problem (V2.4):

  • Orchestrator made 37+ RingCentral API calls per client in single invocation
  • Each reconciliation action (State 1-4) triggered immediate API call
  • 10 clients × 37 calls = 370 API calls in <60 seconds
  • RingCentral rate limit: ~10 calls/minute → 429 errors

Solution (V2.5.0):

  • Orchestrator makes 1 API call per client (fetch call logs)
  • Generates 1 SQS message per action (e.g., State 1 = inject_webhook)
  • Worker processes 1 message at a time (concurrency=2)
  • Random delays (2-15 min) spread load over time

Result:

  • 97% API call reduction (370 → 10 calls for 10 clients)
  • Wide-spread retry prevents thundering herd
  • Independent retry (each action can retry without reprocessing all)
  • Better observability (per-action metrics, logs)

Trade-off:

  • Longer completion time (10-30 min vs <5 min)
  • More complex architecture (orchestrator + worker + SQS)
  • Acceptable: Reconciliation is background job, not time-critical

Why 4-State Model? (V2.4.1)

Problem (Legacy A/B/C):

  • Case B: "Stuck in progress" - didn't distinguish recording vs transcription
  • Case C: "Failed transcription" - overlapped with Case B
  • Hard to troubleshoot: Is recording missing? Or transcription stuck?

Solution (State 1-4):

  • State 1: Missing in DB (was Case A) - unchanged
  • State 2: Recording not downloaded (new) - network/S3 issue
  • State 3: Transcription not started (new) - Transcribe quota/permissions
  • State 4: Transcription stuck (merged B+C) - job failure/timeout
  • Case D: Orphaned (unchanged) - RingCentral deleted

Result:

  • Granular troubleshooting (State 2 = download issue, State 3 = Transcribe issue)
  • Better dashboard visibility (5 widgets instead of 4)
  • Targeted fixes (State 2 = retry download, State 3 = start transcription)
  • Easier metrics (one metric per state)

Migration:

  • Code updated: V2.4.1 (November 28, 2025)
  • Backward compatible: Old metrics still work, new metrics added
  • Dashboard updated: Row 9 shows 5 states (1-4 + D)

Why 6-Hour Window? (V2.4)

Problem (7-Day Window):

  • Reconciliation runs every 3 hours
  • 7-day window = 56 overlapping scans (7 days ÷ 3 hours)
  • Each scan fetches same calls 56 times (99.98% duplication!)
  • RingCentral API cost: 56× necessary calls

Solution (6-Hour Window):

  • 6-hour window = 2 overlapping scans (6 hours ÷ 3 hours)
  • Each call fetched 2 times (50% duplication for safety margin)
  • 2× processing SLA headroom (normal: 3 hours, window: 6 hours)

Result:

  • 50% API call reduction vs 7-day window
  • Faster issue detection (catches problems within 6 hours, not 7 days)
  • Lower RingCentral API cost
  • Still safe: 2× headroom for delayed processing

Trade-off:

  • Misses calls >6 hours delayed (rare: <0.1% based on metrics)
  • Can increase window to 12/24 hours for historical recovery
  • Acceptable: Focus on recent calls, not historical data

Why Concurrency = 2? (V2.5.0)

Problem (No Reserved Concurrency):

  • Lambda auto-scales based on SQS queue depth
  • 100 messages → 100 concurrent Lambdas
  • Each Lambda makes RingCentral API calls
  • 100 Lambdas × 5 calls = 500 calls/minute → 429 storm

Solution (Reserved Concurrency = 2):

  • Maximum 2 Lambdas processing simultaneously
  • Each Lambda processes 1 message at a time (SQS batch size = 1)
  • 2 Lambdas × 5 calls = 10 calls/minute (well under rate limit)
  • SQS queue builds up, but processes slowly and safely

Result:

  • Prevents rate limit storms
  • Stable API call rate (~10 calls/minute)
  • Queue eventually drains (2 workers × 15 min timeout = 8 messages/hour)
  • Better than concurrency=1 (too slow) or concurrency=10 (rate limit risk)

Trade-off:

  • Slower processing (100 messages = 50 hours vs 1 hour)
  • Acceptable: Reconciliation is background job, correctness > speed


Changelog

V2.5.3 (2025-11-30): Auto-enable reconciliation schedule in dev/test

  • Schedule state: Auto-enabled (dev/test), Disabled (prod)
  • Eliminates manual enable step after deployment
  • Updated operational procedures for schedule management

V2.5.0 (2025-11-28): SQS fan-out architecture

  • 97% API call reduction (37+ calls → 1 per client)
  • Wide-spread retry (2-15 min random delays)
  • Independent action retry via SQS
  • Reserved concurrency = 2 (prevents rate limit storms)

V2.4.1 (2025-11-28): 4-state reconciliation model

  • New states: State 2 (recording not downloaded), State 3 (transcription not started)
  • Merged Case B + C → State 4 (transcription stuck)
  • Per-client metrics with ClientId dimension
  • Dashboard Row 9 added (5 widgets for states 1-4 + D)

V2.4 (2025-11-28): Schedule optimization

  • Frequency: Daily → Every 3 hours
  • Window: 7 days → 6 hours
  • RingCentral API pagination support

V2.3 (2025-11-27): Metrics namespace change

  • Namespace: CallAnalyticsCallAnalytics/RingCentral
  • Added ApiCallsPerMinute metric

V2.2.5 (2025-11-24): Secrets Manager IAM fix

  • IAM policy supports 3 secret naming patterns
  • Uses secret_name from DynamoDB config

Document Version: 1.0 Last Updated: 2025-11-30 Author: Engineering Team Review Frequency: Quarterly