""" Backfill Lambda for backend event-logger (manual trigger only) Purpose - Reads failed raw backend events from S3, decodes base64-encoded "rawData" into the inner JSON event, and writes newline-delimited JSON back to S3 organized by event timestamp. - This function only does decode; the rest mirrors the backend parser's behavior (no extra enrichments). - This function can backfill both batched and realtime event logger, so the destination prefix is determined by the source object filename prefix. Required environment variables - SOURCE_DATE: The day to backfill, format "YYYY/MM/DD" (e.g., "2025/02/25"). Must be set. - BACKEND_EVENT_LOGGER_BUCKET: Source bucket name. Staging: "suno-staging-backend-event-logger", Prod: "suno-prod-backend-event-logger". Input/output locations - Input prefix: s3://{BACKEND_EVENT_LOGGER_BUCKET}/error/processing-failed/YYYY/MM/DD (month/day zero-padded from SOURCE_DATE) - Output prefix depends on source object filename prefix: - If key basename starts with "backend-event-logger-firehose": s3://{BACKEND_EVENT_LOGGER_DEST_BUCKET}/batched/0/YYYY/M/D/H/ - If key basename starts with "backend-realtime-event-logger-firehose": s3://{BACKEND_EVENT_LOGGER_DEST_BUCKET}/realtime/0/YYYY/M/D/H/ Where YYYY/M/D/H come from the inner event's timestamp (month/day not zero-padded in output). Files are named as: event-logger-backfill-{AWS_REGION}-1-YYYY-MM-DD-HH-MM-SS-{uuid} """ import base64 import json import os import time import uuid from datetime import datetime, timezone from typing import Any, Dict, Iterable, List, Optional, Tuple import boto3 from botocore.response import StreamingBody def _get_env(name: str) -> str: value = os.getenv(name) if not value: raise RuntimeError(f"Missing required env var: {name}") return value def _iter_s3_lines(body: StreamingBody, chunk_size: int = 64 * 1024) -> Iterable[str]: pending = b"" for chunk in body.iter_chunks(chunk_size=chunk_size): if not chunk: continue pending += chunk while True: nl = pending.find(b"\n") if nl == -1: break line = pending[:nl] pending = pending[nl + 1 :] if line: yield line.decode("utf-8") if pending: yield pending.decode("utf-8") def _parse_event_time(ts: Any) -> datetime: # Accept unix epoch (int/float) or ISO8601-like strings; returns UTC datetime if ts is None: raise ValueError("missing timestamp in backend event") # Numeric epoch if isinstance(ts, (int, float)): return datetime.fromtimestamp(float(ts), tz=timezone.utc) # String inputs if isinstance(ts, str): # Try ISO with trailing Z or explicit offset try: if ts.endswith("Z"): return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(timezone.utc) return datetime.fromisoformat(ts).astimezone(timezone.utc) except Exception: # Try parse as float/int epoch encoded as string try: return datetime.fromtimestamp(float(ts), tz=timezone.utc) except Exception: pass # Common fallback patterns for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): try: dt = datetime.strptime(ts, fmt) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) except Exception: continue raise ValueError(f"unrecognized timestamp format: {ts}") def _s3_put_ndjson(s3, bucket: str, key: str, lines: List[str]) -> None: data = "\n".join(lines) + "\n" if lines else "" s3.put_object(Bucket=bucket, Key=key, Body=data.encode("utf-8")) def _extract_region_from_key(key: str) -> Optional[str]: base = key.rsplit('/', 1)[-1] parts = base.split('-') try: idx = parts.index('firehose') region_parts = parts[idx + 1: idx + 4] if len(region_parts) == 3: return '-'.join(region_parts) except ValueError: pass return None def _dest_subprefix_for_key(src_key: str) -> Optional[str]: # Determine destination subprefix based on file basename base = src_key.rsplit("/", 1)[-1] if base.startswith("backend-event-logger-firehose"): return "batched/0" if base.startswith("backend-realtime-event-logger-firehose"): return "realtime/0" return None def _process_object( s3, src_bucket: str, src_key: str, dest_bucket: str, ) -> Tuple[int, int]: subprefix = _dest_subprefix_for_key(src_key) if not subprefix: print(f"skip object with unknown prefix: s3://{src_bucket}/{src_key}") return 0, 0 obj = s3.get_object(Bucket=src_bucket, Key=src_key) body = obj["Body"] assert isinstance(body, StreamingBody) processed = 0 dropped = 0 # Group by date/hour; also keep HH-MM-SS for filename from first event in group by_group: Dict[Tuple[int, int, int, int], List[str]] = {} time_by_group: Dict[Tuple[int, int, int, int], str] = {} for raw_line in _iter_s3_lines(body): line = raw_line.strip() if not line: continue try: wrapper = json.loads(line) except Exception as exc: print(f"bad json line; key={src_key} err={exc}") continue raw_b64 = wrapper.get("rawData") if not isinstance(raw_b64, str): print(f"missing rawData; key={src_key}") continue try: inner = json.loads(base64.b64decode(raw_b64).decode("utf-8")) except Exception as exc: print(f"rawData decode failed; key={src_key} err={exc}") continue ts = inner.get("timestamp") dt = _parse_event_time(ts) if dt is None: print(f"missing/invalid timestamp; key={src_key}") dropped += 1 continue y, m, d, h = dt.year, dt.month, dt.day, dt.hour by_group.setdefault((y, m, d, h), []).append(json.dumps(inner, separators=(",", ":"))) if (y, m, d, h) not in time_by_group: time_by_group[(y, m, d, h)] = dt.strftime("%H-%M-%S") processed += 1 region = _extract_region_from_key(src_key) or os.getenv("AWS_REGION") for (y, m, d, h), lines in by_group.items(): date_str = f"{y}-{m:02d}-{d:02d}" time_str = time_by_group.get((y, m, d, h), "00-00-00") file_name = f"event-logger-backfill-{region}-1-{date_str}-{time_str}-{uuid.uuid4()}" key_out = f"{subprefix}/{y}/{m}/{d}/{h}/{file_name}" _s3_put_ndjson(s3, dest_bucket, key_out, lines) print(f"object summary bucket={src_bucket} key={src_key} processed={processed} dropped={dropped}") return processed, dropped def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: started = time.monotonic() # Source date source_date = _get_env("SOURCE_DATE") # YYYY/MM/DD parts = source_date.strip().split("/") if len(parts) != 3: raise RuntimeError("SOURCE_DATE must be 'YYYY/MM/DD'") y, m, d = int(parts[0]), int(parts[1]), int(parts[2]) src_bucket = _get_env("BACKEND_EVENT_LOGGER_BUCKET") dest_bucket = src_bucket prefix = f"error/processing-failed/{y}/{m:02d}/{d:02d}" s3 = boto3.client("s3") total_processed = 0 total_dropped = 0 print(f"backend backfill start src_bucket={src_bucket} prefix={prefix} dest_bucket={dest_bucket}") cont: Optional[str] = None while True: kwargs: Dict[str, Any] = {"Bucket": src_bucket, "Prefix": prefix} if cont: kwargs["ContinuationToken"] = cont page = s3.list_objects_v2(**kwargs) contents = page.get("Contents", []) for obj in contents: key = obj["Key"] p, q = _process_object(s3, src_bucket, key, dest_bucket) total_processed += p total_dropped += q if page.get("IsTruncated"): cont = page.get("NextContinuationToken") else: break elapsed = time.monotonic() - started print(f"backend backfill summary src_bucket={src_bucket} prefix={prefix} dest_bucket={dest_bucket} processed={total_processed} dropped={total_dropped} elapsed_s={elapsed:.3f}") return { "status": "ok", "src_bucket": src_bucket, "src_prefix": prefix, "dest_bucket": dest_bucket, "processed": total_processed, "dropped": total_dropped, "elapsed_s": round(elapsed, 3), }