""" Backfill Lambda for event-logger (manual trigger only) Purpose - Reads failed raw events from S3, decodes the outer base64-encoded "rawData" to extract request metadata (request-time, domain-name, x-client-ip) and the inner base64 "request_body". - The inner decoded JSON is the actual event payload and is processed the same way as the event-logger-parser Lambda (field validation, shaping, etc.). This function only adds the rawData/request_body decoding step; the rest of the logic mirrors event-logger-parser. Required environment variables - SOURCE_DATE: The day to backfill, format "YYYY/MM/DD" (e.g., "2025/09/02"). You must set this before invoking; otherwise the function will error. - EVENT_LOGGER_BUCKET: Bucket name to read from and write to. In staging it should be "event-logger-s3-temp"; in prod it should be "event-logger-temp". This is set by CDK per stage. Notes on input/output locations - Input (lookup) prefix: s3://{EVENT_LOGGER_BUCKET}/eerror/processing-failed/YYYY/MM/DD (month/day are zero-padded and come from SOURCE_DATE) - Output (write) prefix: s3://{EVENT_LOGGER_BUCKET}/{EventName}/0/YYYY/M/D/H/ - Year/month/day/hour are derived from the event timestamp (month/day not zero-padded in output) - File name format: event-logger-backfill-{AWS_REGION}-1-YYYY-MM-DD-{uuid} (AWS_REGION is provided by the Lambda runtime, UUID is randomly generated per file) Other behaviors - server_timestamp is derived from request_time (UTC) and formatted like "YYYY-MM-DD HH:MM:SS.microseconds+00:00". If request_time is missing/invalid, the function raises. """ import base64 import hashlib import json import logging import os import random import time import uuid from datetime import datetime, timezone from typing import Any, Dict, Iterable, List, Optional, Tuple import boto3 from botocore.client import BaseClient from botocore.response import StreamingBody # Logging (overridable via LOG_LEVEL) _LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig(level=getattr(logging, _LOG_LEVEL, logging.INFO)) logger = logging.getLogger(__name__) MAX_RECORDS_PER_BATCH = 500 MAX_BATCH_BYTES = 5 * 1024 * 1024 # 5 MiB PUT_RECORDS_MAX_RETRIES = 5 # Backfill processing configuration (copied from event-logger-parser behavior) whilte_list_event_name = [ 'Audio-Player-Event', 'Audio-Creation-Event', 'Audio-Action-Event', 'Playlist-Action-Event', 'Link-Click-Event', 'Web-Page-Event', 'Artist-Action-Event', 'General-Event', 'Audio-Editing-Event', 'Account-Action-Event', 'Search-Event', 'App-Audio-Player-Event', 'App-Audio-Creation-Event', 'App-Audio-Action-Event', 'App-Playlist-Action-Event', 'App-Link-Click-Event', 'App-Artist-Action-Event', 'App-General-Event', 'App-Audio-Editing-Event', 'App-Account-Action-Event', 'App-Search-Event', 'Web-User-Event', 'Hook-Web-Event', ] required_fields = ['event', 'properties', 'timestamp', 'context'] def is_whitelisted_event(json_value: Dict[str, Any]) -> bool: try: return json_value.get('event') in whilte_list_event_name except Exception: return False def is_required_fields_missing(json_value: Dict[str, Any]) -> bool: for field in required_fields: if field not in json_value: return True return False def _parse_s3_path(path: str) -> Tuple[str, str]: if not path: raise RuntimeError("S3_PATH must be provided, e.g. s3://bucket/prefix") if not path.startswith("s3://"): raise RuntimeError("S3_PATH must start with s3://") without_scheme = path[len("s3://") :] parts = without_scheme.split("/", 1) bucket = parts[0] prefix = parts[1] if len(parts) > 1 else "" if not bucket: raise RuntimeError("S3_PATH missing bucket") return bucket, prefix 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: str) -> datetime: # Try a few common formats; force UTC when ambiguous if not ts: return datetime.now(timezone.utc) try: # Handle trailing Z if ts.endswith('Z'): return datetime.fromisoformat(ts.replace('Z', '+00:00')).astimezone(timezone.utc) return datetime.fromisoformat(ts).astimezone(timezone.utc) except Exception: pass # Fallbacks 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 return datetime.now(timezone.utc) 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 _make_partition_key(raw_data_b64: str, entry: Dict[str, Any]) -> str: meta = entry.get("kinesisRecordMetadata") if isinstance(meta, dict): pk = meta.get("partitionKey") if isinstance(pk, str) and pk: return pk return hashlib.sha256(raw_data_b64.encode("utf-8")).hexdigest() def _send_put_records_with_retries( kinesis: BaseClient, stream_name: str, records: List[Dict[str, Any]] ) -> Tuple[int, int, int]: # Kept for compatibility; not used in new S3 backfill flow. total_success = 0 total_failed = 0 attempts = 0 to_send = records while to_send: attempts += 1 resp = kinesis.put_records(StreamName=stream_name, Records=to_send) failed = int(resp.get("FailedRecordCount", 0)) recs = resp.get("Records", []) if failed == 0: total_success += len(to_send) to_send = [] break retry_list: List[Dict[str, Any]] = [] for idx, res in enumerate(recs): if res.get("ErrorCode"): retry_list.append(to_send[idx]) else: total_success += 1 total_failed += failed if attempts >= PUT_RECORDS_MAX_RETRIES: break sleep_s = (2 ** attempts) * 0.2 + random.random() * 0.3 time.sleep(sleep_s) to_send = retry_list return total_success, total_failed, attempts def _should_split_batch(current_bytes: int, next_data_len: int, num_records: int) -> bool: if num_records >= MAX_RECORDS_PER_BATCH: return True overhead = 8 * num_records # conservative overhead estimate return (current_bytes + next_data_len + overhead) > MAX_BATCH_BYTES def _process_object_to_s3( s3, bucket: str, key: str, dest_bucket: str, dest_prefix: str, ) -> Tuple[int, int, int]: obj = s3.get_object(Bucket=bucket, Key=key) body = obj["Body"] assert isinstance(body, StreamingBody) line_no = 0 processed = 0 dropped = 0 # Collect results and then write grouped by event name/date records: List[Dict[str, Any]] = [] for raw_line in _iter_s3_lines(body): line_no += 1 line = raw_line.strip() if not line: continue dedup_key = f"{bucket}:{key}:{line_no}" try: entry = json.loads(line) except Exception as exc: print(f"Bad JSON at line {line_no}; dedup={dedup_key} err={exc}") continue raw_b64 = entry.get("rawData") if not isinstance(raw_b64, str): print(f"Missing rawData at line {line_no}; dedup={dedup_key}") continue try: outer_bytes = base64.b64decode(raw_b64, validate=True) outer = json.loads(outer_bytes.decode('utf-8')) except Exception as exc: print(f"rawData decode/parse error at line {line_no}; dedup={dedup_key} err={exc}") continue client_ip = outer.get('x-client-ip', '') request_time = outer.get('request-time', '') domain_name = outer.get('domain-name', '') request_body_b64 = outer.get('requst_body') # notice the typo requst_body if not isinstance(request_body_b64, str): print(f"Missing request_body after rawData decode at line {line_no}; dedup={dedup_key}") continue try: inner = json.loads(base64.b64decode(request_body_b64).decode('utf-8')) except Exception as exc: print(f"request_body decode/parse error at line {line_no}; dedup={dedup_key} err={exc}") continue if is_required_fields_missing(inner) or not is_whitelisted_event(inner): dropped += 1 continue result: Dict[str, Any] = {} # server_timestamp must reflect request_time; raise if missing or invalid if not request_time: raise ValueError("request_time missing for event") dt_req = datetime.strptime(request_time, "%d/%b/%Y:%H:%M:%S %z").astimezone(timezone.utc) tz = dt_req.strftime("%z") tz_formatted = tz[:3] + ":" + tz[3:] if len(tz) == 5 else tz server_timestamp_str = f"{dt_req.strftime('%Y-%m-%d %H:%M:%S')}.{dt_req.microsecond:06d}{tz_formatted}" result['server_timestamp'] = server_timestamp_str result['client_ip'] = client_ip result['request_time'] = request_time result['domain_name'] = domain_name for field in required_fields: result[field] = inner[field] anonymous_id = inner.get('anonymousId') if anonymous_id is not None: result['anonymous_id'] = anonymous_id records.append({'result': result}) processed += 1 # Group by (event name, event date & hour) and write to S3 as NDJSON by_event_hour: Dict[Tuple[str, int, int, int, int], List[str]] = {} time_by_group: Dict[Tuple[str, int, int, int, int], str] = {} for rec in records: evt = rec['result'] ts = evt.get('timestamp', '') dt = _parse_event_time(ts) if isinstance(ts, str) else datetime.now(timezone.utc) y, m, d, h = dt.year, dt.month, dt.day, dt.hour event_name = evt.get('event', 'Unknown-Event') line = json.dumps(evt, separators=(",", ":")) group_key = (event_name, y, m, d, h) by_event_hour.setdefault(group_key, []).append(line) if group_key not in time_by_group: time_by_group[group_key] = dt.strftime("%H-%M-%S") region = _extract_region_from_key(key) or os.getenv("AWS_REGION") for (event_name, y, m, d, h), lines in by_event_hour.items(): # Key format: {EventName}/0/year/month/date/hour/event-logger-backfill--1-YYYY-MM-DD- date_str = f"{y}-{m:02d}-{d:02d}" time_str = time_by_group.get((event_name, y, m, d, h), "00-00-00") file_name = f"event-logger-backfill-{region}-1-{date_str}-{time_str}-{uuid.uuid4()}" key_out = f"{event_name}/0/{y}/{m}/{d}/{h}/{file_name}" _s3_put_ndjson(s3, dest_bucket, key_out, lines) print(f"object summary bucket={bucket} key={key} written={sum(len(v) for v in by_event_hour.values())} dropped={dropped}") # Detailed per-event counts event_counts: Dict[str, int] = {} for (ename, _yy, _mm, _dd, _hh), lines in by_event_hour.items(): event_counts[ename] = event_counts.get(ename, 0) + len(lines) if event_counts: details = ", ".join(f"{k}:{v}" for k, v in sorted(event_counts.items())) print(f"object event_counts: {details}") return processed, dropped, 0 def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: started = time.monotonic() # Source (fixed bucket and path pattern) source_date = _get_env("SOURCE_DATE") try: parts = source_date.strip().split("/") if len(parts) != 3: raise ValueError("SOURCE_DATE must be 'YYYY/MM/DD'") y, m, d = int(parts[0]), int(parts[1]), int(parts[2]) except Exception: raise RuntimeError("Invalid SOURCE_DATE; expected 'YYYY/MM/DD', e.g. '2025/09/02'") bucket_name = _get_env("EVENT_LOGGER_BUCKET") bucket = bucket_name # Source requires zero-padded month/day prefix = f"eerror/processing-failed/{y}/{m:02d}/{d:02d}" # Destination (same bucket; per-event prefixes are determined per record) dest_bucket = bucket_name dest_prefix = "" # unused; kept for signature compatibility s3 = boto3.client("s3") total_written = 0 total_dropped = 0 print(f"backfill start src_bucket={bucket} src_prefix={prefix} dest_bucket={dest_bucket}") cont: Optional[str] = None while True: kwargs: Dict[str, Any] = {"Bucket": 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"] written, dropped, _ = _process_object_to_s3(s3, bucket, key, dest_bucket, dest_prefix) total_written += written total_dropped += dropped if page.get("IsTruncated"): cont = page.get("NextContinuationToken") else: break elapsed = time.monotonic() - started print(f"backfill summary src_bucket={bucket} src_prefix={prefix} dest_bucket={dest_bucket} written={total_written} dropped={total_dropped} elapsed_s={elapsed:.3f}") return { "status": "ok", "src_bucket": bucket, "src_prefix": prefix, "dest_bucket": dest_bucket, "dest_prefix": None, "written": total_written, "dropped": total_dropped, "elapsed_s": round(elapsed, 3), }