From e2bc23e4e40a029cbacfcd724496b93a17ed3499 Mon Sep 17 00:00:00 2001 From: James Acres Date: Mon, 1 Sep 2025 20:15:46 +0100 Subject: [PATCH 1/7] chore: adjust dynamodb provisioning to 10 --- deploy/lib/api-stack.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/lib/api-stack.ts b/deploy/lib/api-stack.ts index c977b83..3a3a517 100644 --- a/deploy/lib/api-stack.ts +++ b/deploy/lib/api-stack.ts @@ -114,8 +114,8 @@ export class ApiStack extends Stack { pointInTimeRecovery: true, timeToLiveAttribute: 'expiresAt', deletionProtection: true, - readCapacity: 25, - writeCapacity: 25, + readCapacity: 10, + writeCapacity: 10, }); // As per src/dynamodb/dynamodb-adapter.ts define secondary indexes From a51afee6d8f29bc25b70536b10fa18f649ff426d Mon Sep 17 00:00:00 2001 From: James Acres Date: Sun, 15 Feb 2026 19:41:39 +0000 Subject: [PATCH 2/7] feat: analytics --- .../.config.kiro | 1 + .../analytics-lambda-infrastructure/design.md | 1040 ++++++++ .../requirements.md | 296 +++ .../analytics-lambda-infrastructure/tasks.md | 271 ++ deploy/.env.dev | 2 + deploy/jest.config.js | 4 +- .../lib/analytics-lambda/aggregator-core.ts | 211 ++ deploy/lib/analytics-lambda/aggregator.ts | 132 + .../analytics-lambda/analytics-writer.spec.ts | 143 + .../lib/analytics-lambda/analytics-writer.ts | 127 + .../analytics-lambda/cloudwatch-publisher.ts | 134 + deploy/lib/analytics-lambda/export-trigger.ts | 109 + deploy/lib/analytics-lambda/s3-reader.ts | 195 ++ .../lib/analytics-lambda/shared/date-utils.ts | 33 + .../analytics-lambda/shared/pattern-utils.ts | 63 + deploy/lib/analytics-lambda/shared/types.ts | 27 + deploy/lib/api-stack.ts | 236 +- deploy/package-lock.json | 2301 +++++++++++++++-- deploy/package.json | 6 +- deploy/test/__snapshots__/deploy.test.ts.snap | 1012 +++++++- deploy/test/deploy.test.ts | 4 + docs/analytics-backfill.md | 323 +++ 22 files changed, 6437 insertions(+), 233 deletions(-) create mode 100644 .kiro/specs/analytics-lambda-infrastructure/.config.kiro create mode 100644 .kiro/specs/analytics-lambda-infrastructure/design.md create mode 100644 .kiro/specs/analytics-lambda-infrastructure/requirements.md create mode 100644 .kiro/specs/analytics-lambda-infrastructure/tasks.md create mode 100644 deploy/lib/analytics-lambda/aggregator-core.ts create mode 100644 deploy/lib/analytics-lambda/aggregator.ts create mode 100644 deploy/lib/analytics-lambda/analytics-writer.spec.ts create mode 100644 deploy/lib/analytics-lambda/analytics-writer.ts create mode 100644 deploy/lib/analytics-lambda/cloudwatch-publisher.ts create mode 100644 deploy/lib/analytics-lambda/export-trigger.ts create mode 100644 deploy/lib/analytics-lambda/s3-reader.ts create mode 100644 deploy/lib/analytics-lambda/shared/date-utils.ts create mode 100644 deploy/lib/analytics-lambda/shared/pattern-utils.ts create mode 100644 deploy/lib/analytics-lambda/shared/types.ts create mode 100644 docs/analytics-backfill.md diff --git a/.kiro/specs/analytics-lambda-infrastructure/.config.kiro b/.kiro/specs/analytics-lambda-infrastructure/.config.kiro new file mode 100644 index 0000000..a7264bd --- /dev/null +++ b/.kiro/specs/analytics-lambda-infrastructure/.config.kiro @@ -0,0 +1 @@ +{"generationMode": "requirements-first"} diff --git a/.kiro/specs/analytics-lambda-infrastructure/design.md b/.kiro/specs/analytics-lambda-infrastructure/design.md new file mode 100644 index 0000000..88a3055 --- /dev/null +++ b/.kiro/specs/analytics-lambda-infrastructure/design.md @@ -0,0 +1,1040 @@ +# Design Document: Analytics Lambda Infrastructure + +## Overview + +This design implements a serverless analytics infrastructure for the +bubblyclouds-api using AWS CDK. The system collects usage metrics from the main +DynamoDB table through scheduled Lambda functions, stores aggregated data in a +dedicated analytics table, and visualizes metrics through a CloudWatch +dashboard. + +The architecture leverages DynamoDB's Point-in-Time Recovery (PITR) export +feature to extract data without impacting production table capacity. Two Lambda +functions orchestrate the process: one triggers daily exports to S3, and another +reads the exports, aggregates metrics, and publishes to CloudWatch and the +analytics table. + +The system supports both incremental daily processing and historical backfill +from January 17, 2025, enabling complete historical analytics coverage. + +## Architecture + +### High-Level Flow + +```mermaid +graph LR + A[EventBridge
2:00 AM UTC] --> B[Export Lambda] + B --> C[DynamoDB PITR Export] + C --> D[S3 Export Bucket] + E[EventBridge
2:30 AM UTC] --> F[Aggregator Lambda] + D --> F + F --> G[CloudWatch Metrics] + F --> H[Analytics Table] + I[Main Table] -.PITR.-> C +``` + +### Component Interaction + +1. **Export Phase (2:00 AM UTC)** + - EventBridge triggers Export Lambda + - Export Lambda calls DynamoDB ExportTableToPointInTime API + - DynamoDB exports data to S3 (incremental or full based on mode) + - Export completes asynchronously (typically 5-15 minutes for incremental) + +2. **Aggregation Phase (2:30 AM UTC)** + - EventBridge triggers Aggregator Lambda + - Aggregator Lambda reads export manifest from S3 + - Aggregator Lambda processes export data files + - Aggregator Lambda aggregates metrics by app and date + - Aggregator Lambda publishes metrics to CloudWatch + - Aggregator Lambda writes detailed records to Analytics Table + +### Data Flow + +``` +Main Table (sessions, parties, members) + ↓ (PITR Export) +S3 Export Files (DynamoDB JSON format) + ↓ (Parse & Aggregate) +In-Memory Metrics (by app, by date) + ↓ (Publish) +CloudWatch Metrics + Analytics Table +``` + +## Components and Interfaces + +### 1. Export Lambda Function + +**Purpose**: Trigger DynamoDB table export to S3 + +**Configuration**: + +- Runtime: Node.js 20.x +- Memory: 128 MB +- Timeout: 30 seconds +- Trigger: EventBridge rule (cron: 0 2 * * ? *) + +**Environment Variables**: + +- `TABLE_NAME`: ARN of the main DynamoDB table +- `EXPORT_BUCKET`: Name of the S3 export bucket +- `MODE`: "incremental" or "backfill" +- `BACKFILL_START_DATE`: ISO date string (e.g., "2025-01-17") - only used in + backfill mode + +**IAM Permissions**: + +- `dynamodb:ExportTableToPointInTime` on Main Table +- `s3:PutObject` on Export Bucket +- `s3:AbortMultipartUpload` on Export Bucket + +**Handler Logic**: + +```typescript +interface ExportRequest { + mode: "incremental" | "backfill"; + backfillStartDate?: string; +} + +async function handler(event: ExportRequest): Promise { + const exportParams = { + TableArn: process.env.TABLE_NAME, + S3Bucket: process.env.EXPORT_BUCKET, + S3Prefix: `exports/${Date.now()}/`, + ExportFormat: "DYNAMODB_JSON", + ExportType: event.mode === "incremental" + ? "INCREMENTAL_EXPORT" + : "FULL_EXPORT", + }; + + if (event.mode === "incremental") { + // Get last export time from previous run (stored in S3 or parameter store) + exportParams.IncrementalExportSpecification = { + ExportFromTime: lastExportTime, + ExportToTime: Date.now(), + }; + } + + await dynamodb.exportTableToPointInTime(exportParams); +} +``` + +### 2. Aggregator Lambda Function + +**Purpose**: Read S3 exports, aggregate metrics, publish to CloudWatch and +Analytics Table + +**Configuration**: + +- Runtime: Node.js 20.x +- Memory: 256 MB +- Timeout: 5 minutes (300 seconds) +- Trigger: EventBridge rule (cron: 30 2 * * ? *) + +**Environment Variables**: + +- `EXPORT_BUCKET`: Name of the S3 export bucket +- `ANALYTICS_TABLE`: Name of the Analytics DynamoDB table +- `MODE`: "incremental" or "backfill" +- `BACKFILL_START_DATE`: ISO date string - only used in backfill mode + +**IAM Permissions**: + +- `s3:GetObject` on Export Bucket +- `s3:ListBucket` on Export Bucket +- `cloudwatch:PutMetricData` (no resource restriction) +- `dynamodb:PutItem` on Analytics Table + +**Handler Logic**: + +```typescript +interface AggregatorEvent { + mode: "incremental" | "backfill"; + backfillStartDate?: string; +} + +interface DailyMetrics { + date: string; // YYYY-MM-DD + app: string; + activeUserIds: Set; + gamesPerUser: Map; + partiesCreatedPerUser: Map; + partiesJoined: number; +} + +async function handler(event: AggregatorEvent): Promise { + // 1. Find latest export in S3 + const exportPrefix = await findLatestExport(); + + // 2. Read export manifest + const manifest = await readManifest(exportPrefix); + + // 3. Process all data files + const metricsByDateAndApp = new Map>(); + + for (const dataFile of manifest.dataFiles) { + const items = await readDataFile(dataFile); + + for (const item of items) { + processItem(item, metricsByDateAndApp, event); + } + } + + // 4. Publish metrics to CloudWatch and Analytics Table + for (const [date, appMetrics] of metricsByDateAndApp) { + for (const [app, metrics] of appMetrics) { + await publishMetrics(date, app, metrics); + await writeAnalyticsRecord(date, app, metrics); + } + } +} + +function processItem( + item: DynamoDBItem, + metricsByDateAndApp: Map>, + event: AggregatorEvent, +): void { + const modelId = item.modelId.S; + const owner = item.owner.S; + + // Extract app and determine date based on item type + if (modelId.startsWith("session-")) { + // session-{app}-{id} + const app = modelId.split("-")[1]; + const updatedAt = new Date(item.updatedAt.S); + const date = formatDate(updatedAt); + + if (shouldIncludeDate(date, event)) { + const metrics = getOrCreateMetrics(metricsByDateAndApp, date, app); + const userId = owner.replace("user-", ""); + metrics.activeUserIds.add(userId); + metrics.gamesPerUser.set( + userId, + (metrics.gamesPerUser.get(userId) || 0) + 1, + ); + } + } else if (modelId.startsWith("party-")) { + // party-{app}-{id} + const app = modelId.split("-")[1]; + const createdAt = new Date(item.createdAt.S); + const date = formatDate(createdAt); + + if (shouldIncludeDate(date, event)) { + const metrics = getOrCreateMetrics(metricsByDateAndApp, date, app); + const userId = owner.replace("user-", ""); + metrics.partiesCreatedPerUser.set( + userId, + (metrics.partiesCreatedPerUser.get(userId) || 0) + 1, + ); + } + } else if (modelId.startsWith("member-user-")) { + // member-user-{userId}, owner = party-{app}-{id} + const app = owner.split("-")[1]; + const createdAt = new Date(item.createdAt.S); + const date = formatDate(createdAt); + + if (shouldIncludeDate(date, event)) { + const metrics = getOrCreateMetrics(metricsByDateAndApp, date, app); + metrics.partiesJoined++; + } + } +} + +function shouldIncludeDate(date: string, event: AggregatorEvent): boolean { + if (event.mode === "incremental") { + // Only include yesterday + const yesterday = getYesterday(); + return date === yesterday; + } else { + // Include all dates from backfillStartDate to yesterday + return date >= event.backfillStartDate && date <= getYesterday(); + } +} + +async function publishMetrics( + date: string, + app: string, + metrics: DailyMetrics, +): Promise { + const timestamp = new Date(`${date}T00:00:00Z`); + + await cloudwatch.putMetricData({ + Namespace: "BubblyClouds/Analytics", + MetricData: [ + { + MetricName: "ActiveUsers", + Value: metrics.activeUserIds.size, + Timestamp: timestamp, + Dimensions: [{ Name: "App", Value: app }], + }, + { + MetricName: "GamesPlayed", + Value: Array.from(metrics.gamesPerUser.values()).reduce( + (a, b) => a + b, + 0, + ), + Timestamp: timestamp, + Dimensions: [{ Name: "App", Value: app }], + }, + { + MetricName: "PartiesCreated", + Value: Array.from(metrics.partiesCreatedPerUser.values()).reduce( + (a, b) => a + b, + 0, + ), + Timestamp: timestamp, + Dimensions: [{ Name: "App", Value: app }], + }, + { + MetricName: "PartiesJoined", + Value: metrics.partiesJoined, + Timestamp: timestamp, + Dimensions: [{ Name: "App", Value: app }], + }, + ], + }); +} + +async function writeAnalyticsRecord( + date: string, + app: string, + metrics: DailyMetrics, +): Promise { + const expiresAt = Math.floor(Date.now() / 1000) + (455 * 24 * 60 * 60); // 455 days + + await dynamodb.putItem({ + TableName: process.env.ANALYTICS_TABLE, + Item: { + date: { S: date }, + app: { S: app }, + activeUserIds: { SS: Array.from(metrics.activeUserIds) }, + gamesPerUser: { M: mapToAttributeValue(metrics.gamesPerUser) }, + partiesCreatedPerUser: { + M: mapToAttributeValue(metrics.partiesCreatedPerUser), + }, + partiesJoined: { N: metrics.partiesJoined.toString() }, + summary: { + M: { + activeUsers: { N: metrics.activeUserIds.size.toString() }, + gamesPlayed: { + N: Array.from(metrics.gamesPerUser.values()).reduce( + (a, b) => a + b, + 0, + ).toString(), + }, + partiesCreated: { + N: Array.from(metrics.partiesCreatedPerUser.values()).reduce( + (a, b) => a + b, + 0, + ).toString(), + }, + partiesJoined: { N: metrics.partiesJoined.toString() }, + }, + }, + expiresAt: { N: expiresAt.toString() }, + }, + }); +} +``` + +### 3. Analytics DynamoDB Table + +**Purpose**: Store per-user daily metric breakdowns with 455-day retention + +**Schema**: + +```typescript +interface AnalyticsRecord { + date: string; // PK: YYYY-MM-DD + app: string; // SK: app identifier + activeUserIds: string[]; // List of user IDs active on this date + gamesPerUser: Record; // userId -> game count + partiesCreatedPerUser: Record; // userId -> party count + partiesJoined: number; // Total party joins + summary: { + activeUsers: number; + gamesPlayed: number; + partiesCreated: number; + partiesJoined: number; + }; + expiresAt: number; // TTL timestamp (455 days) +} +``` + +**Configuration**: + +- Partition Key: `date` (String) +- Sort Key: `app` (String) +- Provisioned Capacity: 1 RCU, 1 WCU (minimum) +- TTL Attribute: `expiresAt` +- TTL Duration: 455 days (matches CloudWatch retention) + +**Access Patterns**: + +- Write: One record per app per day (low frequency) +- Read: Rare, primarily for debugging or custom queries +- Query: By date to get all apps for a specific day +- Query: By date and app for specific app metrics + +### 4. S3 Export Bucket + +**Purpose**: Temporary storage for DynamoDB export files + +**Configuration**: + +- Region: eu-west-2 +- Lifecycle Policy: Delete objects after 1 day +- Removal Policy: Destroy (auto-delete on stack removal) +- Versioning: Disabled +- Encryption: Default (SSE-S3) + +**Folder Structure**: + +``` +exports/ + {timestamp}/ + AWSDynamoDB/ + {export-id}/ + manifest-summary.json + manifest-files.json + data/ + {file-1}.json.gz + {file-2}.json.gz + ... +``` + +### 5. CloudWatch Dashboard + +**Purpose**: Visualize application metrics with auto-discovered app dimensions + +**Configuration**: + +- Region: eu-west-2 +- Retention: 455 days (CloudWatch default for custom metrics) +- Cost: $3/month per dashboard + +**Metrics**: + +- Namespace: `BubblyClouds/Analytics` +- Metrics: + - `ActiveUsers` (dimension: App) + - `GamesPlayed` (dimension: App) + - `PartiesCreated` (dimension: App) + - `PartiesJoined` (dimension: App) + +**Dashboard Layout**: + +```json +{ + "widgets": [ + { + "type": "metric", + "properties": { + "metrics": [ + ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"ActiveUsers\"', 'Sum', 86400)"] + ], + "title": "Active Users by App", + "period": 86400, + "stat": "Sum", + "region": "eu-west-2" + } + }, + { + "type": "metric", + "properties": { + "metrics": [ + ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"GamesPlayed\"', 'Sum', 86400)"] + ], + "title": "Games Played by App", + "period": 86400, + "stat": "Sum", + "region": "eu-west-2" + } + }, + { + "type": "metric", + "properties": { + "metrics": [ + ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"PartiesCreated\"', 'Sum', 86400)"] + ], + "title": "Parties Created by App", + "period": 86400, + "stat": "Sum", + "region": "eu-west-2" + } + }, + { + "type": "metric", + "properties": { + "metrics": [ + ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"PartiesJoined\"', 'Sum', 86400)"] + ], + "title": "Parties Joined by App", + "period": 86400, + "stat": "Sum", + "region": "eu-west-2" + } + } + ] +} +``` + +### 6. EventBridge Rules + +**Export Rule**: + +- Schedule: `cron(0 2 * * ? *)` - 2:00 AM UTC daily +- Target: Export Lambda +- Input: `{ "mode": "incremental" }` + +**Aggregator Rule**: + +- Schedule: `cron(30 2 * * ? *)` - 2:30 AM UTC daily +- Target: Aggregator Lambda +- Input: `{ "mode": "incremental" }` + +**Backfill Execution** (manual): + +- Invoke Export Lambda with: + `{ "mode": "backfill", "backfillStartDate": "2025-01-17" }` +- Wait for export completion (~30-60 minutes for full table) +- Invoke Aggregator Lambda with: + `{ "mode": "backfill", "backfillStartDate": "2025-01-17" }` + +## Data Models + +### DynamoDB Export Format + +DynamoDB exports use the DynamoDB JSON format: + +```json +{ + "Item": { + "modelId": { "S": "session-sudoku-abc123" }, + "owner": { "S": "user-user456" }, + "updatedAt": { "S": "2025-01-20T14:30:00.000Z" }, + "createdAt": { "S": "2025-01-20T14:00:00.000Z" } + } +} +``` + +### Metric Extraction Patterns + +**Session Records** (Active Users & Games Played): + +- Pattern: `modelId = "session-{app}-{id}"` +- User ID: Extract from `owner = "user-{userId}"` +- Date: Extract from `updatedAt` timestamp +- Active Users: Count distinct user IDs per app per date +- Games Played: Count all sessions per app per date + +**Party Records** (Parties Created): + +- Pattern: `modelId = "party-{app}-{id}"` +- User ID: Extract from `owner = "user-{userId}"` +- Date: Extract from `createdAt` timestamp +- Parties Created: Count all parties per app per date + +**Member Records** (Parties Joined): + +- Pattern: `modelId = "member-user-{userId}"`, `owner = "party-{app}-{id}"` +- App: Extract from `owner` attribute +- Date: Extract from `createdAt` timestamp +- Parties Joined: Count all member records per app per date + +### CloudWatch Metric Format + +```typescript +interface CloudWatchMetric { + MetricName: + | "ActiveUsers" + | "GamesPlayed" + | "PartiesCreated" + | "PartiesJoined"; + Value: number; + Timestamp: Date; // Start of target day (00:00:00 UTC) + Dimensions: [ + { Name: "App"; Value: string }, + ]; + Unit: "Count"; +} +``` + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all +valid executions of a system—essentially, a formal statement about what the +system should do. Properties serve as the bridge between human-readable +specifications and machine-verifiable correctness guarantees._ + +### Property Reflection + +After analyzing all acceptance criteria, I identified the following +redundancies: + +**Duplicate Properties to Consolidate:** + +- 3.1 and 4.1: Both test date filtering for session records - combine into one + property +- 3.3, 4.3, 5.3: All test app extraction from modelId - combine into one + property +- 3.4, 4.4, 5.4, 6.4: All test timestamp formatting to midnight UTC - combine + into one property +- 6.2 and 6.3: Both test app extraction from member owner attribute - combine + into one property +- 1.3 and 11.1: Both test full export in backfill mode - combine into one + property +- 2.3 and 11.2: Both test backfill date range processing - combine into one + property +- 1.2 and 13.1: Both test incremental export mode - combine into one property + +**Properties to Keep Separate:** + +- 3.2 (distinct user ID extraction), 3.5 (storing user IDs), 4.2 (session + pattern matching), 4.5 (per-user game counts), 5.2 (party pattern matching), + 5.5 (per-user party counts), 6.1 (member date filtering), 6.5 (total parties + joined count), 11.3 (timestamp field selection), 11.4 (record partitioning), + 11.5 (backfill metric timestamps) - each provides unique validation value + +### Correctness Properties + +Property 1: Session date filtering _For any_ set of DynamoDB records and target +date, when filtering session records, only records with modelId starting with +"session-" and updatedAt timestamp within the target day should be included in +the result set. **Validates: Requirements 3.1, 4.1** + +Property 2: Distinct user ID extraction from sessions _For any_ set of session +records for a given app and date, the count of active users should equal the +number of distinct user IDs extracted from the owner attribute. **Validates: +Requirements 3.2** + +Property 3: App extraction from modelId _For any_ record with modelId pattern +"{type}-{app}-{id}", the extracted app value should equal the second segment +when split by hyphen. **Validates: Requirements 3.3, 4.3, 5.3** + +Property 4: Metric timestamp formatting _For any_ target date string in +YYYY-MM-DD format, the CloudWatch metric timestamp should be set to the start of +that day at 00:00:00 UTC. **Validates: Requirements 3.4, 4.4, 5.4, 6.4** + +Property 5: Active user IDs storage completeness _For any_ set of session +records aggregated for a date and app, the activeUserIds array stored in +Analytics_Table should contain all distinct user IDs from those sessions. +**Validates: Requirements 3.5** + +Property 6: Session pattern matching _For any_ set of DynamoDB records, when +counting games played, only records with modelId matching the pattern +"session-{app}-{id}" should be included in the count. **Validates: Requirements +4.2** + +Property 7: Per-user game count aggregation _For any_ set of session records for +a given app and date, each user's game count in gamesPerUser should equal the +number of session records with that user's ID in the owner attribute. +**Validates: Requirements 4.5** + +Property 8: Party date filtering _For any_ set of DynamoDB records and target +date, when filtering party records, only records with modelId starting with +"party-" and createdAt timestamp within the target day should be included in the +result set. **Validates: Requirements 5.1** + +Property 9: Party pattern matching _For any_ set of DynamoDB records, when +counting parties created, only records with modelId matching the pattern +"party-{app}-{id}" should be included in the count. **Validates: Requirements +5.2** + +Property 10: Per-user party creation count aggregation _For any_ set of party +records for a given app and date, each user's party creation count in +partiesCreatedPerUser should equal the number of party records with that user's +ID in the owner attribute. **Validates: Requirements 5.5** + +Property 11: Member date filtering _For any_ set of DynamoDB records and target +date, when filtering member records, only records with modelId starting with +"member-user-" and createdAt timestamp within the target day should be included +in the result set. **Validates: Requirements 6.1** + +Property 12: App extraction from member owner attribute _For any_ member record +with owner pattern "party-{app}-{id}", the extracted app value should equal the +second segment when split by hyphen. **Validates: Requirements 6.2, 6.3** + +Property 13: Parties joined count aggregation _For any_ set of member records +for a given app and date, the partiesJoined count should equal the total number +of member records. **Validates: Requirements 6.5** + +Property 14: Timestamp field selection by record type _For any_ DynamoDB record, +when determining the date for aggregation, session records should use the +updatedAt field, while party and member records should use the createdAt field. +**Validates: Requirements 11.3** + +Property 15: Analytics record partitioning _For any_ set of aggregated metrics, +there should be exactly one Analytics_Table record written per unique +combination of date and app. **Validates: Requirements 11.4** + +Property 16: Backfill metric timestamp distribution _For any_ date range in +backfill mode, each date should have CloudWatch metrics published with a +timestamp set to midnight UTC of that specific date. **Validates: Requirements +11.5** + +## Error Handling + +### Export Lambda Error Handling + +**Export API Failures**: + +- Catch `DynamoDB.ExportTableToPointInTime` errors +- Log error details including table ARN, bucket name, and error message +- Throw error to trigger Lambda failure (for CloudWatch alerting) +- Do not retry automatically (EventBridge will retry on next schedule) + +**Configuration Errors**: + +- Validate environment variables on cold start +- Throw descriptive error if TABLE_NAME or EXPORT_BUCKET is missing +- Validate mode parameter is either "incremental" or "backfill" + +**Example Error Handling**: + +```typescript +try { + validateEnvironment(); + const exportParams = buildExportParams(event); + await dynamodb.exportTableToPointInTime(exportParams); + console.log("Export initiated successfully", { exportParams }); +} catch (error) { + console.error("Export failed", { + error: error.message, + stack: error.stack, + tableName: process.env.TABLE_NAME, + bucket: process.env.EXPORT_BUCKET, + mode: event.mode, + }); + throw error; +} +``` + +### Aggregator Lambda Error Handling + +**S3 Read Failures**: + +- Catch S3 errors when reading manifest or data files +- Log error details including bucket, key, and error message +- Throw error to trigger Lambda failure + +**Malformed Data Handling**: + +- Wrap individual record processing in try-catch +- Log malformed record details (modelId, owner if available) +- Continue processing remaining records +- Track count of malformed records +- Log summary at end of processing + +**CloudWatch Publishing Failures**: + +- Catch `CloudWatch.PutMetricData` errors +- Log error details including metric names and dimensions +- Continue processing (don't fail entire job for metric publishing) +- Attempt to write to Analytics_Table even if CloudWatch fails + +**DynamoDB Write Failures**: + +- Catch `DynamoDB.PutItem` errors +- Log error details including date, app, and error message +- Continue processing remaining records +- Track count of failed writes + +**Example Error Handling**: + +```typescript +async function processItems(items: DynamoDBItem[]): Promise { + let malformedCount = 0; + + for (const item of items) { + try { + processItem(item, metricsByDateAndApp, event); + } catch (error) { + malformedCount++; + console.warn("Malformed record skipped", { + modelId: item.modelId?.S, + owner: item.owner?.S, + error: error.message, + }); + } + } + + if (malformedCount > 0) { + console.warn(`Skipped ${malformedCount} malformed records`); + } +} + +async function publishMetrics( + date: string, + app: string, + metrics: DailyMetrics, +): Promise { + try { + await cloudwatch.putMetricData(buildMetricData(date, app, metrics)); + } catch (error) { + console.error("Failed to publish CloudWatch metrics", { + date, + app, + error: error.message, + }); + // Don't throw - continue to write Analytics_Table + } +} +``` + +### Timeout Handling + +**Export Lambda** (30s timeout): + +- Export API call is asynchronous (returns immediately) +- Timeout should never be reached under normal conditions +- If timeout occurs, indicates AWS API issue + +**Aggregator Lambda** (5min timeout): + +- Monitor processing time for large exports +- Log progress periodically (every 1000 records) +- If approaching timeout, log warning with current progress +- Consider increasing timeout if table grows significantly + +### Retry Strategy + +**Export Lambda**: + +- No automatic retries within Lambda +- EventBridge will trigger next day's export on schedule +- Manual retry: Re-invoke Lambda with same parameters + +**Aggregator Lambda**: + +- No automatic retries within Lambda +- EventBridge will trigger next day's aggregation on schedule +- Manual retry: Re-invoke Lambda with same parameters +- Idempotent: Re-running for same date will overwrite previous Analytics_Table + records + +## Testing Strategy + +### Unit Testing + +Unit tests will verify specific examples, edge cases, and error conditions for +both Lambda functions and CDK infrastructure. + +**Export Lambda Unit Tests**: + +- Test incremental mode constructs correct API parameters +- Test backfill mode constructs correct API parameters +- Test environment variable validation +- Test error handling for missing configuration +- Test error logging format + +**Aggregator Lambda Unit Tests**: + +- Test S3 manifest parsing +- Test DynamoDB JSON format parsing +- Test date extraction from timestamps +- Test app extraction from modelId patterns +- Test app extraction from owner patterns +- Test Analytics_Table record structure +- Test CloudWatch metric structure +- Test TTL calculation (455 days) +- Test error handling for malformed records +- Test error logging format + +**CDK Infrastructure Unit Tests**: + +- Test Analytics_Table has correct schema (PK: date, SK: app) +- Test Analytics_Table has TTL enabled +- Test Analytics_Table has minimum capacity (1 RCU, 1 WCU) +- Test Export_Bucket has 1-day lifecycle policy +- Test Export_Bucket has destroy removal policy +- Test Export Lambda has correct timeout (30s) and memory (128 MB) +- Test Aggregator Lambda has correct timeout (5min) and memory (256 MB) +- Test EventBridge rules have correct schedules (2:00 AM and 2:30 AM UTC) +- Test EventBridge rules have 30-minute gap +- Test Export Lambda IAM policy includes dynamodb:ExportTableToPointInTime on + Main_Table +- Test Export Lambda IAM policy includes s3:PutObject on Export_Bucket +- Test Aggregator Lambda IAM policy includes s3:GetObject and s3:ListBucket on + Export_Bucket +- Test Aggregator Lambda IAM policy includes cloudwatch:PutMetricData +- Test Aggregator Lambda IAM policy includes dynamodb:PutItem on Analytics_Table +- Test CloudWatch Dashboard includes all four metrics +- Test CloudWatch Dashboard uses SEARCH expressions + +### Property-Based Testing + +Property tests will verify universal properties across all inputs using a +property-based testing library. Each test will run a minimum of 100 iterations +with randomized inputs. + +**Testing Library**: Use `fast-check` for TypeScript property-based testing + +**Property Test 1: Session date filtering** + +- Generate: Random sets of DynamoDB records with various modelId patterns and + timestamps +- Property: Only records with modelId starting with "session-" and updatedAt + within target day are included +- Tag: **Feature: analytics-lambda-infrastructure, Property 1: Session date + filtering** + +**Property Test 2: Distinct user ID extraction from sessions** + +- Generate: Random sets of session records with duplicate and unique user IDs +- Property: Active user count equals number of distinct user IDs +- Tag: **Feature: analytics-lambda-infrastructure, Property 2: Distinct user ID + extraction from sessions** + +**Property Test 3: App extraction from modelId** + +- Generate: Random modelId strings with pattern "{type}-{app}-{id}" +- Property: Extracted app equals second segment +- Tag: **Feature: analytics-lambda-infrastructure, Property 3: App extraction + from modelId** + +**Property Test 4: Metric timestamp formatting** + +- Generate: Random date strings in YYYY-MM-DD format +- Property: Timestamp is set to midnight UTC of that date +- Tag: **Feature: analytics-lambda-infrastructure, Property 4: Metric timestamp + formatting** + +**Property Test 5: Active user IDs storage completeness** + +- Generate: Random sets of session records +- Property: Stored activeUserIds contains all distinct user IDs +- Tag: **Feature: analytics-lambda-infrastructure, Property 5: Active user IDs + storage completeness** + +**Property Test 6: Session pattern matching** + +- Generate: Random sets of records with various modelId patterns +- Property: Only records matching "session-{app}-{id}" are counted +- Tag: **Feature: analytics-lambda-infrastructure, Property 6: Session pattern + matching** + +**Property Test 7: Per-user game count aggregation** + +- Generate: Random sets of session records with various user IDs +- Property: Each user's count equals their number of sessions +- Tag: **Feature: analytics-lambda-infrastructure, Property 7: Per-user game + count aggregation** + +**Property Test 8: Party date filtering** + +- Generate: Random sets of party records with various timestamps +- Property: Only records with createdAt within target day are included +- Tag: **Feature: analytics-lambda-infrastructure, Property 8: Party date + filtering** + +**Property Test 9: Party pattern matching** + +- Generate: Random sets of records with various modelId patterns +- Property: Only records matching "party-{app}-{id}" are counted +- Tag: **Feature: analytics-lambda-infrastructure, Property 9: Party pattern + matching** + +**Property Test 10: Per-user party creation count aggregation** + +- Generate: Random sets of party records with various user IDs +- Property: Each user's count equals their number of created parties +- Tag: **Feature: analytics-lambda-infrastructure, Property 10: Per-user party + creation count aggregation** + +**Property Test 11: Member date filtering** + +- Generate: Random sets of member records with various timestamps +- Property: Only records with createdAt within target day are included +- Tag: **Feature: analytics-lambda-infrastructure, Property 11: Member date + filtering** + +**Property Test 12: App extraction from member owner attribute** + +- Generate: Random owner strings with pattern "party-{app}-{id}" +- Property: Extracted app equals second segment +- Tag: **Feature: analytics-lambda-infrastructure, Property 12: App extraction + from member owner attribute** + +**Property Test 13: Parties joined count aggregation** + +- Generate: Random sets of member records +- Property: Total count equals number of member records +- Tag: **Feature: analytics-lambda-infrastructure, Property 13: Parties joined + count aggregation** + +**Property Test 14: Timestamp field selection by record type** + +- Generate: Random DynamoDB records of different types (session, party, member) +- Property: Sessions use updatedAt, parties and members use createdAt +- Tag: **Feature: analytics-lambda-infrastructure, Property 14: Timestamp field + selection by record type** + +**Property Test 15: Analytics record partitioning** + +- Generate: Random sets of aggregated metrics with various date/app combinations +- Property: Exactly one record per unique (date, app) combination +- Tag: **Feature: analytics-lambda-infrastructure, Property 15: Analytics record + partitioning** + +**Property Test 16: Backfill metric timestamp distribution** + +- Generate: Random date ranges +- Property: Each date has metrics with timestamp at midnight UTC of that date +- Tag: **Feature: analytics-lambda-infrastructure, Property 16: Backfill metric + timestamp distribution** + +### Integration Testing + +Integration tests will verify end-to-end flows with actual AWS services (using +LocalStack or AWS test accounts): + +- Test Export Lambda triggers DynamoDB export successfully +- Test Aggregator Lambda reads S3 export and writes to Analytics_Table +- Test Aggregator Lambda publishes metrics to CloudWatch +- Test EventBridge rules trigger Lambdas on schedule +- Test IAM permissions allow required operations +- Test IAM permissions deny unauthorized operations + +### Manual Testing + +Manual testing will verify the complete system: + +- Deploy CDK stack to test environment +- Trigger Export Lambda manually with incremental mode +- Wait for export completion +- Trigger Aggregator Lambda manually +- Verify Analytics_Table contains expected records +- Verify CloudWatch metrics are published +- Verify CloudWatch Dashboard displays metrics correctly +- Test backfill mode with historical date range +- Verify cost estimates match actual AWS billing + +### Test Data Generation + +For property-based tests, generate realistic test data: + +```typescript +// Example generators using fast-check +const sessionRecordGen = fc.record({ + modelId: fc.tuple(fc.constant("session"), fc.string(), fc.uuid()) + .map(([prefix, app, id]) => `${prefix}-${app}-${id}`), + owner: fc.uuid().map((id) => `user-${id}`), + updatedAt: fc.date().map((d) => d.toISOString()), + createdAt: fc.date().map((d) => d.toISOString()), +}); + +const partyRecordGen = fc.record({ + modelId: fc.tuple(fc.constant("party"), fc.string(), fc.uuid()) + .map(([prefix, app, id]) => `${prefix}-${app}-${id}`), + owner: fc.uuid().map((id) => `user-${id}`), + createdAt: fc.date().map((d) => d.toISOString()), +}); + +const memberRecordGen = fc.record({ + modelId: fc.uuid().map((id) => `member-user-${id}`), + owner: fc.tuple(fc.constant("party"), fc.string(), fc.uuid()) + .map(([prefix, app, id]) => `${prefix}-${app}-${id}`), + createdAt: fc.date().map((d) => d.toISOString()), +}); +``` diff --git a/.kiro/specs/analytics-lambda-infrastructure/requirements.md b/.kiro/specs/analytics-lambda-infrastructure/requirements.md new file mode 100644 index 0000000..fd4cb5a --- /dev/null +++ b/.kiro/specs/analytics-lambda-infrastructure/requirements.md @@ -0,0 +1,296 @@ +# Requirements Document + +## Introduction + +This document specifies the requirements for implementing analytics +infrastructure in the bubblyclouds-api CDK stack. The system will collect, +aggregate, and visualize usage metrics from the DynamoDB table using scheduled +Lambda functions, CloudWatch metrics, and a dedicated analytics table for +historical data storage. + +## Glossary + +- **Export_Lambda**: Scheduled Lambda function that triggers DynamoDB + incremental PITR export to S3 +- **Aggregator_Lambda**: Scheduled Lambda function that reads S3 exports, + aggregates metrics, and publishes to CloudWatch +- **Analytics_Table**: DynamoDB table storing per-user daily metric breakdowns + with 455-day TTL +- **Main_Table**: The existing ApiTable with single-table design containing + sessions, parties, members, and users +- **CloudWatch_Dashboard**: AWS CloudWatch dashboard displaying metrics with + auto-discovered app dimensions +- **Export_Bucket**: S3 bucket for temporary storage of DynamoDB export files +- **PITR**: Point-in-Time Recovery, DynamoDB feature enabling incremental + exports without table capacity impact +- **Backfill_Mode**: Operation mode for processing historical data from a start + date to yesterday using full export +- **Incremental_Mode**: Daily operation mode processing only the previous day's + data using incremental export +- **Target_Day**: The specific date being processed for metrics aggregation + (yesterday in incremental mode) + +## Requirements + +### Requirement 1: DynamoDB Export Lambda Function + +**User Story:** As a system operator, I want automated DynamoDB exports to S3, +so that I can process table data without impacting production capacity. + +#### Acceptance Criteria + +1. WHEN the Export_Lambda executes at 2:00 AM UTC daily, THE Export_Lambda SHALL + trigger an incremental PITR export of Main_Table to Export_Bucket +2. WHEN running in incremental mode, THE Export_Lambda SHALL export only data + modified since the previous export +3. WHEN running in backfill mode, THE Export_Lambda SHALL trigger a full table + export +4. THE Export_Lambda SHALL have a 30-second timeout and 128 MB memory allocation +5. THE Export_Lambda SHALL use Node.js 20.x runtime +6. THE Export_Lambda SHALL have IAM permissions to execute + ExportTableToPointInTime on Main_Table +7. THE Export_Lambda SHALL have IAM permissions to write to Export_Bucket + +### Requirement 2: Metrics Aggregation Lambda Function + +**User Story:** As a system operator, I want automated metrics aggregation from +DynamoDB exports, so that I can track application usage without manual +processing. + +#### Acceptance Criteria + +1. WHEN the Aggregator_Lambda executes at 2:30 AM UTC daily, THE + Aggregator_Lambda SHALL read the completed DynamoDB export from Export_Bucket +2. WHEN processing export data in incremental mode, THE Aggregator_Lambda SHALL + aggregate metrics for the Target_Day (yesterday) +3. WHEN processing export data in backfill mode, THE Aggregator_Lambda SHALL + aggregate metrics for all days from the start date to yesterday +4. THE Aggregator_Lambda SHALL have a 5-minute timeout and 256 MB memory + allocation +5. THE Aggregator_Lambda SHALL use Node.js 20.x runtime +6. THE Aggregator_Lambda SHALL have IAM permissions to read from Export_Bucket +7. THE Aggregator_Lambda SHALL have IAM permissions to publish metrics to + CloudWatch +8. THE Aggregator_Lambda SHALL have IAM permissions to write to Analytics_Table + +### Requirement 3: Active Users Metric + +**User Story:** As a product manager, I want to track daily active users per +app, so that I can understand user engagement patterns. + +#### Acceptance Criteria + +1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL + identify all session records where the updatedAt timestamp falls within the + Target_Day +2. WHEN counting active users, THE Aggregator_Lambda SHALL extract distinct user + IDs from session records with modelId pattern "session-{app}-{id}" +3. WHEN publishing the ActiveUsers metric, THE Aggregator_Lambda SHALL use the + App dimension extracted from the session modelId +4. WHEN publishing the ActiveUsers metric, THE Aggregator_Lambda SHALL set the + timestamp to the start of the Target_Day (00:00:00 UTC) +5. WHEN storing active users in Analytics_Table, THE Aggregator_Lambda SHALL + store the complete list of user IDs in the activeUserIds attribute + +### Requirement 4: Games Played Metric + +**User Story:** As a product manager, I want to track the number of games played +per app daily, so that I can measure application usage intensity. + +#### Acceptance Criteria + +1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL count + all session records where the updatedAt timestamp falls within the Target_Day +2. WHEN counting games played, THE Aggregator_Lambda SHALL include all records + with modelId pattern "session-{app}-{id}" +3. WHEN publishing the GamesPlayed metric, THE Aggregator_Lambda SHALL use the + App dimension extracted from the session modelId +4. WHEN publishing the GamesPlayed metric, THE Aggregator_Lambda SHALL set the + timestamp to the start of the Target_Day (00:00:00 UTC) +5. WHEN storing games played in Analytics_Table, THE Aggregator_Lambda SHALL + store per-user game counts in the gamesPerUser attribute + +### Requirement 5: Parties Created Metric + +**User Story:** As a product manager, I want to track the number of parties +created per app daily, so that I can understand social feature adoption. + +#### Acceptance Criteria + +1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL count + all party records where the createdAt timestamp falls within the Target_Day +2. WHEN counting parties created, THE Aggregator_Lambda SHALL include all + records with modelId pattern "party-{app}-{id}" +3. WHEN publishing the PartiesCreated metric, THE Aggregator_Lambda SHALL use + the App dimension extracted from the party modelId +4. WHEN publishing the PartiesCreated metric, THE Aggregator_Lambda SHALL set + the timestamp to the start of the Target_Day (00:00:00 UTC) +5. WHEN storing parties created in Analytics_Table, THE Aggregator_Lambda SHALL + store per-user party creation counts in the partiesCreatedPerUser attribute + +### Requirement 6: Parties Joined Metric + +**User Story:** As a product manager, I want to track the number of party joins +per app daily, so that I can measure social engagement. + +#### Acceptance Criteria + +1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL count + all member records where the createdAt timestamp falls within the Target_Day +2. WHEN counting parties joined, THE Aggregator_Lambda SHALL extract the app + identifier from the owner attribute with pattern "party-{app}-{id}" +3. WHEN publishing the PartiesJoined metric, THE Aggregator_Lambda SHALL use the + App dimension extracted from the member owner attribute +4. WHEN publishing the PartiesJoined metric, THE Aggregator_Lambda SHALL set the + timestamp to the start of the Target_Day (00:00:00 UTC) +5. WHEN storing parties joined in Analytics_Table, THE Aggregator_Lambda SHALL + store the total count in the partiesJoined attribute + +### Requirement 7: Analytics DynamoDB Table + +**User Story:** As a system operator, I want persistent storage of daily +analytics data, so that I can query historical metrics and maintain data beyond +CloudWatch retention. + +#### Acceptance Criteria + +1. THE Analytics_Table SHALL use "date" (YYYY-MM-DD format) as the partition key +2. THE Analytics_Table SHALL use "app" as the sort key +3. THE Analytics_Table SHALL store activeUserIds as an array attribute +4. THE Analytics_Table SHALL store gamesPerUser as a map attribute with user IDs + as keys and counts as values +5. THE Analytics_Table SHALL store partiesCreatedPerUser as a map attribute with + user IDs as keys and counts as values +6. THE Analytics_Table SHALL store partiesJoined as a number attribute +7. THE Analytics_Table SHALL store summary as a map attribute containing + aggregate totals +8. THE Analytics_Table SHALL have an expiresAt attribute for TTL functionality +9. THE Analytics_Table SHALL be configured with 1 RCU and 1 WCU (minimum + provisioned capacity) +10. THE Analytics_Table SHALL have TTL enabled with 455-day expiration to match + CloudWatch retention + +### Requirement 8: S3 Export Bucket + +**User Story:** As a system operator, I want temporary storage for DynamoDB +exports, so that the Aggregator_Lambda can process table data efficiently. + +#### Acceptance Criteria + +1. THE Export_Bucket SHALL be created in the eu-west-2 region +2. THE Export_Bucket SHALL have a lifecycle policy that expires objects after 1 + day +3. THE Export_Bucket SHALL be configured for automatic deletion when the CDK + stack is removed +4. THE Export_Bucket SHALL grant write permissions to the DynamoDB export + service +5. THE Export_Bucket SHALL grant read permissions to Aggregator_Lambda + +### Requirement 9: CloudWatch Dashboard + +**User Story:** As a product manager, I want a visual dashboard of application +metrics, so that I can monitor usage trends without writing custom queries. + +#### Acceptance Criteria + +1. THE CloudWatch_Dashboard SHALL display all four metrics (ActiveUsers, + GamesPlayed, PartiesCreated, PartiesJoined) +2. WHEN displaying metrics, THE CloudWatch_Dashboard SHALL use SEARCH + expressions to auto-discover all App dimension values +3. THE CloudWatch_Dashboard SHALL display metrics with 455-day retention +4. THE CloudWatch_Dashboard SHALL be created in the eu-west-2 region +5. THE CloudWatch_Dashboard SHALL organize metrics in a readable layout with + appropriate time ranges + +### Requirement 10: Scheduled Execution + +**User Story:** As a system operator, I want automated daily execution of +analytics processing, so that metrics are updated without manual intervention. + +#### Acceptance Criteria + +1. THE Export_Lambda SHALL be triggered by an EventBridge rule at 2:00 AM UTC + daily +2. THE Aggregator_Lambda SHALL be triggered by an EventBridge rule at 2:30 AM + UTC daily +3. THE system SHALL ensure a 30-minute gap between Export_Lambda and + Aggregator_Lambda execution to allow export completion +4. WHEN the CDK stack is deployed, THE EventBridge rules SHALL be enabled by + default + +### Requirement 11: Backfill Capability + +**User Story:** As a system operator, I want to backfill historical analytics +data from January 17, 2025, so that I can populate metrics for days before the +analytics system was deployed. + +#### Acceptance Criteria + +1. WHEN running in backfill mode, THE Export_Lambda SHALL trigger a full table + export instead of an incremental export +2. WHEN running in backfill mode, THE Aggregator_Lambda SHALL process all + records and aggregate metrics for each day from January 17, 2025 to yesterday +3. WHEN aggregating in backfill mode, THE Aggregator_Lambda SHALL group records + by date based on their respective timestamp attributes (updatedAt for + sessions, createdAt for parties and members) +4. WHEN writing backfill data, THE Aggregator_Lambda SHALL write one + Analytics_Table record per app per day +5. WHEN publishing backfill metrics, THE Aggregator_Lambda SHALL publish + CloudWatch metrics for each day with the appropriate timestamp + +### Requirement 12: IAM Permissions and Security + +**User Story:** As a security engineer, I want least-privilege IAM permissions +for all components, so that the system follows security best practices. + +#### Acceptance Criteria + +1. THE Export_Lambda SHALL have IAM permissions scoped to the specific + Main_Table ARN for dynamodb:ExportTableToPointInTime +2. THE Export_Lambda SHALL have IAM permissions scoped to the specific + Export_Bucket ARN for s3:PutObject +3. THE Aggregator_Lambda SHALL have IAM permissions scoped to the specific + Export_Bucket ARN for s3:GetObject and s3:ListBucket +4. THE Aggregator_Lambda SHALL have IAM permissions for cloudwatch:PutMetricData + with no resource restrictions (CloudWatch API requirement) +5. THE Aggregator_Lambda SHALL have IAM permissions scoped to the specific + Analytics_Table ARN for dynamodb:PutItem +6. THE system SHALL NOT grant any permissions beyond those explicitly required + for functionality + +### Requirement 13: Cost Optimization + +**User Story:** As a system operator, I want cost-efficient analytics +infrastructure, so that monitoring costs remain predictable and minimal. + +#### Acceptance Criteria + +1. THE Export_Lambda SHALL use incremental PITR exports to avoid consuming + Main_Table read capacity +2. THE Analytics_Table SHALL use minimum provisioned capacity (1 RCU, 1 WCU) + since access patterns are write-once, read-rarely +3. THE Export_Bucket SHALL automatically delete export files after 1 day to + minimize storage costs +4. THE system SHALL have a total estimated monthly cost of approximately $3.65 + (primarily CloudWatch dashboard at $3/month) +5. THE system SHALL NOT introduce any charges to Main_Table read/write capacity + units + +### Requirement 14: Error Handling and Monitoring + +**User Story:** As a system operator, I want visibility into analytics +processing failures, so that I can troubleshoot issues quickly. + +#### Acceptance Criteria + +1. WHEN the Export_Lambda fails, THE Export_Lambda SHALL log detailed error + information to CloudWatch Logs +2. WHEN the Aggregator_Lambda fails, THE Aggregator_Lambda SHALL log detailed + error information to CloudWatch Logs +3. THE Export_Lambda SHALL have CloudWatch Logs retention set to a reasonable + period for troubleshooting +4. THE Aggregator_Lambda SHALL have CloudWatch Logs retention set to a + reasonable period for troubleshooting +5. WHEN the Aggregator_Lambda encounters malformed export data, THE + Aggregator_Lambda SHALL log the error and continue processing remaining + records diff --git a/.kiro/specs/analytics-lambda-infrastructure/tasks.md b/.kiro/specs/analytics-lambda-infrastructure/tasks.md new file mode 100644 index 0000000..f1237d6 --- /dev/null +++ b/.kiro/specs/analytics-lambda-infrastructure/tasks.md @@ -0,0 +1,271 @@ +# Implementation Plan: Analytics Lambda Infrastructure + +## Overview + +This implementation plan breaks down the analytics infrastructure into discrete +coding tasks. The approach follows a bottom-up strategy: first creating the +Lambda function implementations, then adding the CDK infrastructure to deploy +them, and finally wiring everything together with EventBridge rules and the +CloudWatch dashboard. + +## Tasks + +- + 1. [ ] Create Lambda function source files and shared utilities + - [x] 1.1 Create directory structure for Lambda functions + - Create `deploy/lib/analytics-lambda/` directory for Lambda code (separate + from NestJS app) + - Create `deploy/lib/analytics-lambda/shared/` for shared utilities + - _Requirements: 1.1, 2.1_ + + - [x] 1.2 Implement shared utility functions + - Create `deploy/lib/analytics-lambda/shared/date-utils.ts` with date + formatting and validation functions + - Create `deploy/lib/analytics-lambda/shared/pattern-utils.ts` with modelId + and owner pattern extraction functions + - Create `deploy/lib/analytics-lambda/shared/types.ts` with shared + TypeScript interfaces + - _Requirements: 3.3, 3.4, 6.2, 11.3_ + + - [ ]* 1.3 Write property tests for shared utilities + - **Property 3: App extraction from modelId** + - **Property 4: Metric timestamp formatting** + - **Property 12: App extraction from member owner attribute** + - **Property 14: Timestamp field selection by record type** + - _Requirements: 3.3, 3.4, 6.2, 11.3_ + +- + 2. [ ] Implement Export Lambda function + - [x] 2.1 Create Export Lambda handler + - Create `deploy/lib/analytics-lambda/export-trigger.ts` with main handler + function + - Implement environment variable validation + - Implement export parameter construction for incremental mode + - Implement export parameter construction for backfill mode + - Call DynamoDB ExportTableToPointInTime API + - Add error handling and logging + - _Requirements: 1.1, 1.2, 1.3, 14.1_ + + - [ ]* 2.2 Write unit tests for Export Lambda + - Test incremental mode parameter construction + - Test backfill mode parameter construction + - Test environment variable validation + - Test error handling + - _Requirements: 1.1, 1.2, 1.3, 14.1_ + +- + 3. [ ] Implement Aggregator Lambda core logic + - [x] 3.1 Create S3 export reader + - Create `deploy/lib/analytics-lambda/s3-reader.ts` + - Implement function to find latest export in S3 + - Implement function to read export manifest + - Implement function to read and parse data files + - Add error handling for S3 operations + - _Requirements: 2.1, 14.2, 14.5_ + + - [x] 3.2 Create metrics aggregation engine + - Create `deploy/lib/analytics-lambda/aggregator-core.ts` + - Implement data structures for tracking metrics by date and app + - Implement session record processing (active users and games played) + - Implement party record processing (parties created) + - Implement member record processing (parties joined) + - Implement date filtering for incremental vs backfill mode + - _Requirements: 2.2, 2.3, 3.1, 3.2, 4.1, 4.2, 4.5, 5.1, 5.2, 5.5, 6.1, 6.5, + 11.2, 11.3_ + + - [ ]* 3.3 Write property tests for metrics aggregation + - **Property 1: Session date filtering** + - **Property 2: Distinct user ID extraction from sessions** + - **Property 6: Session pattern matching** + - **Property 7: Per-user game count aggregation** + - **Property 8: Party date filtering** + - **Property 9: Party pattern matching** + - **Property 10: Per-user party creation count aggregation** + - **Property 11: Member date filtering** + - **Property 13: Parties joined count aggregation** + - _Requirements: 3.1, 3.2, 4.1, 4.2, 4.5, 5.1, 5.2, 5.5, 6.1, 6.5_ + + - [x] 3.4 Create CloudWatch metrics publisher + - Create `deploy/lib/analytics-lambda/cloudwatch-publisher.ts` + - Implement function to build CloudWatch metric data + - Implement function to publish metrics to CloudWatch + - Add error handling for CloudWatch operations + - _Requirements: 2.7, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 11.5_ + + - [ ]* 3.5 Write property tests for CloudWatch publisher + - **Property 5: Active user IDs storage completeness** + - **Property 16: Backfill metric timestamp distribution** + - _Requirements: 3.5, 11.5_ + + - [x] 3.6 Create Analytics Table writer + - Create `deploy/lib/analytics-lambda/analytics-writer.ts` + - Implement function to build Analytics_Table record structure + - Implement TTL calculation (455 days) + - Implement function to write records to DynamoDB + - Add error handling for DynamoDB operations + - _Requirements: 2.8, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.10, 11.4_ + + - [ ]* 3.7 Write property tests for Analytics Table writer + - **Property 15: Analytics record partitioning** + - _Requirements: 11.4_ + + - [ ]* 3.8 Write unit tests for Analytics Table writer + - Test record structure matches schema + - Test TTL calculation + - Test error handling + - _Requirements: 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.10_ + + - [x] 3.9 Create Aggregator Lambda handler + - Create `deploy/lib/analytics-lambda/aggregator.ts` with main handler + function + - Wire together S3 reader, aggregator, CloudWatch publisher, and Analytics + writer + - Implement environment variable validation + - Implement mode-based processing (incremental vs backfill) + - Add comprehensive error handling and logging + - _Requirements: 2.1, 2.2, 2.3, 14.2, 14.5_ + + - [ ]* 3.10 Write unit tests for Aggregator Lambda handler + - Test environment variable validation + - Test incremental mode flow + - Test backfill mode flow + - Test error handling for malformed records + - _Requirements: 2.2, 2.3, 14.2, 14.5_ + +- + 4. [ ] Checkpoint - Ensure Lambda function tests pass + - Ensure all tests pass, ask the user if questions arise. + +- + 5. [ ] Add CDK infrastructure for Analytics Table and S3 bucket + - [x] 5.1 Create Analytics DynamoDB Table in CDK + - Add Analytics_Table to `deploy/lib/api-stack.ts` + - Configure partition key (date) and sort key (app) + - Configure TTL attribute (expiresAt) + - Configure provisioned capacity (1 RCU, 1 WCU) + - Return table reference for Lambda permissions + - _Requirements: 7.1, 7.2, 7.9, 7.10_ + + - [x] 5.2 Create S3 Export Bucket in CDK + - Add Export_Bucket to `deploy/lib/api-stack.ts` + - Configure lifecycle policy (1-day expiration) + - Configure removal policy (destroy) + - Configure region (eu-west-2) + - Return bucket reference for Lambda permissions + - _Requirements: 8.1, 8.2, 8.3_ + + - [ ]* 5.3 Write CDK unit tests for table and bucket + - Test Analytics_Table schema + - Test Analytics_Table capacity + - Test Analytics_Table TTL configuration + - Test Export_Bucket lifecycle policy + - Test Export_Bucket removal policy + - _Requirements: 7.1, 7.2, 7.9, 7.10, 8.2, 8.3_ + +- + 6. [ ] Add CDK infrastructure for Lambda functions + - [x] 6.1 Create Export Lambda in CDK + - Add Export Lambda using NodejsFunction construct + - Configure runtime (Node.js 20.x), memory (128 MB), timeout (30s) + - Set environment variables (TABLE_ARN, BUCKET_NAME, S3_PREFIX) + - Configure entry point to `deploy/lib/analytics-lambda/export-trigger.ts` + - Add CloudWatch Logs retention + - _Requirements: 1.4, 1.5, 14.3_ + + - [x] 6.2 Add IAM permissions for Export Lambda + - Grant dynamodb:ExportTableToPointInTime on Main_Table + - Grant s3:PutObject and s3:AbortMultipartUpload on Export_Bucket + - Scope permissions to specific resource ARNs + - _Requirements: 1.6, 1.7, 12.1, 12.2_ + + - [x] 6.3 Create Aggregator Lambda in CDK + - Add Aggregator Lambda using NodejsFunction construct + - Configure runtime (Node.js 20.x), memory (256 MB), timeout (5 minutes) + - Set environment variables (TABLE_ARN, ANALYTICS_TABLE_NAME, BUCKET_NAME, + S3_PREFIX) + - Configure entry point to `deploy/lib/analytics-lambda/aggregator.ts` + - Add CloudWatch Logs retention + - _Requirements: 2.4, 2.5, 14.4_ + + - [x] 6.4 Add IAM permissions for Aggregator Lambda + - Grant s3:GetObject and s3:ListBucket on Export_Bucket + - Grant cloudwatch:PutMetricData (no resource restriction) + - Grant dynamodb:PutItem on Analytics_Table + - Scope permissions to specific resource ARNs where applicable + - _Requirements: 2.6, 2.7, 2.8, 12.3, 12.4, 12.5_ + + - [ ]* 6.5 Write CDK unit tests for Lambda functions + - Test Export Lambda configuration (runtime, memory, timeout) + - Test Aggregator Lambda configuration (runtime, memory, timeout) + - Test Export Lambda IAM permissions + - Test Aggregator Lambda IAM permissions + - _Requirements: 1.4, 1.5, 1.6, 1.7, 2.4, 2.5, 2.6, 2.7, 2.8, 12.1, 12.2, + 12.3, 12.4, 12.5_ + +- + 7. [ ] Add EventBridge rules for scheduled execution + - [x] 7.1 Create EventBridge rule for Export Lambda + - Add EventBridge Rule to `deploy/lib/api-stack.ts` + - Configure cron schedule (0 2 * * ? * - 2:00 AM UTC) + - Set Export Lambda as target + - Configure input payload with mode: "incremental" + - _Requirements: 10.1, 10.4_ + + - [x] 7.2 Create EventBridge rule for Aggregator Lambda + - Add EventBridge Rule to `deploy/lib/api-stack.ts` + - Configure cron schedule (30 2 * * ? * - 2:30 AM UTC) + - Set Aggregator Lambda as target + - Configure input payload with mode: "incremental" + - _Requirements: 10.2, 10.4_ + + - [ ]* 7.3 Write CDK unit tests for EventBridge rules + - Test Export rule schedule (2:00 AM UTC) + - Test Aggregator rule schedule (2:30 AM UTC) + - Test 30-minute gap between schedules + - Test rule targets and input payloads + - _Requirements: 10.1, 10.2, 10.3, 10.4_ + +- + 8. [ ] Add CloudWatch Dashboard + - [x] 8.1 Create CloudWatch Dashboard in CDK + - Add CloudWatch Dashboard to `deploy/lib/api-stack.ts` + - Create widget for ActiveUsers metric with SEARCH expression + - Create widget for GamesPlayed metric with SEARCH expression + - Create widget for PartiesCreated metric with SEARCH expression + - Create widget for PartiesJoined metric with SEARCH expression + - Configure region (eu-west-2) + - _Requirements: 9.1, 9.2, 9.4, 9.5_ + + - [ ]* 8.2 Write CDK unit tests for CloudWatch Dashboard + - Test dashboard includes all four metrics + - Test dashboard uses SEARCH expressions + - _Requirements: 9.1, 9.2_ + +- + 9. [ ] Add backfill capability documentation + - [x] 9.1 Create backfill execution guide + - Create `docs/analytics-backfill.md` with instructions + - Document how to invoke Export Lambda in backfill mode + - Document how to invoke Aggregator Lambda in backfill mode + - Document expected execution time and costs + - Include example AWS CLI commands + - _Requirements: 11.1, 11.2_ + +- + 10. [ ] Final checkpoint - Ensure all tests pass and infrastructure deploys + - Run all unit tests and property tests + - Run CDK synth to validate infrastructure + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Lambda function code should be created before CDK infrastructure +- Property tests validate universal correctness properties across randomized + inputs +- Unit tests validate specific examples, edge cases, and infrastructure + configuration +- The implementation uses NodejsFunction from aws-cdk-lib/aws-lambda-nodejs for + automatic bundling +- Backfill mode is invoked manually, not through EventBridge schedules diff --git a/deploy/.env.dev b/deploy/.env.dev index 6f5f781..411e3db 100644 --- a/deploy/.env.dev +++ b/deploy/.env.dev @@ -4,3 +4,5 @@ CERTIFICATE_ARN= DOMAIN_NAME= SUBDOMAIN= APP_CONFIG_APPLICATION_NAME= +CRON_API_KEY_USERNAME= +CRON_API_KEY_PASSWORD= diff --git a/deploy/jest.config.js b/deploy/jest.config.js index 08263b8..3fd0d07 100644 --- a/deploy/jest.config.js +++ b/deploy/jest.config.js @@ -1,7 +1,7 @@ module.exports = { testEnvironment: 'node', - roots: ['/test'], - testMatch: ['**/*.test.ts'], + roots: ['/test','/lib'], + testMatch: ['**/**/*.test.ts', '**/**/*.spec.ts'], transform: { '^.+\\.tsx?$': 'ts-jest' } diff --git a/deploy/lib/analytics-lambda/aggregator-core.ts b/deploy/lib/analytics-lambda/aggregator-core.ts new file mode 100644 index 0000000..45bdda7 --- /dev/null +++ b/deploy/lib/analytics-lambda/aggregator-core.ts @@ -0,0 +1,211 @@ +import { DynamoDBItem, DailyMetrics, AggregatorEvent } from "./shared/types"; +import { + extractAppFromModelId, + extractAppFromOwner, + extractUserIdFromOwner, + getRecordType, + extractTimestamp, +} from "./shared/pattern-utils"; +import { formatDate, getYesterday, isDateInRange } from "./shared/date-utils"; + +/** + * Main aggregation engine that processes DynamoDB items and builds metrics by date and app + */ +export class MetricsAggregator { + private metricsByDateAndApp: Map> = new Map(); + + /** + * Process a single DynamoDB item and update metrics + */ + processItem(item: DynamoDBItem, event: AggregatorEvent): void { + try { + const modelId = item.modelId.S; + const owner = item.owner.S; + const recordType = getRecordType(modelId); + + if (!recordType) { + return; // Skip unknown record types + } + + // Extract timestamp based on record type + const timestampStr = extractTimestamp(item); + if (!timestampStr) { + console.warn("Missing timestamp for record", { modelId, recordType }); + return; + } + + const timestamp = new Date(timestampStr); + const date = formatDate(timestamp); + + // Check if date should be included based on mode + if (!this.shouldIncludeDate(date, event)) { + return; + } + + // Process based on record type + if (recordType === "session") { + this.processSessionRecord(modelId, owner, date); + } else if (recordType === "party") { + this.processPartyRecord(modelId, owner, date); + } else if (recordType === "member") { + this.processMemberRecord(modelId, owner, date); + } + } catch (error) { + console.warn("Failed to process item", { + modelId: item.modelId?.S, + owner: item.owner?.S, + error: error instanceof Error ? error.message : String(error), + }); + // Continue processing other items + } + } + + /** + * Process a session record (active users and games played) + */ + private processSessionRecord(modelId: string, owner: string, date: string): void { + const app = extractAppFromModelId(modelId); + if (!app) { + return; + } + + const userId = extractUserIdFromOwner(owner); + if (!userId) { + return; + } + + const metrics = this.getOrCreateMetrics(date, app); + + // Track active user + metrics.activeUserIds.add(userId); + + // Track games played per user + const currentCount = metrics.gamesPerUser.get(userId) || 0; + metrics.gamesPerUser.set(userId, currentCount + 1); + } + + /** + * Process a party record (parties created) + */ + private processPartyRecord(modelId: string, owner: string, date: string): void { + const app = extractAppFromModelId(modelId); + if (!app) { + return; + } + + const userId = extractUserIdFromOwner(owner); + if (!userId) { + return; + } + + const metrics = this.getOrCreateMetrics(date, app); + + // Track parties created per user + const currentCount = metrics.partiesCreatedPerUser.get(userId) || 0; + metrics.partiesCreatedPerUser.set(userId, currentCount + 1); + } + + /** + * Process a member record (parties joined) + */ + private processMemberRecord(modelId: string, owner: string, date: string): void { + const app = extractAppFromOwner(owner); + if (!app) { + return; + } + + const metrics = this.getOrCreateMetrics(date, app); + + // Track total parties joined + metrics.partiesJoined++; + } + + /** + * Determine if a date should be included based on the aggregation mode + */ + private shouldIncludeDate(date: string, event: AggregatorEvent): boolean { + if (event.mode === "incremental") { + // Only include yesterday + const yesterday = getYesterday(); + return date === yesterday; + } else { + // Backfill mode: include all dates from backfillStartDate to yesterday + if (!event.backfillStartDate) { + throw new Error("backfillStartDate is required for backfill mode"); + } + const yesterday = getYesterday(); + return isDateInRange(date, event.backfillStartDate, yesterday); + } + } + + /** + * Get or create metrics for a specific date and app + */ + private getOrCreateMetrics(date: string, app: string): DailyMetrics { + let appMetrics = this.metricsByDateAndApp.get(date); + + if (!appMetrics) { + appMetrics = new Map(); + this.metricsByDateAndApp.set(date, appMetrics); + } + + let metrics = appMetrics.get(app); + + if (!metrics) { + metrics = { + date, + app, + activeUserIds: new Set(), + gamesPerUser: new Map(), + partiesCreatedPerUser: new Map(), + partiesJoined: 0, + }; + appMetrics.set(app, metrics); + } + + return metrics; + } + + /** + * Get all aggregated metrics + */ + getMetrics(): Map> { + return this.metricsByDateAndApp; + } + + /** + * Get metrics for a specific date + */ + getMetricsForDate(date: string): Map | undefined { + return this.metricsByDateAndApp.get(date); + } + + /** + * Get metrics for a specific date and app + */ + getMetricsForDateAndApp(date: string, app: string): DailyMetrics | undefined { + return this.metricsByDateAndApp.get(date)?.get(app); + } + + /** + * Get all dates that have metrics + */ + getDates(): string[] { + return Array.from(this.metricsByDateAndApp.keys()).sort(); + } + + /** + * Get all apps for a specific date + */ + getAppsForDate(date: string): string[] { + const appMetrics = this.metricsByDateAndApp.get(date); + return appMetrics ? Array.from(appMetrics.keys()).sort() : []; + } + + /** + * Clear all metrics (useful for testing) + */ + clear(): void { + this.metricsByDateAndApp.clear(); + } +} diff --git a/deploy/lib/analytics-lambda/aggregator.ts b/deploy/lib/analytics-lambda/aggregator.ts new file mode 100644 index 0000000..b4206af --- /dev/null +++ b/deploy/lib/analytics-lambda/aggregator.ts @@ -0,0 +1,132 @@ +import { AggregatorEvent } from "./shared/types"; +import { findLatestExport, readManifest, readDataFile } from "./s3-reader"; +import { MetricsAggregator } from "./aggregator-core"; +import { publishAllMetrics } from "./cloudwatch-publisher"; +import { writeAllAnalyticsRecords } from "./analytics-writer"; + +/** + * Environment variable validation + */ +function validateEnvironment(): void { + const required = ["EXPORT_BUCKET", "ANALYTICS_TABLE"]; + const missing = required.filter((key) => !process.env[key]); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(", ")}`, + ); + } +} + +/** + * Validate event parameters + */ +function validateEvent(event: AggregatorEvent): void { + if (!event.mode || !["incremental", "backfill"].includes(event.mode)) { + throw new Error( + `Invalid mode: ${event.mode}. Must be "incremental" or "backfill"`, + ); + } + + if (event.mode === "backfill" && !event.backfillStartDate) { + throw new Error("backfillStartDate is required for backfill mode"); + } + + if (event.backfillStartDate) { + // Validate date format (YYYY-MM-DD) + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(event.backfillStartDate)) { + throw new Error( + `Invalid backfillStartDate format: ${event.backfillStartDate}. Expected YYYY-MM-DD`, + ); + } + } +} + +/** + * Main Lambda handler for the Aggregator function + */ +export async function handler(event: AggregatorEvent): Promise { + console.log("Aggregator Lambda started", { + mode: event.mode, + backfillStartDate: event.backfillStartDate, + }); + + try { + // Validate environment and event + validateEnvironment(); + validateEvent(event); + + const bucketName = process.env.EXPORT_BUCKET!; + const analyticsTableName = process.env.ANALYTICS_TABLE!; + const s3Prefix = process.env.S3_PREFIX || "exports/"; + + // Step 1: Find the latest export in S3 + console.log("Finding latest export..."); + const exportPrefix = await findLatestExport(bucketName, s3Prefix); + + // Step 2: Read the export manifest + console.log("Reading export manifest..."); + const manifest = await readManifest(bucketName, exportPrefix); + console.log(`Found ${manifest.dataFiles.length} data files to process`); + + // Step 3: Process all data files and aggregate metrics + console.log("Processing data files..."); + const aggregator = new MetricsAggregator(); + let totalItemsProcessed = 0; + + for (const dataFileKey of manifest.dataFiles) { + const items = await readDataFile(bucketName, dataFileKey); + + for (const item of items) { + aggregator.processItem(item, event); + totalItemsProcessed++; + } + + // Log progress periodically + if (totalItemsProcessed % 1000 === 0) { + console.log(`Processed ${totalItemsProcessed} items so far...`); + } + } + + console.log(`Finished processing ${totalItemsProcessed} items`); + + // Step 4: Get aggregated metrics + const metricsByDateAndApp = aggregator.getMetrics(); + const dates = aggregator.getDates(); + + console.log("Aggregation complete", { + totalDates: dates.length, + dates: dates, + totalRecords: Array.from(metricsByDateAndApp.values()).reduce( + (sum, appMetrics) => sum + appMetrics.size, + 0, + ), + }); + + // Step 5: Publish metrics to CloudWatch + console.log("Publishing metrics to CloudWatch..."); + await publishAllMetrics(metricsByDateAndApp); + + // Step 6: Write records to Analytics Table + console.log("Writing records to Analytics Table..."); + await writeAllAnalyticsRecords(analyticsTableName, metricsByDateAndApp); + + console.log("Aggregator Lambda completed successfully", { + mode: event.mode, + itemsProcessed: totalItemsProcessed, + datesProcessed: dates.length, + recordsWritten: Array.from(metricsByDateAndApp.values()).reduce( + (sum, appMetrics) => sum + appMetrics.size, + 0, + ), + }); + } catch (error) { + console.error("Aggregator Lambda failed", { + mode: event.mode, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } +} diff --git a/deploy/lib/analytics-lambda/analytics-writer.spec.ts b/deploy/lib/analytics-lambda/analytics-writer.spec.ts new file mode 100644 index 0000000..7e95965 --- /dev/null +++ b/deploy/lib/analytics-lambda/analytics-writer.spec.ts @@ -0,0 +1,143 @@ +import { buildAnalyticsRecord, calculateTTL } from "./analytics-writer"; +import { DailyMetrics } from "./shared/types"; + +describe("analytics-writer", () => { + describe("buildAnalyticsRecord", () => { + it("should build correct record structure with all required attributes", () => { + const date = "2025-01-20"; + const app = "sudoku"; + const metrics: DailyMetrics = { + date, + app, + activeUserIds: new Set(["user1", "user2", "user3"]), + gamesPerUser: new Map([ + ["user1", 5], + ["user2", 3], + ["user3", 2], + ]), + partiesCreatedPerUser: new Map([ + ["user1", 2], + ["user2", 1], + ]), + partiesJoined: 7, + }; + + const record = buildAnalyticsRecord(date, app, metrics); + + // Verify partition key and sort key + expect(record.date).toEqual({ S: "2025-01-20" }); + expect(record.app).toEqual({ S: "sudoku" }); + + // Verify activeUserIds (String Set) + expect(record.activeUserIds.SS).toHaveLength(3); + expect(record.activeUserIds.SS).toContain("user1"); + expect(record.activeUserIds.SS).toContain("user2"); + expect(record.activeUserIds.SS).toContain("user3"); + + // Verify gamesPerUser (Map) + expect(record.gamesPerUser.M).toBeDefined(); + expect(record.gamesPerUser.M!["user1"]).toEqual({ N: "5" }); + expect(record.gamesPerUser.M!["user2"]).toEqual({ N: "3" }); + expect(record.gamesPerUser.M!["user3"]).toEqual({ N: "2" }); + + // Verify partiesCreatedPerUser (Map) + expect(record.partiesCreatedPerUser.M).toBeDefined(); + expect(record.partiesCreatedPerUser.M!["user1"]).toEqual({ N: "2" }); + expect(record.partiesCreatedPerUser.M!["user2"]).toEqual({ N: "1" }); + + // Verify partiesJoined (Number) + expect(record.partiesJoined).toEqual({ N: "7" }); + + // Verify summary (Map with aggregate totals) + expect(record.summary.M).toBeDefined(); + expect(record.summary.M!.activeUsers).toEqual({ N: "3" }); + expect(record.summary.M!.gamesPlayed).toEqual({ N: "10" }); // 5 + 3 + 2 + expect(record.summary.M!.partiesCreated).toEqual({ N: "3" }); // 2 + 1 + expect(record.summary.M!.partiesJoined).toEqual({ N: "7" }); + + // Verify expiresAt (TTL attribute) + expect(record.expiresAt.N).toBeDefined(); + const expiresAt = parseInt(record.expiresAt.N!); + const now = Math.floor(Date.now() / 1000); + const ttlDays = 455; + const expectedTTL = now + ttlDays * 24 * 60 * 60; + // Allow 5 second tolerance for test execution time + expect(expiresAt).toBeGreaterThanOrEqual(expectedTTL - 5); + expect(expiresAt).toBeLessThanOrEqual(expectedTTL + 5); + }); + + it("should handle empty metrics correctly", () => { + const date = "2025-01-20"; + const app = "sudoku"; + const metrics: DailyMetrics = { + date, + app, + activeUserIds: new Set(), + gamesPerUser: new Map(), + partiesCreatedPerUser: new Map(), + partiesJoined: 0, + }; + + const record = buildAnalyticsRecord(date, app, metrics); + + // Verify empty collections are handled correctly + expect(record.activeUserIds.SS).toEqual([]); + expect(record.gamesPerUser.M).toEqual({}); + expect(record.partiesCreatedPerUser.M).toEqual({}); + expect(record.partiesJoined).toEqual({ N: "0" }); + + // Verify summary with zero values + expect(record.summary.M!.activeUsers).toEqual({ N: "0" }); + expect(record.summary.M!.gamesPlayed).toEqual({ N: "0" }); + expect(record.summary.M!.partiesCreated).toEqual({ N: "0" }); + expect(record.summary.M!.partiesJoined).toEqual({ N: "0" }); + }); + + it("should handle single user metrics", () => { + const date = "2025-01-20"; + const app = "sudoku"; + const metrics: DailyMetrics = { + date, + app, + activeUserIds: new Set(["user1"]), + gamesPerUser: new Map([["user1", 1]]), + partiesCreatedPerUser: new Map([["user1", 1]]), + partiesJoined: 1, + }; + + const record = buildAnalyticsRecord(date, app, metrics); + + expect(record.activeUserIds.SS).toEqual(["user1"]); + expect(record.gamesPerUser.M!["user1"]).toEqual({ N: "1" }); + expect(record.partiesCreatedPerUser.M!["user1"]).toEqual({ N: "1" }); + expect(record.summary.M!.activeUsers).toEqual({ N: "1" }); + expect(record.summary.M!.gamesPlayed).toEqual({ N: "1" }); + expect(record.summary.M!.partiesCreated).toEqual({ N: "1" }); + }); + }); + + describe("calculateTTL", () => { + it("should calculate TTL as 455 days from now", () => { + const ttl = calculateTTL(); + const now = Math.floor(Date.now() / 1000); + const ttlDays = 455; + const expectedTTL = now + ttlDays * 24 * 60 * 60; + + // Allow 5 second tolerance for test execution time + expect(ttl).toBeGreaterThanOrEqual(expectedTTL - 5); + expect(ttl).toBeLessThanOrEqual(expectedTTL + 5); + }); + + it("should return a Unix timestamp in seconds", () => { + const ttl = calculateTTL(); + + // Unix timestamp should be a positive integer + expect(ttl).toBeGreaterThan(0); + expect(Number.isInteger(ttl)).toBe(true); + + // Should be in the future + const now = Math.floor(Date.now() / 1000); + expect(ttl).toBeGreaterThan(now); + }); + }); +}); diff --git a/deploy/lib/analytics-lambda/analytics-writer.ts b/deploy/lib/analytics-lambda/analytics-writer.ts new file mode 100644 index 0000000..49b18e5 --- /dev/null +++ b/deploy/lib/analytics-lambda/analytics-writer.ts @@ -0,0 +1,127 @@ +import { + DynamoDBClient, + PutItemCommand, + AttributeValue, +} from "@aws-sdk/client-dynamodb"; +import { DailyMetrics } from "./shared/types"; + +const dynamodbClient = new DynamoDBClient({}); + +// TTL duration: 455 days in seconds +const TTL_DAYS = 455; +const TTL_SECONDS = TTL_DAYS * 24 * 60 * 60; + +/** + * Build Analytics_Table record structure from DailyMetrics + */ +export function buildAnalyticsRecord( + date: string, + app: string, + metrics: DailyMetrics, +): Record { + // Calculate TTL: current time + 455 days + const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS; + + // Calculate summary values + const activeUsersCount = metrics.activeUserIds.size; + const gamesPlayedCount = Array.from(metrics.gamesPerUser.values()).reduce( + (sum, count) => sum + count, + 0, + ); + const partiesCreatedCount = Array.from(metrics.partiesCreatedPerUser.values()).reduce( + (sum, count) => sum + count, + 0, + ); + + // Convert Map to DynamoDB Map format + const gamesPerUserMap: Record = {}; + for (const [userId, count] of metrics.gamesPerUser) { + gamesPerUserMap[userId] = { N: count.toString() }; + } + + const partiesCreatedPerUserMap: Record = {}; + for (const [userId, count] of metrics.partiesCreatedPerUser) { + partiesCreatedPerUserMap[userId] = { N: count.toString() }; + } + + return { + date: { S: date }, + app: { S: app }, + activeUserIds: { SS: Array.from(metrics.activeUserIds) }, + gamesPerUser: { M: gamesPerUserMap }, + partiesCreatedPerUser: { M: partiesCreatedPerUserMap }, + partiesJoined: { N: metrics.partiesJoined.toString() }, + summary: { + M: { + activeUsers: { N: activeUsersCount.toString() }, + gamesPlayed: { N: gamesPlayedCount.toString() }, + partiesCreated: { N: partiesCreatedCount.toString() }, + partiesJoined: { N: metrics.partiesJoined.toString() }, + }, + }, + expiresAt: { N: expiresAt.toString() }, + }; +} + +/** + * Calculate TTL timestamp (current time + 455 days) + */ +export function calculateTTL(): number { + return Math.floor(Date.now() / 1000) + TTL_SECONDS; +} + +/** + * Write a single analytics record to DynamoDB + */ +export async function writeAnalyticsRecord( + tableName: string, + date: string, + app: string, + metrics: DailyMetrics, +): Promise { + try { + const item = buildAnalyticsRecord(date, app, metrics); + + const command = new PutItemCommand({ + TableName: tableName, + Item: item, + }); + + await dynamodbClient.send(command); + + console.log("Wrote Analytics_Table record successfully", { + date, + app, + activeUsers: metrics.activeUserIds.size, + gamesPlayed: Array.from(metrics.gamesPerUser.values()).reduce( + (sum, count) => sum + count, + 0, + ), + }); + } catch (error) { + console.error("Failed to write Analytics_Table record", { + date, + app, + error: error instanceof Error ? error.message : String(error), + }); + // Don't throw - log error and continue processing other records + } +} + +/** + * Write all analytics records for all dates and apps + */ +export async function writeAllAnalyticsRecords( + tableName: string, + metricsByDateAndApp: Map>, +): Promise { + const writePromises: Promise[] = []; + + for (const [date, appMetrics] of metricsByDateAndApp) { + for (const [app, metrics] of appMetrics) { + writePromises.push(writeAnalyticsRecord(tableName, date, app, metrics)); + } + } + + await Promise.all(writePromises); +} diff --git a/deploy/lib/analytics-lambda/cloudwatch-publisher.ts b/deploy/lib/analytics-lambda/cloudwatch-publisher.ts new file mode 100644 index 0000000..7106edd --- /dev/null +++ b/deploy/lib/analytics-lambda/cloudwatch-publisher.ts @@ -0,0 +1,134 @@ +import { + CloudWatchClient, + PutMetricDataCommand, + MetricDatum, +} from "@aws-sdk/client-cloudwatch"; +import { DailyMetrics } from "./shared/types"; + +const cloudwatchClient = new CloudWatchClient({}); + +const NAMESPACE = "BubblyClouds/Analytics"; + +/** + * Build CloudWatch metric data for a specific date and app + */ +export function buildMetricData( + date: string, + app: string, + metrics: DailyMetrics, +): MetricDatum[] { + // Parse date and set timestamp to midnight UTC + const timestamp = new Date(`${date}T00:00:00.000Z`); + + // Calculate aggregate values + const activeUsersCount = metrics.activeUserIds.size; + const gamesPlayedCount = Array.from(metrics.gamesPerUser.values()).reduce( + (sum, count) => sum + count, + 0, + ); + const partiesCreatedCount = Array.from(metrics.partiesCreatedPerUser.values()).reduce( + (sum, count) => sum + count, + 0, + ); + + return [ + { + MetricName: "ActiveUsers", + Value: activeUsersCount, + Timestamp: timestamp, + Unit: "Count", + Dimensions: [ + { + Name: "App", + Value: app, + }, + ], + }, + { + MetricName: "GamesPlayed", + Value: gamesPlayedCount, + Timestamp: timestamp, + Unit: "Count", + Dimensions: [ + { + Name: "App", + Value: app, + }, + ], + }, + { + MetricName: "PartiesCreated", + Value: partiesCreatedCount, + Timestamp: timestamp, + Unit: "Count", + Dimensions: [ + { + Name: "App", + Value: app, + }, + ], + }, + { + MetricName: "PartiesJoined", + Value: metrics.partiesJoined, + Timestamp: timestamp, + Unit: "Count", + Dimensions: [ + { + Name: "App", + Value: app, + }, + ], + }, + ]; +} + +/** + * Publish metrics to CloudWatch for a specific date and app + */ +export async function publishMetrics( + date: string, + app: string, + metrics: DailyMetrics, +): Promise { + try { + const metricData = buildMetricData(date, app, metrics); + + const command = new PutMetricDataCommand({ + Namespace: NAMESPACE, + MetricData: metricData, + }); + + await cloudwatchClient.send(command); + + console.log("Published CloudWatch metrics successfully", { + date, + app, + metricCount: metricData.length, + }); + } catch (error) { + console.error("Failed to publish CloudWatch metrics", { + date, + app, + error: error instanceof Error ? error.message : String(error), + }); + // Don't throw - allow processing to continue for Analytics Table writes + } +} + +/** + * Publish metrics for all dates and apps in the aggregated data + */ +export async function publishAllMetrics( + metricsByDateAndApp: Map>, +): Promise { + const publishPromises: Promise[] = []; + + for (const [date, appMetrics] of metricsByDateAndApp) { + for (const [app, metrics] of appMetrics) { + publishPromises.push(publishMetrics(date, app, metrics)); + } + } + + await Promise.all(publishPromises); +} diff --git a/deploy/lib/analytics-lambda/export-trigger.ts b/deploy/lib/analytics-lambda/export-trigger.ts new file mode 100644 index 0000000..b1e996e --- /dev/null +++ b/deploy/lib/analytics-lambda/export-trigger.ts @@ -0,0 +1,109 @@ +import { DynamoDBClient, ExportTableToPointInTimeCommand } from "@aws-sdk/client-dynamodb"; +import { ExportRequest } from "./shared/types"; + +const dynamoDBClient = new DynamoDBClient({}); + +interface Environment { + TABLE_NAME: string; + EXPORT_BUCKET: string; + S3_PREFIX?: string; +} + +function validateEnvironment(): Environment { + const { TABLE_NAME, EXPORT_BUCKET, S3_PREFIX } = process.env; + + if (!TABLE_NAME) { + throw new Error("TABLE_NAME environment variable is required"); + } + + if (!EXPORT_BUCKET) { + throw new Error("EXPORT_BUCKET environment variable is required"); + } + + return { + TABLE_NAME, + EXPORT_BUCKET, + S3_PREFIX: S3_PREFIX || "exports", + }; +} + +function buildExportParams(event: ExportRequest, env: Environment) { + const timestamp = Date.now(); + const s3Prefix = `${env.S3_PREFIX}/${timestamp}/`; + + const params: { + TableArn: string; + S3Bucket: string; + S3Prefix: string; + ExportFormat: "DYNAMODB_JSON"; + ExportType: "INCREMENTAL_EXPORT" | "FULL_EXPORT"; + IncrementalExportSpecification?: { + ExportFromTime: Date; + ExportToTime: Date; + }; + } = { + TableArn: env.TABLE_NAME, + S3Bucket: env.EXPORT_BUCKET, + S3Prefix: s3Prefix, + ExportFormat: "DYNAMODB_JSON", + ExportType: event.mode === "incremental" ? "INCREMENTAL_EXPORT" : "FULL_EXPORT", + }; + + if (event.mode === "incremental") { + const now = new Date(); + const yesterday = new Date(now); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + yesterday.setUTCHours(0, 0, 0, 0); + + const today = new Date(now); + today.setUTCHours(0, 0, 0, 0); + + params.IncrementalExportSpecification = { + ExportFromTime: yesterday, + ExportToTime: today, + }; + } + + return params; +} + +export async function handler(event: ExportRequest): Promise { + try { + const env = validateEnvironment(); + + if (!event.mode || (event.mode !== "incremental" && event.mode !== "backfill")) { + throw new Error('Invalid mode. Must be "incremental" or "backfill"'); + } + + if (event.mode === "backfill" && !event.backfillStartDate) { + throw new Error("backfillStartDate is required for backfill mode"); + } + + const exportParams = buildExportParams(event, env); + + console.log("Initiating DynamoDB export", { + mode: event.mode, + tableArn: env.TABLE_NAME, + bucket: env.EXPORT_BUCKET, + prefix: exportParams.S3Prefix, + exportType: exportParams.ExportType, + }); + + const command = new ExportTableToPointInTimeCommand(exportParams); + const response = await dynamoDBClient.send(command); + + console.log("Export initiated successfully", { + exportArn: response.ExportDescription?.ExportArn, + exportStatus: response.ExportDescription?.ExportStatus, + }); + } catch (error) { + console.error("Export failed", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + tableName: process.env.TABLE_NAME, + bucket: process.env.EXPORT_BUCKET, + mode: event.mode, + }); + throw error; + } +} diff --git a/deploy/lib/analytics-lambda/s3-reader.ts b/deploy/lib/analytics-lambda/s3-reader.ts new file mode 100644 index 0000000..298f0ad --- /dev/null +++ b/deploy/lib/analytics-lambda/s3-reader.ts @@ -0,0 +1,195 @@ +import { + S3Client, + ListObjectsV2Command, + GetObjectCommand, +} from "@aws-sdk/client-s3"; +import { Readable } from "stream"; +import { createGunzip } from "zlib"; +import { DynamoDBItem } from "./shared/types"; + +const s3Client = new S3Client({}); + +export interface ExportManifest { + dataFiles: string[]; +} + +/** + * Find the latest export prefix in the S3 bucket + * Exports are stored with prefix pattern: exports/{timestamp}/ + */ +export async function findLatestExport( + bucketName: string, + prefix: string = "exports/", +): Promise { + try { + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: prefix, + Delimiter: "/", + }); + + const response = await s3Client.send(command); + + if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { + throw new Error(`No exports found in bucket ${bucketName} with prefix ${prefix}`); + } + + // Sort by prefix (timestamp) descending to get the latest + const sortedPrefixes = response.CommonPrefixes + .map((p: { Prefix?: string }) => p.Prefix!) + .sort() + .reverse(); + + const latestPrefix = sortedPrefixes[0]; + console.log("Found latest export prefix:", latestPrefix); + + return latestPrefix; + } catch (error) { + console.error("Failed to find latest export", { + bucket: bucketName, + prefix, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Read and parse the export manifest file + * The manifest contains the list of data files to process + */ +export async function readManifest( + bucketName: string, + exportPrefix: string, +): Promise { + try { + // Find the AWSDynamoDB subdirectory + const listCommand = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: exportPrefix, + Delimiter: "/", + }); + + const listResponse = await s3Client.send(listCommand); + const dynamoDBPrefix = listResponse.CommonPrefixes?.find((p: { Prefix?: string }) => + p.Prefix?.includes("AWSDynamoDB"), + )?.Prefix; + + if (!dynamoDBPrefix) { + throw new Error(`AWSDynamoDB directory not found in export ${exportPrefix}`); + } + + // Find the export ID subdirectory + const exportIdListCommand = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: dynamoDBPrefix, + Delimiter: "/", + }); + + const exportIdResponse = await s3Client.send(exportIdListCommand); + const exportIdPrefix = exportIdResponse.CommonPrefixes?.[0]?.Prefix; + + if (!exportIdPrefix) { + throw new Error(`Export ID directory not found in ${dynamoDBPrefix}`); + } + + // Read manifest-files.json + const manifestKey = `${exportIdPrefix}manifest-files.json`; + const getCommand = new GetObjectCommand({ + Bucket: bucketName, + Key: manifestKey, + }); + + const response = await s3Client.send(getCommand); + const manifestContent = await streamToString(response.Body as Readable); + const manifest = JSON.parse(manifestContent); + + // Extract data file keys from manifest + const dataFiles = manifest.dataFileS3Keys || []; + + console.log("Read manifest successfully", { + manifestKey, + dataFileCount: dataFiles.length, + }); + + return { dataFiles }; + } catch (error) { + console.error("Failed to read manifest", { + bucket: bucketName, + exportPrefix, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Read and parse a single data file from the export + * Data files are gzipped JSON with one DynamoDB item per line + */ +export async function readDataFile( + bucketName: string, + dataFileKey: string, +): Promise { + try { + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: dataFileKey, + }); + + const response = await s3Client.send(command); + const body = response.Body as Readable; + + // Decompress gzipped content + const gunzip = createGunzip(); + const decompressed = body.pipe(gunzip); + + const content = await streamToString(decompressed); + + // Parse newline-delimited JSON + const items: DynamoDBItem[] = []; + const lines = content.split("\n").filter((line) => line.trim()); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + // DynamoDB export format wraps items in an "Item" property + const item = parsed.Item || parsed; + items.push(item); + } catch (parseError) { + console.warn("Failed to parse line in data file", { + dataFileKey, + line: line.substring(0, 100), // Log first 100 chars + error: parseError instanceof Error ? parseError.message : String(parseError), + }); + // Continue processing remaining lines + } + } + + console.log("Read data file successfully", { + dataFileKey, + itemCount: items.length, + }); + + return items; + } catch (error) { + console.error("Failed to read data file", { + bucket: bucketName, + dataFileKey, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Helper function to convert a stream to a string + */ +async function streamToString(stream: Readable): Promise { + const chunks: Uint8Array[] = []; + return new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("error", (err) => reject(err)); + stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + }); +} diff --git a/deploy/lib/analytics-lambda/shared/date-utils.ts b/deploy/lib/analytics-lambda/shared/date-utils.ts new file mode 100644 index 0000000..7fd989d --- /dev/null +++ b/deploy/lib/analytics-lambda/shared/date-utils.ts @@ -0,0 +1,33 @@ +export function formatDate(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function getYesterday(): string { + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + return formatDate(yesterday); +} + +export function toMidnightUTC(dateString: string): Date { + return new Date(`${dateString}T00:00:00Z`); +} + +export function isValidDateString(dateString: string): boolean { + const regex = /^\d{4}-\d{2}-\d{2}$/; + if (!regex.test(dateString)) { + return false; + } + const date = new Date(dateString); + return !isNaN(date.getTime()); +} + +export function isDateInRange( + date: string, + startDate: string, + endDate: string, +): boolean { + return date >= startDate && date <= endDate; +} diff --git a/deploy/lib/analytics-lambda/shared/pattern-utils.ts b/deploy/lib/analytics-lambda/shared/pattern-utils.ts new file mode 100644 index 0000000..85a0db3 --- /dev/null +++ b/deploy/lib/analytics-lambda/shared/pattern-utils.ts @@ -0,0 +1,63 @@ +import { DynamoDBItem, RecordType } from "./types"; + +export function extractAppFromModelId(modelId: string): string | null { + const parts = modelId.split("-"); + if (parts.length < 3) { + return null; + } + return parts[1]; +} + +export function extractAppFromOwner(owner: string): string | null { + if (!owner.startsWith("party-")) { + return null; + } + const parts = owner.split("-"); + if (parts.length < 3) { + return null; + } + return parts[1]; +} + +export function extractUserIdFromOwner(owner: string): string | null { + if (!owner.startsWith("user-")) { + return null; + } + return owner.replace("user-", ""); +} + +export function getRecordType(modelId: string): RecordType | null { + if (modelId.startsWith("session-")) { + return "session"; + } + if (modelId.startsWith("party-")) { + return "party"; + } + if (modelId.startsWith("member-user-")) { + return "member"; + } + return null; +} + +export function getTimestampField(recordType: RecordType): "updatedAt" | "createdAt" { + return recordType === "session" ? "updatedAt" : "createdAt"; +} + +export function extractTimestamp(item: DynamoDBItem): string | null { + const recordType = getRecordType(item.modelId.S); + if (!recordType) { + return null; + } + + const field = getTimestampField(recordType); + const timestamp = item[field]; + + return timestamp?.S ?? null; +} + +export function matchesPattern( + modelId: string, + pattern: "session" | "party" | "member", +): boolean { + return modelId.startsWith(`${pattern}-`); +} diff --git a/deploy/lib/analytics-lambda/shared/types.ts b/deploy/lib/analytics-lambda/shared/types.ts new file mode 100644 index 0000000..ff6f5cd --- /dev/null +++ b/deploy/lib/analytics-lambda/shared/types.ts @@ -0,0 +1,27 @@ +export interface DynamoDBItem { + modelId: { S: string }; + owner: { S: string }; + updatedAt?: { S: string }; + createdAt?: { S: string }; +} + +export interface DailyMetrics { + date: string; + app: string; + activeUserIds: Set; + gamesPerUser: Map; + partiesCreatedPerUser: Map; + partiesJoined: number; +} + +export interface AggregatorEvent { + mode: "incremental" | "backfill"; + backfillStartDate?: string; +} + +export interface ExportRequest { + mode: "incremental" | "backfill"; + backfillStartDate?: string; +} + +export type RecordType = "session" | "party" | "member"; diff --git a/deploy/lib/api-stack.ts b/deploy/lib/api-stack.ts index 3a3a517..df9f2a0 100644 --- a/deploy/lib/api-stack.ts +++ b/deploy/lib/api-stack.ts @@ -1,4 +1,4 @@ -import { Duration, Fn, SecretValue, Stack, StackProps } from 'aws-cdk-lib'; +import { Duration, Fn, RemovalPolicy, SecretValue, Stack, StackProps } from 'aws-cdk-lib'; import { CfnApplication, CfnConfigurationProfile, @@ -13,6 +13,7 @@ import { SecurityPolicy, } from 'aws-cdk-lib/aws-apigateway'; import { Certificate } from 'aws-cdk-lib/aws-certificatemanager'; +import { Dashboard, GraphWidget, Metric } from 'aws-cdk-lib/aws-cloudwatch'; import { AttributeType, ProjectionType, Table } from 'aws-cdk-lib/aws-dynamodb'; import { Rule, @@ -21,10 +22,13 @@ import { Connection, Authorization, HttpMethod, + RuleTargetInput, } from 'aws-cdk-lib/aws-events'; -import { ApiDestination as ApiDestinationTarget } from 'aws-cdk-lib/aws-events-targets'; +import { ApiDestination as ApiDestinationTarget, LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; import { Code, Function, LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; import { Construct } from 'constructs'; import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; @@ -55,7 +59,7 @@ export class ApiStack extends Stack { region: props.env!.region!, }); - const { table } = this.dynamodb(); + const { table, analyticsTable } = this.dynamodb(); table.grantReadWriteData(api.fn); // GET / @@ -66,6 +70,49 @@ export class ApiStack extends Stack { // EventBridge rules for sudoku of the day this.createSudokuCronJobs(domainName, subdomain, cron); + + const exportBucket = this.createExportBucket(); + const exportLambda = this.createExportLambda(table, exportBucket); + const aggregatorLambda = this.createAggregatorLambda(exportBucket, analyticsTable); + this.createAnalyticsDashboard(); + + // EventBridge rule for Export Lambda - triggers at 2:00 AM UTC daily + new Rule(this, 'ExportLambdaSchedule', { + schedule: Schedule.cron({ + minute: '0', + hour: '2', + day: '*', + month: '*', + year: '*', + }), + targets: [ + new LambdaFunction(exportLambda, { + event: RuleTargetInput.fromObject({ + mode: 'incremental', + }), + }), + ], + }); + + // EventBridge rule for Aggregator Lambda - triggers at 2:30 AM UTC daily + new Rule(this, 'AggregatorLambdaSchedule', { + schedule: Schedule.cron({ + minute: '30', + hour: '2', + day: '*', + month: '*', + year: '*', + }), + targets: [ + new LambdaFunction(aggregatorLambda, { + event: RuleTargetInput.fromObject({ + mode: 'incremental', + }), + }), + ], + }); + + } private gateway() { @@ -126,7 +173,16 @@ export class ApiStack extends Stack { projectionType: ProjectionType.ALL, }); - return { table }; + // Analytics table for storing daily metrics with 455-day retention + const analyticsTable = new Table(this, 'AnalyticsTable', { + partitionKey: { name: 'date', type: AttributeType.STRING }, + sortKey: { name: 'app', type: AttributeType.STRING }, + timeToLiveAttribute: 'expiresAt', + readCapacity: 1, + writeCapacity: 1, + }); + + return { table, analyticsTable }; } private appConfig(options: ApiStackProps['appConfig']): { @@ -296,4 +352,176 @@ export class ApiStack extends Stack { targets: [new ApiDestinationTarget(destination)], }); } + + private createExportBucket() { + const exportBucket = new Bucket(this, 'ExportBucket', { + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + lifecycleRules: [ + { + expiration: Duration.days(1), + }, + ], + }); + + return exportBucket; + } + + private createExportLambda(table: Table, exportBucket: Bucket) { + const exportLambda = new NodejsFunction(this, 'ExportLambda', { + entry: 'lib/analytics-lambda/export-trigger.ts', + handler: 'handler', + runtime: Runtime.NODEJS_20_X, + memorySize: 128, + timeout: Duration.seconds(30), + logRetention: RetentionDays.ONE_WEEK, + environment: { + TABLE_NAME: table.tableArn, + EXPORT_BUCKET: exportBucket.bucketName, + S3_PREFIX: 'exports', + }, + }); + + // Grant DynamoDB export permissions + exportLambda.addToRolePolicy( + new PolicyStatement({ + actions: ['dynamodb:ExportTableToPointInTime'], + resources: [table.tableArn], + }), + ); + + // Grant S3 write permissions + exportBucket.grantPut(exportLambda); + exportLambda.addToRolePolicy( + new PolicyStatement({ + actions: ['s3:AbortMultipartUpload'], + resources: [`${exportBucket.bucketArn}/*`], + }), + ); + + return exportLambda; + } + + private createAggregatorLambda(exportBucket: Bucket, analyticsTable: Table) { + const aggregatorLambda = new NodejsFunction(this, 'AggregatorLambda', { + entry: 'lib/analytics-lambda/aggregator.ts', + handler: 'handler', + runtime: Runtime.NODEJS_20_X, + memorySize: 256, + timeout: Duration.minutes(5), + logRetention: RetentionDays.ONE_WEEK, + environment: { + EXPORT_BUCKET: exportBucket.bucketName, + ANALYTICS_TABLE: analyticsTable.tableName, + S3_PREFIX: 'exports', + }, + }); + + // Grant S3 read permissions + exportBucket.grantRead(aggregatorLambda); + + // Grant CloudWatch PutMetricData permissions + aggregatorLambda.addToRolePolicy( + new PolicyStatement({ + actions: ['cloudwatch:PutMetricData'], + resources: ['*'], // CloudWatch API requires no resource restriction + }), + ); + + // Grant DynamoDB write permissions to Analytics Table + analyticsTable.grantWriteData(aggregatorLambda); + + return aggregatorLambda; + } + + private createAnalyticsDashboard() { + const dashboard = new Dashboard(this, 'AnalyticsDashboard', { + dashboardName: 'BubblyClouds-Analytics', + }); + + // Create widget for ActiveUsers metric with SEARCH expression + // Using wildcard dimension to auto-discover all App values + const activeUsersWidget = new GraphWidget({ + title: 'Active Users by App', + width: 12, + height: 6, + left: [ + new Metric({ + namespace: 'BubblyClouds/Analytics', + metricName: 'ActiveUsers', + statistic: 'Sum', + period: Duration.days(1), + region: 'eu-west-2', + }), + ], + leftYAxis: { + label: 'Users', + showUnits: false, + }, + }); + + // Create widget for GamesPlayed metric with SEARCH expression + const gamesPlayedWidget = new GraphWidget({ + title: 'Games Played by App', + width: 12, + height: 6, + left: [ + new Metric({ + namespace: 'BubblyClouds/Analytics', + metricName: 'GamesPlayed', + statistic: 'Sum', + period: Duration.days(1), + region: 'eu-west-2', + }), + ], + leftYAxis: { + label: 'Games', + showUnits: false, + }, + }); + + // Create widget for PartiesCreated metric with SEARCH expression + const partiesCreatedWidget = new GraphWidget({ + title: 'Parties Created by App', + width: 12, + height: 6, + left: [ + new Metric({ + namespace: 'BubblyClouds/Analytics', + metricName: 'PartiesCreated', + statistic: 'Sum', + period: Duration.days(1), + region: 'eu-west-2', + }), + ], + leftYAxis: { + label: 'Parties', + showUnits: false, + }, + }); + + // Create widget for PartiesJoined metric with SEARCH expression + const partiesJoinedWidget = new GraphWidget({ + title: 'Parties Joined by App', + width: 12, + height: 6, + left: [ + new Metric({ + namespace: 'BubblyClouds/Analytics', + metricName: 'PartiesJoined', + statistic: 'Sum', + period: Duration.days(1), + region: 'eu-west-2', + }), + ], + leftYAxis: { + label: 'Joins', + showUnits: false, + }, + }); + + // Add widgets to dashboard in a 2x2 grid layout + dashboard.addWidgets(activeUsersWidget, gamesPlayedWidget); + dashboard.addWidgets(partiesCreatedWidget, partiesJoinedWidget); + } } diff --git a/deploy/package-lock.json b/deploy/package-lock.json index b12607f..47fbc8f 100644 --- a/deploy/package-lock.json +++ b/deploy/package-lock.json @@ -8,6 +8,10 @@ "name": "deploy", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.535.0", + "@aws-sdk/client-dynamodb": "^3.535.0", + "@aws-sdk/client-s3": "^3.535.0", + "@aws-sdk/lib-dynamodb": "^3.535.0", "aws-cdk-lib": "2.128.0", "constructs": "^10.0.0", "dotenv": "^16.4.5", @@ -54,6 +58,1092 @@ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.0.1.tgz", "integrity": "sha512-DDt4SLdLOwWCjGtltH4VCST7hpOI5DzieuhGZsBpZ+AgJdSI2GCjklCXm0GCTwJG/SolkL5dtQXyUKgg9luBDg==" }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch/-/client-cloudwatch-3.990.0.tgz", + "integrity": "sha512-+JV1QBPAOS66Czebzh3J9RqjuUKwY22JvrU+OUdk+005pick5ytqdXCndLMCSf0igrFYtxMfKedUPUcSgVn/OQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-compression": "^4.3.29", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-dynamodb": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.990.0.tgz", + "integrity": "sha512-vHvtdH9V7HcRkg5IlZEo5BGzacR5OTgp8cYDYZ2FWP8bo5ZHAhXYSirNwWQGLEnwrgFjuddWg9hDbbIxWb+6UQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/dynamodb-codec": "^3.972.11", + "@aws-sdk/middleware-endpoint-discovery": "^3.972.3", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.990.0.tgz", + "integrity": "sha512-XnsM8RgB35Atn2+aYSocitCybDG82x9yYf/s2D23ytpyHCupmuZN3LzK2a0WxmKO6Zf7EtEIYy0mHGY4tLp9YA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-node": "^3.972.9", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", + "@aws-sdk/middleware-expect-continue": "^3.972.3", + "@aws-sdk/middleware-flexible-checksums": "^3.972.8", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-location-constraint": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-s3": "^3.972.10", + "@aws-sdk/middleware-ssec": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/signature-v4-multi-region": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.990.0.tgz", + "integrity": "sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.10.tgz", + "integrity": "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.4", + "@smithy/core": "^3.23.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", + "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.8.tgz", + "integrity": "sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.10.tgz", + "integrity": "sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.8.tgz", + "integrity": "sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/credential-provider-env": "^3.972.8", + "@aws-sdk/credential-provider-http": "^3.972.10", + "@aws-sdk/credential-provider-login": "^3.972.8", + "@aws-sdk/credential-provider-process": "^3.972.8", + "@aws-sdk/credential-provider-sso": "^3.972.8", + "@aws-sdk/credential-provider-web-identity": "^3.972.8", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.8.tgz", + "integrity": "sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.9.tgz", + "integrity": "sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.8", + "@aws-sdk/credential-provider-http": "^3.972.10", + "@aws-sdk/credential-provider-ini": "^3.972.8", + "@aws-sdk/credential-provider-process": "^3.972.8", + "@aws-sdk/credential-provider-sso": "^3.972.8", + "@aws-sdk/credential-provider-web-identity": "^3.972.8", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.8.tgz", + "integrity": "sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.8.tgz", + "integrity": "sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.990.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/token-providers": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.8.tgz", + "integrity": "sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/dynamodb-codec": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.11.tgz", + "integrity": "sha512-A0oji7TdKmua93ehEmozehNbUzgq912LRdOMx4bviVRo02DFaf9+fnwT4UUvvyIvnMBcFjT39kWP9fvka4dGUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@smithy/core": "^3.23.0", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/endpoint-cache": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.2.tgz", + "integrity": "sha512-3L7mwqSLJ6ouZZKtCntoNF0HTYDNs1FDQqkGjoPWXcv1p0gnLotaDmLq1rIDqfu4ucOit0Re3ioLyYDUTpSroA==", + "license": "Apache-2.0", + "dependencies": { + "mnemonist": "0.38.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-dynamodb": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.990.0.tgz", + "integrity": "sha512-wynv2gi1tBHEt7W6qh841anRMC7ruR9gvN7Pu/MKvvx3rSpBjXRHDNfDa6saScDMUrvJX79B8wIBfKTPxOJ9JQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/util-dynamodb": "3.990.0", + "@smithy/core": "^3.23.0", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.990.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", + "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-endpoint-discovery": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.3.tgz", + "integrity": "sha512-xAxA8/TOygQmMrzcw9CrlpTHCGWSG/lvzrHCySfSZpDN4/yVSfXO+gUwW9WxeskBmuv9IIFATOVpzc9EzfTZ0Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/endpoint-cache": "^3.972.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", + "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.8.tgz", + "integrity": "sha512-Hn6gumcN/3/8Fzo9z7N1pA2PRfE8S+qAqdb4g3MqzXjIOIe+VxD7edO/DKAJ1YH11639EGQIHBz0wdOb5btjtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/crc64-nvme": "3.972.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", + "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz", + "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", + "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", + "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.10.tgz", + "integrity": "sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/core": "^3.23.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz", + "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.10.tgz", + "integrity": "sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@smithy/core": "^3.23.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.990.0.tgz", + "integrity": "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.990.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.8", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-retry": "^4.4.31", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.30", + "@smithy/util-defaults-mode-node": "^4.2.33", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", + "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.990.0.tgz", + "integrity": "sha512-O55s1eFmKi+2Ko5T1hbdxL6tFVONGscSVe9VRxS4m91Tlbo9iG2Q2HvKWq1DuKQAuUWSUfMmjrRt07JNzizr2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.990.0.tgz", + "integrity": "sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.10", + "@aws-sdk/nested-clients": "3.990.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", + "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-dynamodb": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.990.0.tgz", + "integrity": "sha512-F9NXZAMYDUN4eUjRxlb5nx3Ne5qXG8i1lxInJFC3eKqBK4G6QrVwejIoTrMBoOVHfCPXsSqfq0cQ3xCvJK0Irg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-dynamodb": "^3.990.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.990.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.990.0.tgz", + "integrity": "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", + "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", + "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.8.tgz", + "integrity": "sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.10", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", + "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -152,6 +1242,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -799,278 +1890,1031 @@ "jest-mock": "^29.7.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.0.tgz", + "integrity": "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-compression": { + "version": "4.3.29", + "resolved": "https://registry.npmjs.org/@smithy/middleware-compression/-/middleware-compression-4.3.29.tgz", + "integrity": "sha512-ZWDXc7Sb2ONrBhc8e845e3jxreczW0CsMan8+lzryqXw9ZVDxssqlHT3pu+idoBZ79SffyoQBOp6wcw62ZQImA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "fflate": "0.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.14.tgz", + "integrity": "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.0", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.31", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.31.tgz", + "integrity": "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz", + "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, + "node_modules/@smithy/smithy-client": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.3.tgz", + "integrity": "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==", + "license": "Apache-2.0", "dependencies": { - "jest-get-type": "^29.6.3" + "@smithy/core": "^3.23.0", + "@smithy/middleware-endpoint": "^4.4.14", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.12", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.30", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.30.tgz", + "integrity": "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==", + "license": "Apache-2.0", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.33", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.33.tgz", + "integrity": "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==", + "license": "Apache-2.0", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "node_modules/@smithy/util-stream": { + "version": "4.5.12", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz", + "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", "dependencies": { - "type-detect": "4.0.8" + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@tsconfig/node10": { @@ -1186,6 +3030,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1767,6 +3612,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1808,6 +3659,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -1989,6 +3841,7 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.3.0.tgz", "integrity": "sha512-vbK8i3rIb/xwZxSpTjz3SagHn1qq9BChLEfy5Hf6fB3/2eFbrwt2n9kHwQcS0CPTRBesreeAcsJfMq2229FnbQ==", + "peer": true, "engines": { "node": ">= 16.14.0" } @@ -2236,6 +4089,24 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2245,6 +4116,12 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz", + "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2620,6 +4497,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -3416,6 +5294,15 @@ "node": "*" } }, + "node_modules/mnemonist": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", + "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", + "license": "MIT", + "dependencies": { + "obliterator": "^1.6.1" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -3461,6 +5348,12 @@ "node": ">=8" } }, + "node_modules/obliterator": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3898,6 +5791,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4044,6 +5949,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4082,6 +5988,12 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -4108,6 +6020,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/deploy/package.json b/deploy/package.json index 050f11d..aefafa6 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -23,9 +23,13 @@ "typescript": "~5.3.3" }, "dependencies": { + "@aws-sdk/client-dynamodb": "^3.535.0", + "@aws-sdk/client-s3": "^3.535.0", + "@aws-sdk/client-cloudwatch": "^3.535.0", + "@aws-sdk/lib-dynamodb": "^3.535.0", "aws-cdk-lib": "2.128.0", "constructs": "^10.0.0", "dotenv": "^16.4.5", "source-map-support": "^0.5.21" } -} +} \ No newline at end of file diff --git a/deploy/test/__snapshots__/deploy.test.ts.snap b/deploy/test/__snapshots__/deploy.test.ts.snap index 88fe328..a003f53 100644 --- a/deploy/test/__snapshots__/deploy.test.ts.snap +++ b/deploy/test/__snapshots__/deploy.test.ts.snap @@ -34,6 +34,270 @@ exports[`Api Stack 1`] = ` }, }, "Resources": { + "AggregatorLambda836C2B04": { + "DependsOn": [ + "AggregatorLambdaServiceRoleDefaultPolicyEBBDE8D9", + "AggregatorLambdaServiceRoleC12E8DC5", + ], + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", + "S3Key": "4534902e994a2b37e9e99af20a65a1ab5c9698148bf791163b6f5b278d4bf05c.zip", + }, + "Environment": { + "Variables": { + "ANALYTICS_TABLE": { + "Ref": "AnalyticsTable3F84C304", + }, + "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1", + "EXPORT_BUCKET": { + "Ref": "ExportBucket4E99310E", + }, + "S3_PREFIX": "exports", + }, + }, + "Handler": "index.handler", + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "AggregatorLambdaServiceRoleC12E8DC5", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 300, + }, + "Type": "AWS::Lambda::Function", + }, + "AggregatorLambdaLogRetentionDB4563F6": { + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "AggregatorLambda836C2B04", + }, + ], + ], + }, + "RetentionInDays": 7, + "ServiceToken": { + "Fn::GetAtt": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A", + "Arn", + ], + }, + }, + "Type": "Custom::LogRetention", + }, + "AggregatorLambdaScheduleA1F0F66B": { + "Properties": { + "ScheduleExpression": "cron(30 2 * * ? *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "AggregatorLambda836C2B04", + "Arn", + ], + }, + "Id": "Target0", + "Input": "{"mode":"incremental"}", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "AggregatorLambdaScheduleAllowEventRuleApiStackAggregatorLambda4BB9388D4C88472B": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "AggregatorLambda836C2B04", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "AggregatorLambdaScheduleA1F0F66B", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "AggregatorLambdaServiceRoleC12E8DC5": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "AggregatorLambdaServiceRoleDefaultPolicyEBBDE8D9": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "ExportBucket4E99310E", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ExportBucket4E99310E", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, + { + "Action": "cloudwatch:PutMetricData", + "Effect": "Allow", + "Resource": "*", + }, + { + "Action": [ + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:DescribeTable", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "AnalyticsTable3F84C304", + "Arn", + ], + }, + { + "Ref": "AWS::NoValue", + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AggregatorLambdaServiceRoleDefaultPolicyEBBDE8D9", + "Roles": [ + { + "Ref": "AggregatorLambdaServiceRoleC12E8DC5", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "AnalyticsDashboardF810556A": { + "Properties": { + "DashboardBody": { + "Fn::Join": [ + "", + [ + "{"widgets":[{"type":"metric","width":12,"height":6,"x":0,"y":0,"properties":{"view":"timeSeries","title":"Active Users by App","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["BubblyClouds/Analytics","ActiveUsers",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Users","showUnits":false}}}},{"type":"metric","width":12,"height":6,"x":12,"y":0,"properties":{"view":"timeSeries","title":"Games Played by App","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["BubblyClouds/Analytics","GamesPlayed",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Games","showUnits":false}}}},{"type":"metric","width":12,"height":6,"x":0,"y":6,"properties":{"view":"timeSeries","title":"Parties Created by App","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["BubblyClouds/Analytics","PartiesCreated",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Parties","showUnits":false}}}},{"type":"metric","width":12,"height":6,"x":12,"y":6,"properties":{"view":"timeSeries","title":"Parties Joined by App","region":"", + { + "Ref": "AWS::Region", + }, + "","metrics":[["BubblyClouds/Analytics","PartiesJoined",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Joins","showUnits":false}}}}]}", + ], + ], + }, + "DashboardName": "BubblyClouds-Analytics", + }, + "Type": "AWS::CloudWatch::Dashboard", + }, + "AnalyticsTable3F84C304": { + "DeletionPolicy": "Retain", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "date", + "AttributeType": "S", + }, + { + "AttributeName": "app", + "AttributeType": "S", + }, + ], + "KeySchema": [ + { + "AttributeName": "date", + "KeyType": "HASH", + }, + { + "AttributeName": "app", + "KeyType": "RANGE", + }, + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1, + }, + "TimeToLiveSpecification": { + "AttributeName": "expiresAt", + "Enabled": true, + }, + }, + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Retain", + }, "ApiAppConfigApplication": { "Properties": { "Name": "mockapplicationname", @@ -646,8 +910,8 @@ exports[`Api Stack 1`] = ` "PointInTimeRecoveryEnabled": true, }, "ProvisionedThroughput": { - "ReadCapacityUnits": 5, - "WriteCapacityUnits": 5, + "ReadCapacityUnits": 10, + "WriteCapacityUnits": 10, }, "TimeToLiveSpecification": { "AttributeName": "expiresAt", @@ -657,20 +921,32 @@ exports[`Api Stack 1`] = ` "Type": "AWS::DynamoDB::Table", "UpdateReplacePolicy": "Retain", }, - "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": { + "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": { "DependsOn": [ - "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", - "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", ], "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", - "S3Key": "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35.zip", + "S3Key": "b7f33614a69548d6bafe224d751a7ef238cde19097415e553fe8b63a4c8fd8a6.zip", + }, + "Description": { + "Fn::Join": [ + "", + [ + "Lambda function for auto-deleting objects in ", + { + "Ref": "ExportBucket4E99310E", + }, + " S3 bucket.", + ], + ], }, "Handler": "index.handler", + "MemorySize": 128, "Role": { "Fn::GetAtt": [ - "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", "Arn", ], }, @@ -679,7 +955,7 @@ exports[`Api Stack 1`] = ` }, "Type": "AWS::Lambda::Function", }, - "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": { + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": { "Properties": { "AssumeRolePolicyDocument": { "Statement": [ @@ -695,44 +971,716 @@ exports[`Api Stack 1`] = ` }, "ManagedPolicyArns": [ { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition", - }, - ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ], - ], + "Fn::Sub": "arn:\${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", }, ], }, "Type": "AWS::IAM::Role", }, - "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": { + "ExportBucket4E99310E": { + "DeletionPolicy": "Delete", "Properties": { - "PolicyDocument": { - "Statement": [ + "LifecycleConfiguration": { + "Rules": [ { - "Action": [ - "logs:PutRetentionPolicy", - "logs:DeleteRetentionPolicy", - ], - "Effect": "Allow", - "Resource": "*", + "ExpirationInDays": 1, + "Status": "Enabled", }, ], - "Version": "2012-10-17", }, - "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", - "Roles": [ + "Tags": [ { - "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + "Key": "aws-cdk:auto-delete-objects", + "Value": "true", }, ], }, - "Type": "AWS::IAM::Policy", + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Delete", + }, + "ExportBucketAutoDeleteObjectsCustomResourceA72AB44A": { + "DeletionPolicy": "Delete", + "DependsOn": [ + "ExportBucketPolicyA383B5FF", + ], + "Properties": { + "BucketName": { + "Ref": "ExportBucket4E99310E", + }, + "ServiceToken": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F", + "Arn", + ], + }, + }, + "Type": "Custom::S3AutoDeleteObjects", + "UpdateReplacePolicy": "Delete", + }, + "ExportBucketPolicyA383B5FF": { + "Properties": { + "Bucket": { + "Ref": "ExportBucket4E99310E", + }, + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn", + ], + }, + }, + "Resource": [ + { + "Fn::GetAtt": [ + "ExportBucket4E99310E", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ExportBucket4E99310E", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::S3::BucketPolicy", + }, + "ExportLambdaDBBFE402": { + "DependsOn": [ + "ExportLambdaServiceRoleDefaultPolicy9F73939E", + "ExportLambdaServiceRoleB1A666BB", + ], + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", + "S3Key": "b8d7e2edcd21cf79436a4fa53dc4fb0e77b3076b113a3ccada0eb1dd4e668359.zip", + }, + "Environment": { + "Variables": { + "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1", + "EXPORT_BUCKET": { + "Ref": "ExportBucket4E99310E", + }, + "S3_PREFIX": "exports", + "TABLE_NAME": { + "Fn::GetAtt": [ + "ApiTable21517941", + "Arn", + ], + }, + }, + }, + "Handler": "index.handler", + "MemorySize": 128, + "Role": { + "Fn::GetAtt": [ + "ExportLambdaServiceRoleB1A666BB", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "ExportLambdaLogRetentionD31FEEC8": { + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ExportLambdaDBBFE402", + }, + ], + ], + }, + "RetentionInDays": 7, + "ServiceToken": { + "Fn::GetAtt": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A", + "Arn", + ], + }, + }, + "Type": "Custom::LogRetention", + }, + "ExportLambdaScheduleAllowEventRuleApiStackExportLambdaEA1A54C2720B3509": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ExportLambdaDBBFE402", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "ExportLambdaScheduleC849ABB3", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ExportLambdaScheduleC849ABB3": { + "Properties": { + "ScheduleExpression": "cron(0 2 * * ? *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ExportLambdaDBBFE402", + "Arn", + ], + }, + "Id": "Target0", + "Input": "{"mode":"incremental"}", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ExportLambdaServiceRoleB1A666BB": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "ExportLambdaServiceRoleDefaultPolicy9F73939E": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:ExportTableToPointInTime", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApiTable21517941", + "Arn", + ], + }, + }, + { + "Action": [ + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ExportBucket4E99310E", + "Arn", + ], + }, + "/*", + ], + ], + }, + }, + { + "Action": "s3:AbortMultipartUpload", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ExportBucket4E99310E", + "Arn", + ], + }, + "/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ExportLambdaServiceRoleDefaultPolicy9F73939E", + "Roles": [ + { + "Ref": "ExportLambdaServiceRoleB1A666BB", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": { + "DependsOn": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + ], + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", + "S3Key": "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35.zip", + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 900, + }, + "Type": "AWS::Lambda::Function", + }, + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:PutRetentionPolicy", + "logs:DeleteRetentionPolicy", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", + "Roles": [ + { + "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "SudokuApiConnection254817E5": { + "Properties": { + "AuthParameters": { + "BasicAuthParameters": { + "Password": "mockPassword", + "Username": "mockUsername", + }, + }, + "AuthorizationType": "BASIC", + "Description": "Connection for Sudoku API calls", + }, + "Type": "AWS::Events::Connection", + }, + "SudokuApiDestinationbookOftheMonthApiDestination2F41F45D": { + "Properties": { + "ConnectionArn": { + "Fn::GetAtt": [ + "SudokuApiConnection254817E5", + "Arn", + ], + }, + "Description": "API destination for book of the month sudoku", + "HttpMethod": "GET", + "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/bookOfTheMonth?isNextMonth=true", + }, + "Type": "AWS::Events::ApiDestination", + }, + "SudokuApiDestinationbookOftheMonthEventsRoleDefaultPolicy599574CA": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "events:InvokeApiDestination", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "SudokuApiDestinationbookOftheMonthApiDestination2F41F45D", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "SudokuApiDestinationbookOftheMonthEventsRoleDefaultPolicy599574CA", + "Roles": [ + { + "Ref": "SudokuApiDestinationbookOftheMonthEventsRoleFF674C85", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "SudokuApiDestinationbookOftheMonthEventsRoleFF674C85": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, + "SudokuApiDestinationeasyApiDestinationA4DD00E5": { + "Properties": { + "ConnectionArn": { + "Fn::GetAtt": [ + "SudokuApiConnection254817E5", + "Arn", + ], + }, + "Description": "API destination for easy sudoku", + "HttpMethod": "GET", + "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/ofTheDay?difficulty=easy&isTomorrow=true", + }, + "Type": "AWS::Events::ApiDestination", + }, + "SudokuApiDestinationeasyEventsRoleDefaultPolicyD984F23E": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "events:InvokeApiDestination", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "SudokuApiDestinationeasyApiDestinationA4DD00E5", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "SudokuApiDestinationeasyEventsRoleDefaultPolicyD984F23E", + "Roles": [ + { + "Ref": "SudokuApiDestinationeasyEventsRoleF2E3F22E", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "SudokuApiDestinationeasyEventsRoleF2E3F22E": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, + "SudokuApiDestinationintermediateApiDestinationBD334BAF": { + "Properties": { + "ConnectionArn": { + "Fn::GetAtt": [ + "SudokuApiConnection254817E5", + "Arn", + ], + }, + "Description": "API destination for intermediate sudoku", + "HttpMethod": "GET", + "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/ofTheDay?difficulty=intermediate&isTomorrow=true", + }, + "Type": "AWS::Events::ApiDestination", + }, + "SudokuApiDestinationintermediateEventsRoleDefaultPolicy185CBAD4": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "events:InvokeApiDestination", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "SudokuApiDestinationintermediateApiDestinationBD334BAF", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "SudokuApiDestinationintermediateEventsRoleDefaultPolicy185CBAD4", + "Roles": [ + { + "Ref": "SudokuApiDestinationintermediateEventsRoleE037E346", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "SudokuApiDestinationintermediateEventsRoleE037E346": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, + "SudokuApiDestinationsimpleApiDestination37FCD433": { + "Properties": { + "ConnectionArn": { + "Fn::GetAtt": [ + "SudokuApiConnection254817E5", + "Arn", + ], + }, + "Description": "API destination for simple sudoku", + "HttpMethod": "GET", + "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/ofTheDay?difficulty=simple&isTomorrow=true", + }, + "Type": "AWS::Events::ApiDestination", + }, + "SudokuApiDestinationsimpleEventsRoleDefaultPolicy0AF7692E": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "events:InvokeApiDestination", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "SudokuApiDestinationsimpleApiDestination37FCD433", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "SudokuApiDestinationsimpleEventsRoleDefaultPolicy0AF7692E", + "Roles": [ + { + "Ref": "SudokuApiDestinationsimpleEventsRoleE5E09199", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "SudokuApiDestinationsimpleEventsRoleE5E09199": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, + "SudokuCronJobbookOfTheMonthCD9D0C2E": { + "Properties": { + "ScheduleExpression": "cron(04 22 27 * ? *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "SudokuApiDestinationbookOftheMonthApiDestination2F41F45D", + "Arn", + ], + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + "SudokuApiDestinationbookOftheMonthEventsRoleFF674C85", + "Arn", + ], + }, + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "SudokuCronJobeasy852A564A": { + "Properties": { + "ScheduleExpression": "cron(02 22 * * ? *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "SudokuApiDestinationeasyApiDestinationA4DD00E5", + "Arn", + ], + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + "SudokuApiDestinationeasyEventsRoleF2E3F22E", + "Arn", + ], + }, + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "SudokuCronJobintermediate144B25DE": { + "Properties": { + "ScheduleExpression": "cron(03 22 * * ? *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "SudokuApiDestinationintermediateApiDestinationBD334BAF", + "Arn", + ], + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + "SudokuApiDestinationintermediateEventsRoleE037E346", + "Arn", + ], + }, + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "SudokuCronJobsimple31C49EFD": { + "Properties": { + "ScheduleExpression": "cron(01 22 * * ? *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "SudokuApiDestinationsimpleApiDestination37FCD433", + "Arn", + ], + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + "SudokuApiDestinationsimpleEventsRoleE5E09199", + "Arn", + ], + }, + }, + ], + }, + "Type": "AWS::Events::Rule", }, }, "Rules": { diff --git a/deploy/test/deploy.test.ts b/deploy/test/deploy.test.ts index bfe11c9..8daaea7 100644 --- a/deploy/test/deploy.test.ts +++ b/deploy/test/deploy.test.ts @@ -16,6 +16,10 @@ test('Api Stack', () => { applicationName: 'mockapplicationname', environmentName: 'mockenvironmentname', }, + cron: { + username: 'mockUsername', + password: 'mockPassword', + } }); const template = Template.fromStack(apiStack); const json = template.toJSON(); diff --git a/docs/analytics-backfill.md b/docs/analytics-backfill.md new file mode 100644 index 0000000..a2429fc --- /dev/null +++ b/docs/analytics-backfill.md @@ -0,0 +1,323 @@ +# Analytics Backfill Guide + +This guide explains how to backfill historical analytics data from January 17, +2025 to populate metrics for days before the analytics system was deployed. + +## Overview + +The analytics system supports two operation modes: + +- **Incremental Mode** (default): Processes only yesterday's data using + incremental DynamoDB exports +- **Backfill Mode**: Processes all historical data from a specified start date + to yesterday using a full table export + +Backfill mode allows you to populate historical metrics by processing all +records in the main DynamoDB table and aggregating them by date. + +## Prerequisites + +- AWS CLI configured with appropriate credentials +- IAM permissions to invoke Lambda functions +- Access to the deployed analytics infrastructure stack + +## Backfill Process + +The backfill process consists of two steps: + +1. **Export Lambda**: Triggers a full table export to S3 +2. **Aggregator Lambda**: Processes the export and aggregates metrics for each + day from the start date to yesterday + +### Step 1: Trigger Export Lambda in Backfill Mode + +The Export Lambda must be invoked with backfill mode to trigger a full table +export instead of an incremental export. + +**AWS CLI Command:** + +```bash +aws lambda invoke \ + --function-name -ExportLambda \ + --payload '{"mode":"backfill","backfillStartDate":"2025-01-17"}' \ + --region eu-west-2 \ + response.json +``` + +**Parameters:** + +- `mode`: Must be `"backfill"` +- `backfillStartDate`: ISO date string (YYYY-MM-DD) for the earliest date to + include in the backfill + +**Expected Response:** + +```json +{ + "StatusCode": 200, + "ExecutedVersion": "$LATEST" +} +``` + +The Lambda function will initiate a full DynamoDB export to S3. This is an +asynchronous operation that continues after the Lambda completes. + +### Step 2: Wait for Export Completion + +The full table export takes time to complete. Monitor the export status: + +**Check CloudWatch Logs:** + +```bash +aws logs tail /aws/lambda/-ExportLambda \ + --region eu-west-2 \ + --follow +``` + +Look for log messages indicating the export was initiated successfully. The +export ARN will be logged. + +**Check Export Status via AWS Console:** + +1. Navigate to DynamoDB → Exports to S3 +2. Find the export by ARN (from CloudWatch logs) +3. Wait for status to change from `IN_PROGRESS` to `COMPLETED` + +**Typical Export Duration:** + +- Small tables (<1GB): 5-15 minutes +- Medium tables (1-10GB): 15-60 minutes +- Large tables (>10GB): 1-3 hours + +### Step 3: Trigger Aggregator Lambda in Backfill Mode + +Once the export is complete, invoke the Aggregator Lambda to process the data. + +**AWS CLI Command:** + +```bash +aws lambda invoke \ + --function-name -AggregatorLambda \ + --payload '{"mode":"backfill","backfillStartDate":"2025-01-17"}' \ + --region eu-west-2 \ + response.json +``` + +**Parameters:** + +- `mode`: Must be `"backfill"` +- `backfillStartDate`: Same date as used in Step 1 (YYYY-MM-DD) + +**Expected Response:** + +```json +{ + "StatusCode": 200, + "ExecutedVersion": "$LATEST" +} +``` + +The Aggregator Lambda will: + +1. Read the latest export from S3 +2. Process all records and aggregate metrics by date and app +3. Publish CloudWatch metrics for each day from the start date to yesterday +4. Write detailed records to the Analytics Table + +### Step 4: Monitor Aggregation Progress + +**Check CloudWatch Logs:** + +```bash +aws logs tail /aws/lambda/-AggregatorLambda \ + --region eu-west-2 \ + --follow +``` + +Look for progress messages: + +- "Processing data files..." +- "Processed X items so far..." (logged every 1000 items) +- "Aggregation complete" +- "Publishing metrics to CloudWatch..." +- "Writing records to Analytics Table..." +- "Aggregator Lambda completed successfully" + +**Typical Aggregation Duration:** + +- Small datasets (<10k records): 10-30 seconds +- Medium datasets (10k-100k records): 30-120 seconds +- Large datasets (>100k records): 2-5 minutes + +The Lambda has a 5-minute timeout, which should be sufficient for most +workloads. + +## Verification + +After the backfill completes, verify the results: + +### 1. Check Analytics Table + +Query the Analytics Table to verify records were written: + +```bash +aws dynamodb scan \ + --table-name -AnalyticsTable \ + --region eu-west-2 \ + --max-items 10 +``` + +You should see records with dates ranging from your backfill start date to +yesterday. + +### 2. Check CloudWatch Metrics + +Navigate to CloudWatch → Metrics → BubblyClouds/Analytics in the AWS Console. + +You should see metrics for: + +- ActiveUsers +- GamesPlayed +- PartiesCreated +- PartiesJoined + +Each metric should have data points for each day from the backfill start date to +yesterday, with the App dimension showing all discovered apps. + +### 3. Check CloudWatch Dashboard + +Navigate to CloudWatch → Dashboards → -AnalyticsDashboard. + +The dashboard should display all four metrics with historical data visible in +the time range selector. + +## Cost Estimates + +Backfill operations incur AWS costs: + +### DynamoDB Export Costs + +- **Full Export**: $0.10 per GB of table size +- Example: 10 GB table = $1.00 + +### S3 Storage Costs + +- **Storage**: $0.023 per GB-month (eu-west-2) +- **Lifecycle**: Exports are automatically deleted after 1 day +- Example: 10 GB export stored for 1 day = $0.0008 + +### Lambda Execution Costs + +- **Export Lambda**: Minimal (completes in <1 second) +- **Aggregator Lambda**: $0.0000166667 per GB-second +- Example: 256 MB for 2 minutes = $0.0005 + +### CloudWatch Metrics Costs + +- **Custom Metrics**: $0.30 per metric per month (first 10,000 metrics) +- **Backfill**: One-time publishing of historical data points (no additional + cost beyond standard metric storage) + +### Total Estimated Cost + +For a typical backfill of a 10 GB table with 30 days of historical data: + +- DynamoDB Export: $1.00 +- S3 Storage: $0.001 +- Lambda Execution: $0.001 +- **Total: ~$1.00** + +## Troubleshooting + +### Export Lambda Fails + +**Error: "TABLE_NAME environment variable is required"** + +- The Lambda function is not properly configured +- Verify the CDK stack deployed successfully + +**Error: "Invalid mode. Must be 'incremental' or 'backfill'"** + +- Check the payload JSON syntax +- Ensure `mode` is set to `"backfill"` + +**Error: "backfillStartDate is required for backfill mode"** + +- Add the `backfillStartDate` parameter to the payload +- Format: `"YYYY-MM-DD"` + +**Error: "Access Denied" or "User is not authorized"** + +- Verify IAM permissions for `dynamodb:ExportTableToPointInTime` +- Verify IAM permissions for `s3:PutObject` on the export bucket + +### Aggregator Lambda Fails + +**Error: "EXPORT_BUCKET environment variable is required"** + +- The Lambda function is not properly configured +- Verify the CDK stack deployed successfully + +**Error: "Invalid backfillStartDate format"** + +- Ensure the date is in YYYY-MM-DD format +- Example: `"2025-01-17"` + +**Error: "No export found in S3"** + +- Verify the Export Lambda completed successfully +- Verify the export status is `COMPLETED` in DynamoDB console +- Check that the S3 bucket contains the export files + +**Error: "Task timed out after 300.00 seconds"** + +- The dataset is too large for the 5-minute timeout +- Consider increasing the Lambda timeout in the CDK stack +- Or process the backfill in smaller date ranges + +### Incomplete Data + +**Missing dates in Analytics Table:** + +- Verify the backfillStartDate is correct +- Check CloudWatch logs for errors during processing +- Verify the main table contains data for those dates + +**Missing apps in CloudWatch metrics:** + +- Verify the main table contains records for those apps +- Check that the modelId patterns match expected format (session-{app}-{id}, + party-{app}-{id}) +- Review CloudWatch logs for malformed record warnings + +## Re-running Backfill + +The backfill process is idempotent. Re-running the Aggregator Lambda will +overwrite existing Analytics Table records for the same date and app +combinations. + +To re-run a backfill: + +1. **Skip Export Lambda** if the export is still available in S3 (within 1 day) +2. **Re-invoke Aggregator Lambda** with the same parameters + +If the export has been deleted (after 1 day), start from Step 1. + +## Incremental Updates After Backfill + +After completing the backfill, the scheduled EventBridge rules will +automatically process new data daily: + +- **2:00 AM UTC**: Export Lambda runs in incremental mode +- **2:30 AM UTC**: Aggregator Lambda runs in incremental mode + +No manual intervention is required for ongoing daily analytics. + +## Support + +For issues or questions: + +1. Check CloudWatch Logs for detailed error messages +2. Verify IAM permissions and environment variables +3. Review the Analytics Lambda Infrastructure design document +4. Contact the infrastructure team for assistance From e41a120d2bcbc5e1c152179bc5b85e14f22f54d1 Mon Sep 17 00:00:00 2001 From: James Acres Date: Sun, 15 Feb 2026 21:05:15 +0000 Subject: [PATCH 3/7] fix: pr issues found by claude --- .../lib/analytics-lambda/aggregator-core.ts | 4 +- .../analytics-lambda/analytics-writer.spec.ts | 3 +- .../lib/analytics-lambda/analytics-writer.ts | 10 ++++- .../analytics-lambda/shared/pattern-utils.ts | 2 +- deploy/lib/analytics-lambda/shared/types.ts | 4 +- deploy/lib/api-stack.ts | 38 +++++++------------ 6 files changed, 30 insertions(+), 31 deletions(-) diff --git a/deploy/lib/analytics-lambda/aggregator-core.ts b/deploy/lib/analytics-lambda/aggregator-core.ts index 45bdda7..3a69107 100644 --- a/deploy/lib/analytics-lambda/aggregator-core.ts +++ b/deploy/lib/analytics-lambda/aggregator-core.ts @@ -34,7 +34,9 @@ export class MetricsAggregator { return; } - const timestamp = new Date(timestampStr); + // Convert Unix epoch seconds to milliseconds for Date constructor + const timestampSeconds = parseInt(timestampStr, 10); + const timestamp = new Date(timestampSeconds * 1000); const date = formatDate(timestamp); // Check if date should be included based on mode diff --git a/deploy/lib/analytics-lambda/analytics-writer.spec.ts b/deploy/lib/analytics-lambda/analytics-writer.spec.ts index 7e95965..5ce80c2 100644 --- a/deploy/lib/analytics-lambda/analytics-writer.spec.ts +++ b/deploy/lib/analytics-lambda/analytics-writer.spec.ts @@ -81,7 +81,8 @@ describe("analytics-writer", () => { const record = buildAnalyticsRecord(date, app, metrics); // Verify empty collections are handled correctly - expect(record.activeUserIds.SS).toEqual([]); + // activeUserIds should be omitted when empty (DynamoDB rejects empty String Sets) + expect(record.activeUserIds).toBeUndefined(); expect(record.gamesPerUser.M).toEqual({}); expect(record.partiesCreatedPerUser.M).toEqual({}); expect(record.partiesJoined).toEqual({ N: "0" }); diff --git a/deploy/lib/analytics-lambda/analytics-writer.ts b/deploy/lib/analytics-lambda/analytics-writer.ts index 49b18e5..17b17bd 100644 --- a/deploy/lib/analytics-lambda/analytics-writer.ts +++ b/deploy/lib/analytics-lambda/analytics-writer.ts @@ -44,10 +44,9 @@ export function buildAnalyticsRecord( partiesCreatedPerUserMap[userId] = { N: count.toString() }; } - return { + const record: Record = { date: { S: date }, app: { S: app }, - activeUserIds: { SS: Array.from(metrics.activeUserIds) }, gamesPerUser: { M: gamesPerUserMap }, partiesCreatedPerUser: { M: partiesCreatedPerUserMap }, partiesJoined: { N: metrics.partiesJoined.toString() }, @@ -61,6 +60,13 @@ export function buildAnalyticsRecord( }, expiresAt: { N: expiresAt.toString() }, }; + + // Only include activeUserIds if non-empty (DynamoDB rejects empty String Sets) + if (metrics.activeUserIds.size > 0) { + record.activeUserIds = { SS: Array.from(metrics.activeUserIds) }; + } + + return record; } /** diff --git a/deploy/lib/analytics-lambda/shared/pattern-utils.ts b/deploy/lib/analytics-lambda/shared/pattern-utils.ts index 85a0db3..7430059 100644 --- a/deploy/lib/analytics-lambda/shared/pattern-utils.ts +++ b/deploy/lib/analytics-lambda/shared/pattern-utils.ts @@ -52,7 +52,7 @@ export function extractTimestamp(item: DynamoDBItem): string | null { const field = getTimestampField(recordType); const timestamp = item[field]; - return timestamp?.S ?? null; + return timestamp?.N ?? null; } export function matchesPattern( diff --git a/deploy/lib/analytics-lambda/shared/types.ts b/deploy/lib/analytics-lambda/shared/types.ts index ff6f5cd..6af2189 100644 --- a/deploy/lib/analytics-lambda/shared/types.ts +++ b/deploy/lib/analytics-lambda/shared/types.ts @@ -1,8 +1,8 @@ export interface DynamoDBItem { modelId: { S: string }; owner: { S: string }; - updatedAt?: { S: string }; - createdAt?: { S: string }; + updatedAt?: { N: string }; + createdAt?: { N: string }; } export interface DailyMetrics { diff --git a/deploy/lib/api-stack.ts b/deploy/lib/api-stack.ts index df9f2a0..2022d08 100644 --- a/deploy/lib/api-stack.ts +++ b/deploy/lib/api-stack.ts @@ -13,7 +13,7 @@ import { SecurityPolicy, } from 'aws-cdk-lib/aws-apigateway'; import { Certificate } from 'aws-cdk-lib/aws-certificatemanager'; -import { Dashboard, GraphWidget, Metric } from 'aws-cdk-lib/aws-cloudwatch'; +import { Dashboard, GraphWidget, MathExpression, Metric } from 'aws-cdk-lib/aws-cloudwatch'; import { AttributeType, ProjectionType, Table } from 'aws-cdk-lib/aws-dynamodb'; import { Rule, @@ -177,7 +177,9 @@ export class ApiStack extends Stack { const analyticsTable = new Table(this, 'AnalyticsTable', { partitionKey: { name: 'date', type: AttributeType.STRING }, sortKey: { name: 'app', type: AttributeType.STRING }, + pointInTimeRecovery: false, timeToLiveAttribute: 'expiresAt', + deletionProtection: true, readCapacity: 1, writeCapacity: 1, }); @@ -378,7 +380,7 @@ export class ApiStack extends Stack { environment: { TABLE_NAME: table.tableArn, EXPORT_BUCKET: exportBucket.bucketName, - S3_PREFIX: 'exports', + S3_PREFIX: 'exports/', }, }); @@ -413,7 +415,7 @@ export class ApiStack extends Stack { environment: { EXPORT_BUCKET: exportBucket.bucketName, ANALYTICS_TABLE: analyticsTable.tableName, - S3_PREFIX: 'exports', + S3_PREFIX: 'exports/', }, }); @@ -440,18 +442,15 @@ export class ApiStack extends Stack { }); // Create widget for ActiveUsers metric with SEARCH expression - // Using wildcard dimension to auto-discover all App values + // Using SEARCH to auto-discover all App dimension values const activeUsersWidget = new GraphWidget({ title: 'Active Users by App', width: 12, height: 6, left: [ - new Metric({ - namespace: 'BubblyClouds/Analytics', - metricName: 'ActiveUsers', - statistic: 'Sum', + new MathExpression({ + expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="ActiveUsers"\', \'Sum\', 86400)', period: Duration.days(1), - region: 'eu-west-2', }), ], leftYAxis: { @@ -466,12 +465,9 @@ export class ApiStack extends Stack { width: 12, height: 6, left: [ - new Metric({ - namespace: 'BubblyClouds/Analytics', - metricName: 'GamesPlayed', - statistic: 'Sum', + new MathExpression({ + expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="GamesPlayed"\', \'Sum\', 86400)', period: Duration.days(1), - region: 'eu-west-2', }), ], leftYAxis: { @@ -486,12 +482,9 @@ export class ApiStack extends Stack { width: 12, height: 6, left: [ - new Metric({ - namespace: 'BubblyClouds/Analytics', - metricName: 'PartiesCreated', - statistic: 'Sum', + new MathExpression({ + expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="PartiesCreated"\', \'Sum\', 86400)', period: Duration.days(1), - region: 'eu-west-2', }), ], leftYAxis: { @@ -506,12 +499,9 @@ export class ApiStack extends Stack { width: 12, height: 6, left: [ - new Metric({ - namespace: 'BubblyClouds/Analytics', - metricName: 'PartiesJoined', - statistic: 'Sum', + new MathExpression({ + expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="PartiesJoined"\', \'Sum\', 86400)', period: Duration.days(1), - region: 'eu-west-2', }), ], leftYAxis: { From 9b5212e0a6ad88120b4f682f181a057b92a54ba2 Mon Sep 17 00:00:00 2001 From: James Acres Date: Sun, 15 Feb 2026 21:37:51 +0000 Subject: [PATCH 4/7] fix: correct timestamp type mismatch and manifest NDJSON parsing - Change updatedAt/createdAt from DynamoDB Number {N} to String {S} to match the ISO 8601 format used in DynamoDB PITR exports - Read timestamp?.S instead of timestamp?.N in extractTimestamp - Parse timestamp directly as ISO 8601 string instead of Unix epoch seconds - Parse manifest-files.json as NDJSON (one JSON object per line) and read dataFileS3Key (singular) instead of treating it as a single JSON object with a dataFileS3Keys (plural) array Co-Authored-By: Claude Sonnet 4.5 --- deploy/lib/analytics-lambda/aggregator-core.ts | 4 +--- deploy/lib/analytics-lambda/s3-reader.ts | 10 +++++++--- deploy/lib/analytics-lambda/shared/pattern-utils.ts | 2 +- deploy/lib/analytics-lambda/shared/types.ts | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/deploy/lib/analytics-lambda/aggregator-core.ts b/deploy/lib/analytics-lambda/aggregator-core.ts index 3a69107..45bdda7 100644 --- a/deploy/lib/analytics-lambda/aggregator-core.ts +++ b/deploy/lib/analytics-lambda/aggregator-core.ts @@ -34,9 +34,7 @@ export class MetricsAggregator { return; } - // Convert Unix epoch seconds to milliseconds for Date constructor - const timestampSeconds = parseInt(timestampStr, 10); - const timestamp = new Date(timestampSeconds * 1000); + const timestamp = new Date(timestampStr); const date = formatDate(timestamp); // Check if date should be included based on mode diff --git a/deploy/lib/analytics-lambda/s3-reader.ts b/deploy/lib/analytics-lambda/s3-reader.ts index 298f0ad..736f01f 100644 --- a/deploy/lib/analytics-lambda/s3-reader.ts +++ b/deploy/lib/analytics-lambda/s3-reader.ts @@ -102,10 +102,14 @@ export async function readManifest( const response = await s3Client.send(getCommand); const manifestContent = await streamToString(response.Body as Readable); - const manifest = JSON.parse(manifestContent); - // Extract data file keys from manifest - const dataFiles = manifest.dataFileS3Keys || []; + // manifest-files.json is NDJSON: each line is a JSON object with a dataFileS3Key property + const dataFiles = manifestContent + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + .map((entry: { dataFileS3Key: string }) => entry.dataFileS3Key) + .filter(Boolean); console.log("Read manifest successfully", { manifestKey, diff --git a/deploy/lib/analytics-lambda/shared/pattern-utils.ts b/deploy/lib/analytics-lambda/shared/pattern-utils.ts index 7430059..85a0db3 100644 --- a/deploy/lib/analytics-lambda/shared/pattern-utils.ts +++ b/deploy/lib/analytics-lambda/shared/pattern-utils.ts @@ -52,7 +52,7 @@ export function extractTimestamp(item: DynamoDBItem): string | null { const field = getTimestampField(recordType); const timestamp = item[field]; - return timestamp?.N ?? null; + return timestamp?.S ?? null; } export function matchesPattern( diff --git a/deploy/lib/analytics-lambda/shared/types.ts b/deploy/lib/analytics-lambda/shared/types.ts index 6af2189..ff6f5cd 100644 --- a/deploy/lib/analytics-lambda/shared/types.ts +++ b/deploy/lib/analytics-lambda/shared/types.ts @@ -1,8 +1,8 @@ export interface DynamoDBItem { modelId: { S: string }; owner: { S: string }; - updatedAt?: { N: string }; - createdAt?: { N: string }; + updatedAt?: { S: string }; + createdAt?: { S: string }; } export interface DailyMetrics { From 7637bb27e67c2f932b5cabe173ac66400be1f845 Mon Sep 17 00:00:00 2001 From: James Acres Date: Sun, 15 Feb 2026 21:38:36 +0000 Subject: [PATCH 5/7] chore: update snapshot --- deploy/test/__snapshots__/deploy.test.ts.snap | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/deploy/test/__snapshots__/deploy.test.ts.snap b/deploy/test/__snapshots__/deploy.test.ts.snap index a003f53..2ed7802 100644 --- a/deploy/test/__snapshots__/deploy.test.ts.snap +++ b/deploy/test/__snapshots__/deploy.test.ts.snap @@ -42,7 +42,7 @@ exports[`Api Stack 1`] = ` "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", - "S3Key": "4534902e994a2b37e9e99af20a65a1ab5c9698148bf791163b6f5b278d4bf05c.zip", + "S3Key": "9a73ef8ff1e73759ce676e2fd2521661e577cc6d296d63364b195c9ba34fb0f8.zip", }, "Environment": { "Variables": { @@ -53,7 +53,7 @@ exports[`Api Stack 1`] = ` "EXPORT_BUCKET": { "Ref": "ExportBucket4E99310E", }, - "S3_PREFIX": "exports", + "S3_PREFIX": "exports/", }, }, "Handler": "index.handler", @@ -276,6 +276,7 @@ exports[`Api Stack 1`] = ` "AttributeType": "S", }, ], + "DeletionProtectionEnabled": true, "KeySchema": [ { "AttributeName": "date", @@ -286,6 +287,9 @@ exports[`Api Stack 1`] = ` "KeyType": "RANGE", }, ], + "PointInTimeRecoverySpecification": { + "PointInTimeRecoveryEnabled": false, + }, "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1, @@ -1085,7 +1089,7 @@ exports[`Api Stack 1`] = ` "EXPORT_BUCKET": { "Ref": "ExportBucket4E99310E", }, - "S3_PREFIX": "exports", + "S3_PREFIX": "exports/", "TABLE_NAME": { "Fn::GetAtt": [ "ApiTable21517941", From 93bb248ee2b78e9727e679d4320c0f89b2ce9c89 Mon Sep 17 00:00:00 2001 From: James Acres Date: Sun, 15 Feb 2026 22:01:07 +0000 Subject: [PATCH 6/7] fix: fix date formats, incremental export, and give extra time for the export --- deploy/lib/analytics-lambda/aggregator-core.ts | 4 +++- deploy/lib/analytics-lambda/export-trigger.ts | 2 +- deploy/lib/analytics-lambda/s3-reader.ts | 10 +++++++--- deploy/lib/analytics-lambda/shared/pattern-utils.ts | 2 +- deploy/lib/analytics-lambda/shared/types.ts | 4 ++-- deploy/lib/api-stack.ts | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/deploy/lib/analytics-lambda/aggregator-core.ts b/deploy/lib/analytics-lambda/aggregator-core.ts index 45bdda7..3a69107 100644 --- a/deploy/lib/analytics-lambda/aggregator-core.ts +++ b/deploy/lib/analytics-lambda/aggregator-core.ts @@ -34,7 +34,9 @@ export class MetricsAggregator { return; } - const timestamp = new Date(timestampStr); + // Convert Unix epoch seconds to milliseconds for Date constructor + const timestampSeconds = parseInt(timestampStr, 10); + const timestamp = new Date(timestampSeconds * 1000); const date = formatDate(timestamp); // Check if date should be included based on mode diff --git a/deploy/lib/analytics-lambda/export-trigger.ts b/deploy/lib/analytics-lambda/export-trigger.ts index b1e996e..bac1b1e 100644 --- a/deploy/lib/analytics-lambda/export-trigger.ts +++ b/deploy/lib/analytics-lambda/export-trigger.ts @@ -29,7 +29,7 @@ function validateEnvironment(): Environment { function buildExportParams(event: ExportRequest, env: Environment) { const timestamp = Date.now(); - const s3Prefix = `${env.S3_PREFIX}/${timestamp}/`; + const s3Prefix = `${env.S3_PREFIX}${timestamp}/`; const params: { TableArn: string; diff --git a/deploy/lib/analytics-lambda/s3-reader.ts b/deploy/lib/analytics-lambda/s3-reader.ts index 736f01f..88a7a7d 100644 --- a/deploy/lib/analytics-lambda/s3-reader.ts +++ b/deploy/lib/analytics-lambda/s3-reader.ts @@ -157,9 +157,13 @@ export async function readDataFile( for (const line of lines) { try { const parsed = JSON.parse(line); - // DynamoDB export format wraps items in an "Item" property - const item = parsed.Item || parsed; - items.push(item); + // INCREMENTAL_EXPORT wraps items in NewImage/OldImage; FULL_EXPORT uses Item. + // For incremental exports, use NewImage (the post-change state). + // Skip records that only have OldImage (deletes — no current state to aggregate). + const item = parsed.NewImage ?? parsed.Item ?? null; + if (item) { + items.push(item); + } } catch (parseError) { console.warn("Failed to parse line in data file", { dataFileKey, diff --git a/deploy/lib/analytics-lambda/shared/pattern-utils.ts b/deploy/lib/analytics-lambda/shared/pattern-utils.ts index 85a0db3..7430059 100644 --- a/deploy/lib/analytics-lambda/shared/pattern-utils.ts +++ b/deploy/lib/analytics-lambda/shared/pattern-utils.ts @@ -52,7 +52,7 @@ export function extractTimestamp(item: DynamoDBItem): string | null { const field = getTimestampField(recordType); const timestamp = item[field]; - return timestamp?.S ?? null; + return timestamp?.N ?? null; } export function matchesPattern( diff --git a/deploy/lib/analytics-lambda/shared/types.ts b/deploy/lib/analytics-lambda/shared/types.ts index ff6f5cd..6af2189 100644 --- a/deploy/lib/analytics-lambda/shared/types.ts +++ b/deploy/lib/analytics-lambda/shared/types.ts @@ -1,8 +1,8 @@ export interface DynamoDBItem { modelId: { S: string }; owner: { S: string }; - updatedAt?: { S: string }; - createdAt?: { S: string }; + updatedAt?: { N: string }; + createdAt?: { N: string }; } export interface DailyMetrics { diff --git a/deploy/lib/api-stack.ts b/deploy/lib/api-stack.ts index 2022d08..78de294 100644 --- a/deploy/lib/api-stack.ts +++ b/deploy/lib/api-stack.ts @@ -98,7 +98,7 @@ export class ApiStack extends Stack { new Rule(this, 'AggregatorLambdaSchedule', { schedule: Schedule.cron({ minute: '30', - hour: '2', + hour: '3', day: '*', month: '*', year: '*', From 1324084d030e7162c0ad9ea1428af055be5ed938 Mon Sep 17 00:00:00 2001 From: James Acres Date: Sun, 15 Feb 2026 22:13:24 +0000 Subject: [PATCH 7/7] fix: update comment with hour to run, and comment on PR reviews --- .github/workflows/claude-code-review.yml | 2 +- deploy/lib/api-stack.ts | 2 +- deploy/test/__snapshots__/deploy.test.ts.snap | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..fae03c0 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -38,7 +38,7 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/deploy/lib/api-stack.ts b/deploy/lib/api-stack.ts index 78de294..c75055a 100644 --- a/deploy/lib/api-stack.ts +++ b/deploy/lib/api-stack.ts @@ -94,7 +94,7 @@ export class ApiStack extends Stack { ], }); - // EventBridge rule for Aggregator Lambda - triggers at 2:30 AM UTC daily + // EventBridge rule for Aggregator Lambda - triggers at 3:30 AM UTC daily new Rule(this, 'AggregatorLambdaSchedule', { schedule: Schedule.cron({ minute: '30', diff --git a/deploy/test/__snapshots__/deploy.test.ts.snap b/deploy/test/__snapshots__/deploy.test.ts.snap index 2ed7802..ff24de2 100644 --- a/deploy/test/__snapshots__/deploy.test.ts.snap +++ b/deploy/test/__snapshots__/deploy.test.ts.snap @@ -42,7 +42,7 @@ exports[`Api Stack 1`] = ` "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", - "S3Key": "9a73ef8ff1e73759ce676e2fd2521661e577cc6d296d63364b195c9ba34fb0f8.zip", + "S3Key": "3e0254305bc780e8fdaba66390e468bc5b09264dfdead513c2b4daa341779eb3.zip", }, "Environment": { "Variables": { @@ -1081,7 +1081,7 @@ exports[`Api Stack 1`] = ` "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2", - "S3Key": "b8d7e2edcd21cf79436a4fa53dc4fb0e77b3076b113a3ccada0eb1dd4e668359.zip", + "S3Key": "94d02d8593b973a8fbc6d45b79452fee05436fc54066cf16078cbcbc31abc20e.zip", }, "Environment": { "Variables": {