career/qwestly/2026-10-soc2-log-retention-cloudwatch.md

Extended SOC 2 log retention from one app to all four member-data apps

Situation

Vercel Pro keeps runtime logs only briefly, so SOC 2 log retention depended on a 2025 log drain into a dead "monitoring API" that wrote one S3 object per batch. It covered only the candidate app; the agent, the Python API and the internal admin tool kept no logs at all. Nothing ever expired (650,304 objects, 2.3 GB), same-second batches overwrote each other, and answering an auditor's question meant downloading files. The receiver also kept an unauthenticated endpoint alive.

What I did

  • Scoped the work into a plan (decisions, phases, verification gates) and picked CloudWatch Logs Infrequent Access over S3: per-group automatic expiry, Logs Insights search in place, and cheaper at our volume than per-request S3 writes.
  • Found that neither approved policy states a log retention period; set 365 days provisionally (safe, since nothing can expire for a year) and held back the irreversible S3 expiry until the policy names a period.
  • Built and deployed the receiver as infrastructure as code (CloudFormation, per the Operations Security Policy): a signature-verifying Lambda, per-app log groups the function cannot create itself (so no group can exist without retention), and a rotated drain secret held in SSM.
  • Verified end to end against production: marker requests to all four apps found by Logs Insights in the right group, unsigned requests rejected.
  • PR: https://github.com/Qwestly/vercel-log-drain/pull/1. Plan: qwestly-workspace plan/archive/2026/vercel-log-drain-cloudwatch.md (successor: docs/log-retention.md). Asana 1215393993487608.

Result

Production apps with retained logs went from 1 of 4 to 4 of 4, with 1-year retention enforced automatically in CloudWatch and on the S3 history. A side-by-side check over a two-hour production window found every S3 entry in CloudWatch (377 of 377) and 15 more that the old receiver had lost. The old service and its unauthenticated endpoint were retired the same day. I then moved AWS administration off the root account onto a named MFA user, and gave agents a read-only log search skill, so production debugging no longer stops at Vercel's short log window. Cost is still to measure after a full month.

Metrics

Metric Baseline (date) Target Latest (date)
Production apps handling member data whose logs are retained 1 of 4 (2026-10-08) 4 of 4 4 of 4 (2026-10-09)
Retained log storage with automatic expiry 0 of 2.3 GB / 650,304 S3 objects (2026-10-08) all of it all: CloudWatch 365 days; S3 vercel-logs/ expiry rule at 365 days (2026-10-09)
Unauthenticated endpoints on the logging path 1 (POST /api/logs/ship) (2026-10-08) 0 0 (2026-10-09, now 404)
Monthly log retention cost not measured yet single-digit dollars check 2026-11-09 in Cost Explorer
Log entries lost in transit (2-hour production sample) 15 of 392 lost by the old S3 receiver (2026-10-09) 0 0 in CloudWatch (2026-10-09)
Known SOC 2 logging gaps without a control 5 (no retention policy, 1 app of 4, no expiry, unauthenticated endpoint, no delivery alerting) (2026-10-08) 0 3 remaining, all tracked: no CloudTrail, no automated credential scan of log text, app-user S3 reach (2026-10-09)
AWS admin work done as root all of it (2026-10-08) none none: named MFA user dominick (2026-10-09)

How to measure

  • Apps covered: vercel-cli api /v1/drains (strip delivery.secret), list projectIds of enabled drains; and in CloudWatch, describe-log-groups --log-group-name-prefix /qwestly/vercel with storedBytes > 0 and retentionInDays set.
  • S3 history: aws s3 ls s3://qwestly--logs/vercel-logs/ --recursive --summarize (read-only qwestly-app-user key works) and get-bucket-lifecycle-configuration.
  • Cost: AWS Cost Explorer, service CloudWatch, usage types *DataProcessingIA-Bytes and *TimedStorage-ByteHrs, plus Lambda.

Log

  • 2026-10-08: baseline taken (1 of 4 apps; 650,304 objects, 2.3 GB, no expiry).
  • 2026-10-09: receiver deployed, new drain live for all four apps, verified with Logs Insights.
  • 2026-10-09: S3 history expiry applied at 1 year; cutover after a 377/377 match; monitoring API retired; root replaced by a named MFA admin; read-only qwestly-logs skill shipped.
  • 2026-10-09: closed the "no alert when log delivery fails" gap: CloudWatch alarms (per-app 1-hour silence, write failures, receiver crashes) post to #dev-alerts; verified by forcing an alarm (alert and recovery posted). Vault Supabase-era log docs rewritten to the CloudWatch pipeline the same day.