diff --git a/Examples/JSONLogging/.gitignore b/Examples/JSONLogging/.gitignore new file mode 100644 index 000000000..62fdfc748 --- /dev/null +++ b/Examples/JSONLogging/.gitignore @@ -0,0 +1 @@ +samconfig.toml \ No newline at end of file diff --git a/Examples/JSONLogging/Package.swift b/Examples/JSONLogging/Package.swift new file mode 100644 index 000000000..07786ee9c --- /dev/null +++ b/Examples/JSONLogging/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version:6.2 + +import PackageDescription + +let package = Package( + name: "swift-aws-lambda-runtime-example", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "JSONLogging", targets: ["JSONLogging"]) + ], + dependencies: [ + // For local development (default) + // When using the below line, use LAMBDA_USE_LOCAL_DEPS=../.. for swift package archive command, e.g. + // `LAMBDA_USE_LOCAL_DEPS=../.. swift package archive --allow-network-connections docker` + .package(name: "swift-aws-lambda-runtime", path: "../..") + + // For standalone usage, comment the line above and uncomment below: + // .package(url: "https://github.com/awslabs/swift-aws-lambda-runtime.git", from: "2.0.0"), + ], + targets: [ + .executableTarget( + name: "JSONLogging", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime") + ], + path: "Sources" + ) + ] +) diff --git a/Examples/JSONLogging/README.md b/Examples/JSONLogging/README.md new file mode 100644 index 000000000..9d40cf032 --- /dev/null +++ b/Examples/JSONLogging/README.md @@ -0,0 +1,268 @@ +# JSON Logging Example + +This example demonstrates how to use structured JSON logging with AWS Lambda functions written in Swift. When configured with JSON log format, your logs are automatically structured as JSON objects, making them easier to search, filter, and analyze in CloudWatch Logs. + +## Features + +- Structured JSON log output +- Automatic inclusion of request ID and trace ID +- Support for all log levels (TRACE, DEBUG, INFO, WARN, ERROR, FATAL) +- Custom metadata in logs +- Compatible with CloudWatch Logs Insights queries + +## Code + +The Lambda function demonstrates various logging levels and metadata usage. When `AWS_LAMBDA_LOG_FORMAT` is set to `JSON`, all logs are automatically formatted as JSON objects with the following structure: + +```json +{ + "timestamp": "2024-10-27T19:17:45.586Z", + "level": "INFO", + "message": "Processing request for Alice", + "requestId": "79b4f56e-95b1-4643-9700-2807f4e68189", + "traceId": "Root=1-67890abc-def12345678901234567890a" +} +``` + +## Configuration + +### Environment Variables + +- `AWS_LAMBDA_LOG_FORMAT`: Set to `JSON` for structured logging (default: `Text`) +- `AWS_LAMBDA_LOG_LEVEL`: Control which logs are sent to CloudWatch + - Valid values: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL` + - Default: `INFO` when JSON format is enabled + +### SAM Template Configuration + +Add the `LoggingConfig` property to your Lambda function: + +```yaml +Resources: + JSONLoggingFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip + Handler: swift.bootstrap + Runtime: provided.al2023 + Architectures: + - arm64 + LoggingConfig: + LogFormat: JSON + ApplicationLogLevel: INFO # TRACE | DEBUG | INFO | WARN | ERROR | FATAL + SystemLogLevel: INFO # DEBUG | INFO | WARN +``` + +## Test Locally + +Start the local server with TEXT logging: + +```bash +swift run +``` + +Send test requests: + +```bash +# Basic request +curl -d '{"name":"Alice"}' http://127.0.0.1:7000/invoke + +# Request with custom level +curl -d '{"name":"Bob","level":"debug"}' http://127.0.0.1:7000/invoke + +# Trigger error logging +curl -d '{"name":"error"}' http://127.0.0.1:7000/invoke +``` + +To test with JSON logging locally, set the environment variable: + +```bash +AWS_LAMBDA_LOG_FORMAT=JSON swift run +``` + +## Build & Package + +```bash +swift build +LAMBDA_USE_LOCAL_DEPS=../.. swift package archive --allow-network-connections docker +``` + +The deployment package will be at: +`.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip` + +## Deploy with SAM + +Create a `template.yaml` file: + +```yaml +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: JSON Logging Example + +Resources: + JSONLoggingFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip + Timeout: 60 + Handler: swift.bootstrap + Runtime: provided.al2023 + Architectures: + - arm64 + LoggingConfig: + LogFormat: JSON + ApplicationLogLevel: DEBUG + SystemLogLevel: INFO + +Outputs: + FunctionName: + Description: Lambda Function Name + Value: !Ref JSONLoggingFunction +``` + +Deploy: + +```bash +sam deploy --guided +``` + +## Deploy with AWS CLI + +As an alternative to SAM, you can use the AWS CLI: + +```bash +ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text) +aws lambda create-function \ + --function-name JSONLoggingExample \ + --zip-file fileb://.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip \ + --runtime provided.al2023 \ + --handler swift.bootstrap \ + --architectures arm64 \ + --role arn:aws:iam::${ACCOUNT_ID}:role/lambda_basic_execution \ + --logging-config LogFormat=JSON,ApplicationLogLevel=DEBUG,SystemLogLevel=INFO +``` + +## Invoke + +```bash +aws lambda invoke \ + --function-name JSONLoggingExample \ + --cli-binary-format raw-in-base64-out \ + --payload '{"name":"Alice","level":"debug"}' \ + response.json && cat response.json && rm response.json +``` + +## Query Logs with CloudWatch Logs Insights + +With JSON formatted logs, you can use powerful queries in [CloudWatch Logs Insights](https://console.aws.amazon.com/cloudwatch/home#logsV2:logs-insights). + +### Using the AWS Console + +1. Open the [CloudWatch Logs Insights console](https://console.aws.amazon.com/cloudwatch/home#logsV2:logs-insights) +2. In the "Select log group(s)" dropdown, choose the log group for your Lambda function (typically `/aws/lambda/JSONLoggingExample`) +3. Type or paste one of the queries below into the query editor +4. Adjust the time range in the top-right corner to cover the period you're interested in +5. Click "Run query" + +``` +# Find all ERROR level logs +fields @timestamp, level, message, requestId +| filter level = "ERROR" +| sort @timestamp desc + +# Find logs for a specific request +fields @timestamp, level, message +| filter requestId = "79b4f56e-95b1-4643-9700-2807f4e68189" +| sort @timestamp asc + +# Count logs by level +stats count() by level + +# Find logs with specific metadata +fields @timestamp, message, metadata.errorType +| filter metadata.errorType = "SimulatedError" +``` + +### Using the AWS CLI + +You can also run Logs Insights queries from the command line. Each query is a two-step process: start the query, then fetch the results. + +```bash +# 1. Start a query (adjust --start-time and --end-time as needed) +QUERY_ID=$(aws logs start-query \ + --log-group-name '/aws/lambda/JSONLoggingExample' \ + --start-time $(date -v-1H +%s) \ + --end-time $(date +%s) \ + --query-string 'fields @timestamp, level, message | filter level = "ERROR" | sort @timestamp desc' \ + --query 'queryId' --output text) + +# 2. Wait a moment for the query to complete, then get the results +sleep 2 +aws logs get-query-results --query-id "$QUERY_ID" +``` + +A few more examples: + +```bash +# Count logs by level over the last 24 hours +QUERY_ID=$(aws logs start-query \ + --log-group-name '/aws/lambda/JSONLoggingExample' \ + --start-time $(date -v-24H +%s) \ + --end-time $(date +%s) \ + --query-string 'stats count() by level' \ + --query 'queryId' --output text) +sleep 2 +aws logs get-query-results --query-id "$QUERY_ID" + +# Find logs with a specific error type in the last hour +QUERY_ID=$(aws logs start-query \ + --log-group-name '/aws/lambda/JSONLoggingExample' \ + --start-time $(date -v-1H +%s) \ + --end-time $(date +%s) \ + --query-string 'fields @timestamp, message, metadata.errorType | filter metadata.errorType = "SimulatedError"' \ + --query 'queryId' --output text) +sleep 2 +aws logs get-query-results --query-id "$QUERY_ID" +``` + +> **Note**: On Linux, replace `date -v-1H +%s` with `date -d '1 hour ago' +%s` (and similarly for other time offsets). + +## Log Levels + +The runtime maps Swift's `Logger.Level` to AWS Lambda log levels: + +| Swift Logger.Level | JSON Output | Description | +|-------------------|-------------|-------------| +| `.trace` | `TRACE` | Most detailed | +| `.debug` | `DEBUG` | Debug information | +| `.info` | `INFO` | Informational | +| `.notice` | `INFO` | Notable events | +| `.warning` | `WARN` | Warning conditions | +| `.error` | `ERROR` | Error conditions | +| `.critical` | `FATAL` | Critical failures | + +## Benefits of JSON Logging + +1. **Structured Data**: Logs are key-value pairs, not plain text +2. **Easy Filtering**: Query specific fields in CloudWatch Logs Insights +3. **Automatic Context**: Request ID and trace ID included automatically +4. **Metadata Support**: Add custom fields to logs +5. **No Double Encoding**: Already-JSON logs aren't double-encoded +6. **Better Analysis**: Automated log analysis and alerting + +## Clean Up + +```bash +# SAM deployment +sam delete + +# AWS CLI deployment +aws lambda delete-function --function-name JSONLoggingExample +``` + +## ⚠️ Important Notes + +- JSON logging adds metadata, which increases log size +- Default log level is `INFO` when JSON format is enabled +- For Python functions, the default changes from `WARN` to `INFO` with JSON format +- Logs are only formatted as JSON in the Lambda environment, not in local testing (unless you set `AWS_LAMBDA_LOG_FORMAT=JSON`) diff --git a/Examples/JSONLogging/Sources/main.swift b/Examples/JSONLogging/Sources/main.swift new file mode 100644 index 000000000..14dafc2bd --- /dev/null +++ b/Examples/JSONLogging/Sources/main.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright SwiftAWSLambdaRuntime project authors +// Copyright (c) Amazon.com, Inc. or its affiliates. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AWSLambdaRuntime + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +// This example demonstrates structured JSON logging in AWS Lambda +// When AWS_LAMBDA_LOG_FORMAT=JSON, logs are automatically formatted as JSON + +struct Request: Decodable { + let name: String + let level: String? +} + +struct Response: Encodable { + let message: String + let timestamp: String +} + +let runtime = LambdaRuntime { + (event: Request, context: LambdaContext) in + + // These log statements will be formatted as JSON when AWS_LAMBDA_LOG_FORMAT=JSON + context.logger.trace("Processing request with trace level") + context.logger.debug("Request details", metadata: ["name": .string(event.name)]) + context.logger.info("Processing request for \(event.name)") + + if let level = event.level { + context.logger.notice("Custom log level requested: \(level)") + } + + context.logger.warning("This is a warning message") + + // Simulate different scenarios + if event.name.lowercased() == "error" { + context.logger.error( + "Error scenario triggered", + metadata: [ + "errorType": .string("SimulatedError"), + "errorCode": .string("TEST_ERROR"), + ] + ) + } + + return Response( + message: "Hello \(event.name)! Logs are in JSON format.", + timestamp: Date().ISO8601Format() + ) +} + +try await runtime.run() diff --git a/Examples/JSONLogging/template.yaml b/Examples/JSONLogging/template.yaml new file mode 100644 index 000000000..67bd9fb5b --- /dev/null +++ b/Examples/JSONLogging/template.yaml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: JSON Logging Example + +Resources: + JSONLoggingFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip + Timeout: 60 + Handler: swift.bootstrap + Runtime: provided.al2023 + MemorySize: 128 + Architectures: + - arm64 + LoggingConfig: + LogFormat: JSON + ApplicationLogLevel: DEBUG + SystemLogLevel: INFO + +Outputs: + FunctionName: + Description: Lambda Function Name + Value: !Ref JSONLoggingFunction diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Deployment.md b/Sources/AWSLambdaRuntime/Docs.docc/Deployment.md index cadeee486..48d85cd52 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Deployment.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Deployment.md @@ -640,7 +640,7 @@ LambdaApiStack: destroying... [1/1] We welcome contributions to this section. If you have experience deploying Swift Lambda functions with third-party tools like Serverless Framework, Terraform, or Pulumi, please share your knowledge with the community. -## ⚠️ Security and Reliability Notice +### ⚠️ Security and Reliability Notice These are example applications for demonstration purposes. When deploying such infrastructure in production environments, we strongly encourage you to follow these best practices for improved security and resiliency: diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md new file mode 100644 index 000000000..8d505075a --- /dev/null +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -0,0 +1,363 @@ +# Structured JSON Logging Support for swift-aws-lambda-runtime + +AWS Lambda supports advanced logging controls that enable functions to emit logs in JSON structured format and control log level granularity. The Swift AWS Lambda Runtime should support these capabilities to provide developers with enhanced logging, filtering, and observability features. + +## Overview + +For more details, see the [AWS Lambda advanced logging controls documentation](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-logformat.html). + +Versions: + +- v3 (2025-02-12): Add `LambdaManagedRuntime` in the list of struct to modify +- v2 (2025-01-20): Make `LogHandler` public +- v1 (2025-01-18): Initial version + +### Motivation + +#### Current Limitations + +##### Unstructured Logging Format + +Currently, the Swift runtime emits logs in plaintext (unstructured) format only. This creates several limitations: + +- No native support for JSON structured logging +- Difficult to query and filter logs programmatically +- Limited integration with CloudWatch Logs Insights +- Reduced observability capabilities compared to other Lambda runtimes + +##### Limited Log Level Configuration + +The current implementation supports log level control via the `LOG_LEVEL` environment variable, which works well for text format logging. However, AWS Lambda's new advanced logging controls introduce `AWS_LAMBDA_LOG_LEVEL` as the standard environment variable for log level configuration, particularly for JSON format logging. This creates a need to: + +- Support both `LOG_LEVEL` (existing) and `AWS_LAMBDA_LOG_LEVEL` (new) with proper precedence +- Align with AWS Lambda's standard logging environment variables +- Maintain backward compatibility while supporting new AWS logging features + +##### Limited Lambda Managed Instances Support + +For Lambda Managed Instances, the log format is always JSON and cannot be changed. While Swift functions can work with Lambda Managed Instances, they will have their application logs automatically converted to JSON format by the Lambda service, which may not preserve the intended structure or metadata. + +#### New Features + +##### Support for JSON Structured Logging + +AWS Lambda provides logging configuration through environment variables that custom runtimes should read and respect: + +- `AWS_LAMBDA_LOG_FORMAT`: Controls output format (`Text` or `JSON`) +- `AWS_LAMBDA_LOG_LEVEL`: Controls log level granularity (`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`) + +##### Enhanced Log Level Configuration + +The runtime should support both existing and new log level environment variables with proper precedence: + +1. `AWS_LAMBDA_LOG_LEVEL` (new AWS standard, takes precedence for JSON format) +2. `LOG_LEVEL` (existing, maintained for backward compatibility and preferred for text format) + +##### Enhanced Observability + +JSON structured logs enable: + +- Better integration with CloudWatch Logs Insights +- Programmatic log filtering and analysis +- Structured metadata inclusion (requestId, traceId, etc.) +- Cost optimization through dynamic log level control + +### Proposed Solution + +#### Environment Variable Configuration + +The runtime will read logging configuration from Lambda-provided environment variables: + +- When `AWS_LAMBDA_LOG_FORMAT=JSON`, emit structured JSON logs +- When `AWS_LAMBDA_LOG_FORMAT=Text` (or not set), maintain current plaintext behavior +- Support both `AWS_LAMBDA_LOG_LEVEL` and `LOG_LEVEL` with appropriate precedence based on format +- Maintain full backward compatibility with existing `LOG_LEVEL` usage + +#### JSON Log Format Structure + +When JSON format is enabled, application logs will follow this structure: + +```json +{ + "timestamp": "2024-01-16T10:30:45.586Z", + "level": "INFO", + "message": "User authentication successful", + "requestId": "8286a188-ba32-4475-8077-530cd35c09a9", + "traceId": "1-5e1b4151-43a0913a12345678901234567" +} +``` + +Additional fields can be included based on the logging context and user-provided metadata. + +#### Integration with swift-log + +The Swift runtime uses the `swift-log` library for logging. The implementation will: + +1. Create a custom `LogHandler` that supports JSON output when `AWS_LAMBDA_LOG_FORMAT=JSON` +2. Support both `AWS_LAMBDA_LOG_LEVEL` and `LOG_LEVEL` with format-appropriate precedence +3. Include Lambda-specific metadata (requestId, traceId, etc.) +4. Format logs according to the expected JSON structure +5. Continue using existing logging implementation when `AWS_LAMBDA_LOG_FORMAT=Text` (default) + +#### Logger Initialization Strategy + +The logger initialization will follow a two-phase approach: + +##### Runtime Initialization (once per runtime instance) + +```swift +let loggingConfiguration = LoggingConfiguration() +let runtimeLogger = loggingConfiguration.makeLogger(label: "LambdaRuntime") +``` + +##### Per-Request Logger Creation (once per invocation) + +```swift +let requestLogger = loggingConfiguration.makeLogger( + label: "Lambda", + requestID: invocation.metadata.requestID, + traceID: invocation.metadata.traceID +) +``` + +This approach ensures: + +- Request-specific metadata is included in all logs for that invocation +- Efficient logger creation (reuses configuration, creates new logger instance) +- Proper isolation between concurrent invocations +- Structured concurrency compliance + +### Detailed Solution + +#### LoggingConfiguration + +A new `LoggingConfiguration` struct will handle environment variable parsing and logger creation: + +```swift +public struct LoggingConfiguration: Sendable { + public enum LogFormat: String, CaseIterable { + case text = "Text" + case json = "JSON" + } + + public let format: LogFormat + public let level: Logger.Level + + public init() + + public func makeLogger( + label: String, + requestID: String? = nil, + traceID: String? = nil + ) -> Logger +} +``` + +Key features: + +- Reads `AWS_LAMBDA_LOG_FORMAT` and both `AWS_LAMBDA_LOG_LEVEL` and `LOG_LEVEL` environment variables +- Implements log level precedence rules based on format (AWS standard for JSON, existing behavior for text) +- Provides factory method for creating loggers with request-specific metadata +- Thread-safe and sendable for concurrent access + +#### JSONLogHandler + +A new `LogHandler` implementation for JSON format logging: + +```swift +public struct JSONLogHandler: LogHandler, Sendable { + public var logLevel: Logger.Level + public var metadata: Logger.Metadata + + public init( + label: String, + logLevel: Logger.Level = .info, + requestID: String, + traceID: String + ) + + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) +} +``` + +Key features: + +- Outputs JSON-formatted log entries to stdout +- Includes Lambda-specific metadata (requestId, traceId) +- Uses ISO 8601 timestamp format for compatibility +- Efficient JSON encoding using Foundation's JSONEncoder +- Cross-platform compatibility (macOS and Linux) + +#### Runtime Integration + +The `LambdaRuntime` will be updated to support the new logging configuration: + +##### Runtime Initialization + +```swift +public final class LambdaRuntime: ServiceLifecycle.Service, Sendable + where Handler: StreamingLambdaHandler +{ + public init( + handler: sending Handler, + loggingConfiguration: LoggingConfiguration = LoggingConfiguration(), + eventLoop: EventLoop = Lambda.defaultEventLoop, + logger: Logger? = nil + ) +} +``` + +##### Per-Request Logger Creation + +In the main run loop, each invocation will receive a logger with request-specific metadata: + +```swift +let requestLogger = loggingConfiguration.makeLogger( + label: "Lambda", + requestID: invocation.metadata.requestID, + traceID: invocation.metadata.traceID +) + +let context = LambdaContext( + requestID: invocation.metadata.requestID, + traceID: invocation.metadata.traceID, + // ... other properties + logger: requestLogger +) +``` + +#### Log Level Filtering + +When log level environment variables are set, implement efficient log level filtering at the handler level to avoid unnecessary processing of log messages that won't be emitted. The precedence rules are: + +- **JSON Format**: Prefer `AWS_LAMBDA_LOG_LEVEL`, fall back to `LOG_LEVEL` +- **Text Format**: Prefer `LOG_LEVEL` (existing behavior), support `AWS_LAMBDA_LOG_LEVEL` as alternative + +### Implementation Considerations + +#### Backward Compatibility + +- When `AWS_LAMBDA_LOG_FORMAT=Text` (or not set), the runtime continues working exactly as it does today +- No breaking changes to existing APIs +- Existing log level configuration via `LOG_LEVEL` continues to work exactly as before +- New `AWS_LAMBDA_LOG_LEVEL` support is additive, not replacing existing functionality + +#### Performance + +- JSON encoding only occurs when `AWS_LAMBDA_LOG_FORMAT=JSON` +- Efficient logger creation with minimal per-request overhead +- Log level filtering prevents unnecessary message processing + +#### Cross-Platform Support + +- Uses conditional imports for Foundation compatibility +- Tested on both macOS and Linux (Amazon Linux 2) +- ISO 8601 timestamp formatting works consistently across platforms + +#### System vs Application Logs + +Custom runtimes are NOT responsible for emitting system logs (START, END, REPORT). The Lambda service handles these automatically. This implementation only affects application logs emitted through the `Logger` instance. + +#### Logger Consistency Audit + +**Current Status**: Code audit reveals mixed logger usage patterns that need to be addressed for consistent JSON logging: + +**✅ Compliant Components:** +- `LambdaRuntimeClient` - properly receives logger from runtime +- `LambdaContext` - uses runtime-provided logger +- Handler adapters - accept logger parameters correctly + +**⚠️ Issues Identified:** +2. **Default parameters** in convenience initializers create new loggers instead of using runtime logger +3. **Examples** create independent loggers (acceptable for demonstration) + +**Required Changes:** +- Default logger parameters should be removed or use runtime logger +- All internal components must use the centralized logging configuration + +This ensures consistent JSON formatting and log level control across all runtime components. + +### Files to Create/Modify + +#### New Files + +1. `Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift` + - Environment variable parsing + - Logger factory methods + - Log level precedence logic + +2. `Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift` + - JSON log formatting + - Lambda metadata integration + - Cross-platform timestamp handling + +#### Modified Files + +1. `Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift` + - Add `LoggingConfiguration` parameter to initializers + - Integrate per-request logger creation + +2. `Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift` + - Add `LoggingConfiguration` parameter to initializers + - Integrate per-request logger creation + +3. `Sources/AWSLambdaRuntime/Lambda.swift` + - Update run loop to create request-specific loggers + - Pass enhanced context to handlers + +4. `Sources/AWSLambdaRuntime/LambdaContext.swift` + - Ensure logger property uses request-specific instance + +### Migration Considerations + +#### For Existing Applications + +- No code changes required for basic functionality +- Opt-in to JSON logging via environment variable +- Gradual migration path available + +#### For New Applications + +- JSON logging available from day one +- Enhanced observability capabilities +- Better integration with AWS tooling + +### Alternatives Considered + +#### Custom Logging Framework + +We considered creating a Lambda-specific logging framework instead of extending swift-log. However, swift-log is the established standard in the Swift on Server ecosystem, and extending it provides better compatibility with existing libraries and tools. + +#### Always-On JSON Logging + +We considered making JSON the default format, but this would break backward compatibility. The environment variable approach allows for gradual adoption while maintaining compatibility. + +### References + +- [AWS Lambda Advanced Logging Controls](https://docs.aws.amazon.com/lambda/latest/dg/configuration-logging.html) +- [Building a custom runtime for AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html) +- [Swift Logging API](https://github.com/apple/swift-log) +- [Lambda Managed Instances](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances.html) + +### Related Issues + +- [#634: Add Support for Structured JSON Logging](https://github.com/awslabs/swift-aws-lambda-runtime/issues/634) + +### Labels + +- enhancement +- logging +- observability +- aws-lambda + +### Priority + +Medium-High: This is a significant enhancement that improves observability and aligns with AWS Lambda best practices. It's also required for Lambda Managed Instances compatibility (which always use JSON format and cannot be changed). \ No newline at end of file diff --git a/Sources/AWSLambdaRuntime/Docs.docc/managed-instances.md b/Sources/AWSLambdaRuntime/Docs.docc/managed-instances.md index d95c458f9..7d56f0943 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/managed-instances.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/managed-instances.md @@ -15,7 +15,7 @@ Lambda Managed Instances enables you to run Lambda functions on your current-gen The key difference from traditional Lambda is concurrent execution support—multiple invocations can run simultaneously within the same execution environment on the same EC2 host. -## When to Use Lambda Managed Instances +### When to Use Lambda Managed Instances Lambda Managed Instances are ideal for: @@ -24,11 +24,11 @@ Lambda Managed Instances are ideal for: - **High-throughput scenarios** where concurrent execution on the same host improves performance and resource utilization - **Workloads requiring EC2 flexibility** while maintaining serverless operational simplicity -## Code Changes Required +### Code Changes Required Migrating existing Lambda functions to Lambda Managed Instances requires two simple changes: -### 1. Use `LambdaManagedRuntime` Instead of `LambdaRuntime` +#### 1. Use `LambdaManagedRuntime` Instead of `LambdaRuntime` Replace your standard `LambdaRuntime` initialization with `LambdaManagedRuntime`: @@ -50,7 +50,7 @@ let runtime = LambdaManagedRuntime { try await runtime.run() ``` -### 2. Ensure Handlers Conform to `Sendable` +#### 2. Ensure Handlers Conform to `Sendable` Because Lambda Managed Instances support concurrent invocations, your handler functions and structs must conform to the `Sendable` protocol to ensure thread safety: @@ -79,7 +79,7 @@ try await runtime.run() For simple data structures, the Swift compiler automatically infers `Sendable` conformance, but explicitly declaring it is recommended for clarity and safety. -## How It Works +### How It Works The runtime automatically detects the configured concurrency level through the `AWS_LAMBDA_MAX_CONCURRENCY` environment variable and launches the appropriate number of Runtime Interface Clients (RICs) to handle concurrent requests efficiently. @@ -110,14 +110,14 @@ targets: [ ] ``` -## Prerequisites +### Prerequisites Before deploying to Lambda Managed Instances: 1. Create a [Lambda Managed Instances capacity provider](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances-capacity-providers.html) in your AWS account 2. Configure your deployment to reference the capacity provider ARN -## Example Functions +### Example Functions The Swift AWS Lambda Runtime includes three comprehensive examples demonstrating Lambda Managed Instances capabilities: @@ -127,7 +127,7 @@ The Swift AWS Lambda Runtime includes three comprehensive examples demonstrating See the [ManagedInstances example directory](https://github.com/awslabs/swift-aws-lambda-runtime/tree/main/Examples/ManagedInstances) for complete deployment instructions using AWS SAM. -## Additional Resources +### Additional Resources - [AWS Lambda Managed Instances Documentation](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances.html) - [Execution Environment Guide](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances-execution-environment.html) diff --git a/Sources/AWSLambdaRuntime/Lambda.swift b/Sources/AWSLambdaRuntime/Lambda.swift index 1c499774c..c4bcf39f3 100644 --- a/Sources/AWSLambdaRuntime/Lambda.swift +++ b/Sources/AWSLambdaRuntime/Lambda.swift @@ -32,27 +32,53 @@ import ucrt @available(LambdaSwift 2.0, *) public enum Lambda { + @available( + *, + deprecated, + message: + "This method will be removed in a future major version update. Use runLoop(runtimeClient:handler:loggingConfiguration:logger:) instead." + ) @inlinable package static func runLoop( runtimeClient: RuntimeClient, handler: Handler, logger: Logger + ) async throws where Handler: StreamingLambdaHandler { + try await self.runLoop( + runtimeClient: runtimeClient, + handler: handler, + loggingConfiguration: LoggingConfiguration(logger: logger), + logger: logger + ) + } + + @inlinable + package static func runLoop( + runtimeClient: RuntimeClient, + handler: Handler, + loggingConfiguration: LoggingConfiguration, + logger: Logger ) async throws where Handler: StreamingLambdaHandler { var handler = handler - var logger = logger do { while !Task.isCancelled { logger.trace("Waiting for next invocation") let (invocation, writer) = try await runtimeClient.nextInvocation() - logger[metadataKey: "aws-request-id"] = "\(invocation.metadata.requestID)" + + // Create a per-request logger with request-specific metadata + let requestLogger = loggingConfiguration.makeLogger( + label: "Lambda", + requestID: invocation.metadata.requestID, + traceID: invocation.metadata.traceID + ) // when log level is trace or lower, print the first 6 Mb of the payload let bytes = invocation.event let maxPayloadPreviewSize = 6 * 1024 * 1024 var metadata: Logger.Metadata? = nil - if logger.logLevel <= .trace, + if requestLogger.logLevel <= .trace, let buffer = bytes.getSlice(at: 0, length: min(bytes.readableBytes, maxPayloadPreviewSize)) { metadata = [ @@ -61,7 +87,7 @@ public enum Lambda { ) ] } - logger.trace( + requestLogger.trace( "Sending invocation event to lambda handler", metadata: metadata ) @@ -78,16 +104,15 @@ public enum Lambda { deadline: LambdaClock.Instant( millisecondsSinceEpoch: invocation.metadata.deadlineInMillisSinceEpoch ), - logger: logger + logger: requestLogger ) ) - logger.trace("Handler finished processing invocation") + requestLogger.trace("Handler finished processing invocation") } catch { - logger.trace("Handler failed processing invocation", metadata: ["Handler error": "\(error)"]) + requestLogger.trace("Handler failed processing invocation", metadata: ["Handler error": "\(error)"]) try await writer.reportError(error) continue } - logger.handler.metadata.removeValue(forKey: "aws-request-id") } } catch is CancellationError { // don't allow cancellation error to propagate further diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift new file mode 100644 index 000000000..d8bdfc382 --- /dev/null +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -0,0 +1,210 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright SwiftAWSLambdaRuntime project authors +// Copyright (c) Amazon.com, Inc. or its affiliates. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import Synchronization + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// Serializes all stderr writes across JSONLogHandler instances so that +/// concurrent log calls (e.g. from multiple RICs on Lambda Managed Instances) +/// cannot interleave bytes mid-line. The lock is only held for the duration of +/// the POSIX write() syscall — JSON encoding happens outside the lock. +@available(LambdaSwift 2.0, *) +private let _stderrLock = Mutex(()) + +@available(LambdaSwift 2.0, *) +public struct JSONLogHandler: LogHandler { + public var logLevel: Logger.Level + public var metadata: Logger.Metadata = [:] + + private let label: String + private let requestID: String + private let traceID: String + + public init(label: String, logLevel: Logger.Level = .info, requestID: String, traceID: String) { + self.label = label + self.logLevel = logLevel + self.requestID = requestID + self.traceID = traceID + } + + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + // Merge metadata + var allMetadata = self.metadata + if let metadata = metadata { + allMetadata.merge(metadata) { _, new in new } + } + + // Create log entry struct + let logEntry = LogEntry( + timestamp: Date(), + level: Self.mapLogLevel(level), + message: message.description, + requestId: self.requestID, + traceId: self.traceID, + file: file, + function: function, + line: line, + metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } + ) + + // Encode to JSON and write to stderr using POSIX write() on fd 2. + // We avoid print() because Swift's stdout is fully buffered on Lambda (no TTY), + // causing log lines to never be flushed before the invocation completes. + // POSIX write() on fd 2 is unbuffered and avoids referencing the global + // `stderr` C pointer which is not concurrency-safe on Linux/Swift 6. + // We create a new encoder per call to avoid sharing a mutable reference type + // across concurrent log calls, since JSONEncoder is not thread-safe. + // JSONEncoder allocation is on the order of nanoseconds — the JSON serialization + // and the write() syscall dominate the cost by orders of magnitude. + // If profiling ever shows this matters, consider manual JSON serialization + // which would also bypass the Codable overhead entirely. + if let jsonData = Self.encodeLogEntry(logEntry) { + var output = jsonData + output.append(contentsOf: "\n".utf8) + let bytesWritten = self.writeToStderr(output) + if bytesWritten != output.count { + let warning = Data( + "STDERR_WRITE_INCOMPLETE expected=\(output.count) written=\(bytesWritten) level=\(logEntry.level) message=\(logEntry.message)\n" + .utf8 + ) + self.writeToStderr(warning) + } + } else { + // JSON encoding failed — emit a plain-text fallback to stderr so the log + // message is not silently lost. This should only happen if metadata contains + // values that cannot be encoded, which is unlikely with String-typed metadata. + let fallback = Data( + "JSON_ENCODE_ERROR level=\(logEntry.level) message=\(logEntry.message)\n".utf8 + ) + self.writeToStderr(fallback) + } + } + + public subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + + /// Writes raw bytes to stderr (fd 2) using POSIX write(). + /// The write is serialized through `_stderrLock` so that concurrent log + /// calls from multiple tasks cannot interleave bytes within a single line. + /// Uses a loop to handle partial writes and EINTR retries, ensuring + /// large log lines are not silently truncated. + /// - Returns: The number of bytes successfully written. + @discardableResult + private func writeToStderr(_ data: Data) -> Int { + _stderrLock.withLock { _ in + self.writeAll(data) { pointer, count in + #if canImport(Darwin) + Darwin.write(2, pointer, count) + #elseif canImport(Glibc) + Glibc.write(2, pointer, count) + #elseif canImport(Musl) + Musl.write(2, pointer, count) + #endif + } + } + } + + /// Write loop that handles partial writes and EINTR retries. + /// Accepts an injectable write function so tests can simulate partial writes. + /// - Parameters: + /// - data: The bytes to write. + /// - writeFn: A function matching the POSIX `write()` signature — takes a pointer + /// and byte count, returns the number of bytes written or -1 on error. + /// - Returns: The total number of bytes successfully written. + internal func writeAll( + _ data: Data, + using writeFn: (_ pointer: UnsafeRawPointer, _ count: Int) -> Int + ) -> Int { + data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { return 0 } + var remaining = buffer.count + var offset = 0 + while remaining > 0 { + let written = writeFn(baseAddress + offset, remaining) + if written < 0 { + // Retry on EINTR; give up on any other error + if errno == EINTR { continue } + return offset + } + offset += written + remaining -= written + } + return offset + } + } + + // MARK: - Log Entry Structure + + struct LogEntry: Codable { + let timestamp: Date + let level: String + let message: String + let requestId: String + let traceId: String + let file: String + let function: String + let line: UInt + let metadata: [String: String]? + } + + /// Encodes a log entry to JSON data. Extracted for testability. + /// Returns nil if encoding fails. + internal static func encodeLogEntry(_ logEntry: LogEntry) -> Data? { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true))) + } + encoder.outputFormatting = [] // Compact output (no pretty printing) + return try? encoder.encode(logEntry) + } + + /// Maps a swift-log level to the AWS Lambda log level string. + internal static func mapLogLevel(_ level: Logger.Level) -> String { + switch level { + case .trace: return "TRACE" + case .debug: return "DEBUG" + case .info: return "INFO" + case .notice: return "INFO" + case .warning: return "WARN" + case .error: return "ERROR" + case .critical: return "FATAL" + } + } +} diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift new file mode 100644 index 000000000..dd8421660 --- /dev/null +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright SwiftAWSLambdaRuntime project authors +// Copyright (c) Amazon.com, Inc. or its affiliates. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging + +@available(LambdaSwift 2.0, *) +public struct LoggingConfiguration: Sendable { + public enum LogFormat: String, Sendable { + case text = "Text" + case json = "JSON" + } + + public let format: LogFormat + public let applicationLogLevel: Logger.Level? + /// Stores the raw environment variable value when it couldn't be parsed as a valid log level. + /// Callers should use `logConfigurationWarnings(logger:)` after obtaining a configured logger. + private let unrecognizedLogLevel: String? + private let baseLogger: Logger + + /// Note: No log messages are emitted during initialization because the logging + /// configuration is not yet fully constructed. The provided `logger` still uses its + /// original format and log level, so any messages emitted here would bypass the + /// configured format (e.g. appearing as plain text when JSON mode is selected). + /// Callers should use `makeRuntimeLogger()` after initialization to obtain a + /// properly configured logger for any diagnostic messages. + public init(logger: Logger) { + // Read AWS_LAMBDA_LOG_FORMAT (default: Text) + self.format = + LogFormat( + rawValue: Lambda.env("AWS_LAMBDA_LOG_FORMAT") ?? "Text" + ) ?? .text + + // Store the base logger for cloning + self.baseLogger = logger + + // Determine log level with proper precedence + // When both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set: + // - JSON format: AWS_LAMBDA_LOG_LEVEL takes precedence + // - Text format: LOG_LEVEL takes precedence (backward compatibility) + let awsLambdaLogLevel = Lambda.env("AWS_LAMBDA_LOG_LEVEL") + let logLevel = Lambda.env("LOG_LEVEL") + + // Determine which raw env var value to parse based on format and precedence + let rawLevel: String? + switch (self.format, awsLambdaLogLevel, logLevel) { + case (.json, .some(let awsLevel), _): + rawLevel = awsLevel + case (.json, .none, .some(let legacyLevel)): + rawLevel = legacyLevel + case (.text, _, .some(let legacyLevel)): + rawLevel = legacyLevel + case (.text, .some(let awsLevel), .none): + rawLevel = awsLevel + case (_, .none, .none): + rawLevel = nil + } + + self.applicationLogLevel = rawLevel.flatMap { Self.parseLogLevel($0) } + self.unrecognizedLogLevel = rawLevel != nil && self.applicationLogLevel == nil ? rawLevel : nil + } + + private static func parseLogLevel(_ level: String) -> Logger.Level? { + switch level.uppercased() { + case "TRACE": return .trace + case "DEBUG": return .debug + case "INFO": return .info + case "NOTICE": return .notice + case "WARN", "WARNING": return .warning + case "ERROR": return .error + case "FATAL", "CRITICAL": return .critical + default: return nil + } + } + + /// Create a logger for a specific invocation + public func makeLogger( + label: String, + requestID: String, + traceID: String + ) -> Logger { + switch self.format { + case .text: + // Clone the base logger and add request metadata + var logger = self.baseLogger + logger[metadataKey: "aws-request-id"] = .string(requestID) + logger[metadataKey: "aws-trace-id"] = .string(traceID) + if let level = self.applicationLogLevel { + logger.logLevel = level + } + return logger + + case .json: + // Use JSON log handler + var logger = Logger(label: label) { label in + JSONLogHandler( + label: label, + requestID: requestID, + traceID: traceID + ) + } + if let level = self.applicationLogLevel { + logger.logLevel = level + } + return logger + } + } + + /// Create a logger for runtime-level messages (before any invocation). + /// In text mode, this returns the base logger provided by the user. + /// In JSON mode, this creates a JSON logger using the base logger's label. + public func makeRuntimeLogger() -> Logger { + var logger: Logger + switch self.format { + case .text: + logger = self.baseLogger + case .json: + logger = Logger(label: self.baseLogger.label) { label in + JSONLogHandler( + label: label, + requestID: "N/A", + traceID: "N/A" + ) + } + } + if let level = self.applicationLogLevel { + logger.logLevel = level + } + if let unrecognized = self.unrecognizedLogLevel { + logger.warning( + "Unrecognized log level '\(unrecognized)'. Using default log level. Valid values: TRACE, DEBUG, INFO, NOTICE, WARN, ERROR, FATAL." + ) + } + return logger + } +} diff --git a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift index 2b1638654..0ad06cba4 100644 --- a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift +++ b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift @@ -25,6 +25,9 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream @usableFromInline let logger: Logger + @usableFromInline + let loggingConfiguration: LoggingConfiguration + @usableFromInline let eventLoop: EventLoop @@ -39,17 +42,21 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream self.handler = handler self.eventLoop = eventLoop + // Initialize logging configuration + self.loggingConfiguration = LoggingConfiguration(logger: logger) + // by setting the log level here, we understand it can not be changed dynamically at runtime // developers have to wait for AWS Lambda to dispose and recreate a runtime environment to pickup a change // this approach is less flexible but more performant than reading the value of the environment variable at each invocation - var log = logger - - // use the LOG_LEVEL environment variable to set the log level. - // if the environment variable is not set, use the default log level from the logger provided - log.logLevel = Lambda.env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? logger.logLevel - + let log = self.loggingConfiguration.makeRuntimeLogger() self.logger = log - self.logger.debug("LambdaManagedRuntime initialized") + self.logger.debug( + "LambdaManagedRuntime initialized", + metadata: [ + "logFormat": "\(self.loggingConfiguration.format)", + "logLevel": "\(log.logLevel)", + ] + ) } #if !ServiceLifecycleSupport @@ -88,6 +95,7 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream endpoint: runtimeEndpoint, handler: self.handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: self.logger ) } else { @@ -104,6 +112,7 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream endpoint: runtimeEndpoint, handler: self.handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: logger ) } @@ -119,6 +128,7 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream try await LambdaRuntime.startLocalServer( handler: self.handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: self.logger ) } diff --git a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift index 72a6aca6c..953ec98e8 100644 --- a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift +++ b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift @@ -46,6 +46,8 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb @usableFromInline let logger: Logger @usableFromInline + let loggingConfiguration: LoggingConfiguration + @usableFromInline let eventLoop: EventLoop public init( @@ -56,17 +58,21 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb self.handlerStorage = SendingStorage(handler) self.eventLoop = eventLoop + // Initialize logging configuration + self.loggingConfiguration = LoggingConfiguration(logger: logger) + // by setting the log level here, we understand it can not be changed dynamically at runtime // developers have to wait for AWS Lambda to dispose and recreate a runtime environment to pickup a change // this approach is less flexible but more performant than reading the value of the environment variable at each invocation - var log = logger - - // use the LOG_LEVEL environment variable to set the log level. - // if the environment variable is not set, use the default log level from the logger provided - log.logLevel = Lambda.env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? logger.logLevel - + let log = self.loggingConfiguration.makeRuntimeLogger() self.logger = log - self.logger.debug("LambdaRuntime initialized") + self.logger.debug( + "LambdaRuntime initialized", + metadata: [ + "logFormat": "\(self.loggingConfiguration.format)", + "logLevel": "\(log.logLevel)", + ] + ) } #if !ServiceLifecycleSupport @@ -98,6 +104,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb endpoint: runtimeEndpoint, handler: handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: self.logger ) @@ -107,6 +114,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb try await LambdaRuntime.startLocalServer( handler: handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: self.logger ) } @@ -117,6 +125,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb endpoint: String, handler: Handler, eventLoop: EventLoop, + loggingConfiguration: LoggingConfiguration, logger: Logger ) async throws { @@ -133,6 +142,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb try await Lambda.runLoop( runtimeClient: runtimeClient, handler: handler, + loggingConfiguration: loggingConfiguration, logger: logger ) } @@ -155,6 +165,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb internal static func startLocalServer( handler: sending Handler, eventLoop: EventLoop, + loggingConfiguration: LoggingConfiguration, logger: Logger ) async throws { #if LocalServerSupport @@ -181,6 +192,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb try await Lambda.runLoop( runtimeClient: runtimeClient, handler: handler, + loggingConfiguration: loggingConfiguration, logger: logger ) } diff --git a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift new file mode 100644 index 000000000..c1cd5681a --- /dev/null +++ b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift @@ -0,0 +1,342 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright SwiftAWSLambdaRuntime project authors +// Copyright (c) Amazon.com, Inc. or its affiliates. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import Testing + +@testable import AWSLambdaRuntime + +#if canImport(Darwin) +import Darwin.C +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +@Suite +struct JSONLogHandlerTests { + + // MARK: - Helpers + + /// Decodable mirror of LogEntry for test assertions. + private struct TestLogEntry: Decodable { + let timestamp: String + let level: String + let message: String + let requestId: String + let traceId: String + let file: String? + let function: String? + let line: UInt? + let metadata: [String: String]? + } + + /// Creates a LogEntry and encodes it, returning the decoded TestLogEntry for assertions. + @available(LambdaSwift 2.0, *) + private func makeAndEncode( + level: Logger.Level = .info, + message: String = "test", + requestID: String = "req-1", + traceID: String = "trace-1", + file: String = "TestFile.swift", + function: String = "testFunction()", + line: UInt = 1, + handlerMetadata: Logger.Metadata = [:], + callMetadata: Logger.Metadata? = nil + ) -> (entry: TestLogEntry?, rawJSON: String?) { + // Merge metadata the same way the handler does + var allMetadata = handlerMetadata + if let callMetadata { + allMetadata.merge(callMetadata) { _, new in new } + } + + let logEntry = JSONLogHandler.LogEntry( + timestamp: Date(), + level: JSONLogHandler.mapLogLevel(level), + message: message, + requestId: requestID, + traceId: traceID, + file: file, + function: function, + line: line, + metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } + ) + + guard let data = JSONLogHandler.encodeLogEntry(logEntry) else { + return (nil, nil) + } + + let rawJSON = String(data: data, encoding: .utf8) + let decoded = try? JSONDecoder().decode(TestLogEntry.self, from: data) + return (decoded, rawJSON) + } + + // MARK: - JSON Structure + + @Test("Encoded log entry contains all expected fields") + @available(LambdaSwift 2.0, *) + func wellFormedJSON() { + let (entry, rawJSON) = makeAndEncode( + message: "hello world", + requestID: "req-abc", + traceID: "trace-xyz" + ) + + #expect(rawJSON != nil, "Encoding should produce valid JSON") + #expect(entry != nil, "JSON should decode back to TestLogEntry") + #expect(entry?.timestamp.isEmpty == false) + #expect(entry?.level == "INFO") + #expect(entry?.message == "hello world") + #expect(entry?.requestId == "req-abc") + #expect(entry?.traceId == "trace-xyz") + } + + // MARK: - Log Level Mapping + + @Test("Log levels are mapped correctly to AWS Lambda level strings") + @available(LambdaSwift 2.0, *) + func logLevelMapping() { + let cases: [(Logger.Level, String)] = [ + (.trace, "TRACE"), + (.debug, "DEBUG"), + (.info, "INFO"), + (.notice, "INFO"), + (.warning, "WARN"), + (.error, "ERROR"), + (.critical, "FATAL"), + ] + + for (level, expected) in cases { + let mapped = JSONLogHandler.mapLogLevel(level) + #expect(mapped == expected, "Expected \(level) to map to \(expected)") + } + } + + // MARK: - Metadata + + @Test("Per-call metadata is included in encoded output") + @available(LambdaSwift 2.0, *) + func perCallMetadata() { + let (entry, _) = makeAndEncode(callMetadata: ["key1": "value1", "key2": "value2"]) + + #expect(entry?.metadata?["key1"] == "value1") + #expect(entry?.metadata?["key2"] == "value2") + } + + @Test("Handler-level metadata is included in encoded output") + @available(LambdaSwift 2.0, *) + func handlerLevelMetadata() { + let (entry, _) = makeAndEncode(handlerMetadata: ["persistent": "yes"]) + + #expect(entry?.metadata?["persistent"] == "yes") + } + + @Test("Per-call metadata overrides handler-level metadata for same key") + @available(LambdaSwift 2.0, *) + func metadataMergeOverride() { + let (entry, _) = makeAndEncode( + handlerMetadata: ["key": "old"], + callMetadata: ["key": "new"] + ) + + #expect(entry?.metadata?["key"] == "new") + } + + @Test("Metadata field is nil when no metadata is provided") + @available(LambdaSwift 2.0, *) + func noMetadataField() { + let (entry, _) = makeAndEncode() + + #expect(entry?.metadata == nil) + } + + // MARK: - Request ID and Trace ID + + @Test("requestID and traceID are correctly encoded") + @available(LambdaSwift 2.0, *) + func requestAndTraceIDs() { + let (entry, _) = makeAndEncode( + requestID: "550e8400-e29b-41d4-a716-446655440000", + traceID: "Root=1-5e1b4151-43a0913a12345678901234567" + ) + + #expect(entry?.requestId == "550e8400-e29b-41d4-a716-446655440000") + #expect(entry?.traceId == "Root=1-5e1b4151-43a0913a12345678901234567") + } + + // MARK: - Source Location + + @Test("Log entry includes file, function, and line") + @available(LambdaSwift 2.0, *) + func sourceLocation() { + let (entry, _) = makeAndEncode( + file: "Sources/MyLambda/Handler.swift", + function: "handle(_:context:)", + line: 42 + ) + + #expect(entry?.file == "Sources/MyLambda/Handler.swift") + #expect(entry?.function == "handle(_:context:)") + #expect(entry?.line == 42) + } + + // MARK: - Timestamp + + @Test("Timestamp is in ISO 8601 format") + @available(LambdaSwift 2.0, *) + func iso8601Timestamp() { + let (entry, _) = makeAndEncode() + let timestamp = entry?.timestamp + #expect(timestamp != nil) + + // Verify it matches ISO 8601 format with milliseconds (e.g. "2024-01-16T10:30:45.123Z") + let iso8601Pattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}Z$"# + let matches = timestamp?.range(of: iso8601Pattern, options: .regularExpression) != nil + #expect(matches, "Timestamp '\(timestamp ?? "")' should be in ISO 8601 format with fractional seconds") + } + + // MARK: - Metadata subscript + + @Test("Metadata subscript get and set work correctly") + @available(LambdaSwift 2.0, *) + func metadataSubscript() { + var handler = JSONLogHandler(label: "test", requestID: "r", traceID: "t") + + #expect(handler[metadataKey: "foo"] == nil) + + handler[metadataKey: "foo"] = "bar" + #expect(handler[metadataKey: "foo"] == "bar") + + handler[metadataKey: "foo"] = nil + #expect(handler[metadataKey: "foo"] == nil) + } + + // MARK: - Encoding + + @Test("encodeLogEntry returns non-nil for valid entry") + @available(LambdaSwift 2.0, *) + func encodeReturnsData() { + let logEntry = JSONLogHandler.LogEntry( + timestamp: Date(), + level: "INFO", + message: "test", + requestId: "r", + traceId: "t", + file: "Test.swift", + function: "test()", + line: 1, + metadata: nil + ) + let data = JSONLogHandler.encodeLogEntry(logEntry) + #expect(data != nil) + #expect(data?.isEmpty == false) + } + + // MARK: - writeAll (write loop) + + /// Creates a minimal handler instance for testing writeAll. + @available(LambdaSwift 2.0, *) + private func makeHandler() -> JSONLogHandler { + JSONLogHandler(label: "test", requestID: "r", traceID: "t") + } + + @Test("writeAll writes all bytes in a single call when write succeeds fully") + @available(LambdaSwift 2.0, *) + func writeAllSingleCall() { + let handler = makeHandler() + let data = Data("hello".utf8) + var callCount = 0 + let written = handler.writeAll(data) { _, count in + callCount += 1 + return count // write everything at once + } + #expect(written == data.count) + #expect(callCount == 1) + } + + @Test("writeAll handles partial writes by looping until all bytes are written") + @available(LambdaSwift 2.0, *) + func writeAllPartialWrites() { + let handler = makeHandler() + let data = Data("hello world!".utf8) // 12 bytes + var callCount = 0 + let written = handler.writeAll(data) { _, count in + callCount += 1 + // Simulate writing at most 4 bytes per call + return min(count, 4) + } + #expect(written == data.count) + #expect(callCount == 3) // 4 + 4 + 4 + } + + @Test("writeAll retries on EINTR and eventually succeeds") + @available(LambdaSwift 2.0, *) + func writeAllRetriesOnEINTR() { + let handler = makeHandler() + let data = Data("abc".utf8) + var callCount = 0 + let written = handler.writeAll(data) { _, count in + callCount += 1 + if callCount <= 2 { + // Simulate EINTR on first two attempts + errno = EINTR + return -1 + } + return count + } + #expect(written == data.count) + #expect(callCount == 3) + } + + @Test("writeAll stops and returns partial count on non-EINTR error") + @available(LambdaSwift 2.0, *) + func writeAllStopsOnError() { + let handler = makeHandler() + let data = Data("hello world!".utf8) // 12 bytes + var callCount = 0 + let written = handler.writeAll(data) { _, count in + callCount += 1 + if callCount == 1 { + return min(count, 4) // write 4 bytes + } + // Fail with ENOSPC on second call + errno = ENOSPC + return -1 + } + #expect(written == 4) + #expect(callCount == 2) + } + + @Test("writeAll returns 0 for empty data") + @available(LambdaSwift 2.0, *) + func writeAllEmptyData() { + let handler = makeHandler() + let data = Data() + var callCount = 0 + let written = handler.writeAll(data) { _, count in + callCount += 1 + return count + } + #expect(written == 0) + #expect(callCount == 0) + } +} diff --git a/Tests/AWSLambdaRuntimeTests/LambdaRunLoopTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaRunLoopTests.swift index c508fb7a5..d2484f2a3 100644 --- a/Tests/AWSLambdaRuntimeTests/LambdaRunLoopTests.swift +++ b/Tests/AWSLambdaRuntimeTests/LambdaRunLoopTests.swift @@ -60,14 +60,16 @@ struct LambdaRunLoopTests { try await withThrowingTaskGroup(of: Void.self) { group in let logStore = CollectEverythingLogHandler.LogStore() + let logger = Logger( + label: "RunLoopTest", + factory: { _ in CollectEverythingLogHandler(logStore: logStore) } + ) group.addTask { try await Lambda.runLoop( runtimeClient: mockClient, handler: mockEchoHandler, - logger: Logger( - label: "RunLoopTest", - factory: { _ in CollectEverythingLogHandler(logStore: logStore) } - ) + loggingConfiguration: LoggingConfiguration(logger: logger), + logger: logger ) } @@ -89,14 +91,16 @@ struct LambdaRunLoopTests { await withThrowingTaskGroup(of: Void.self) { group in let logStore = CollectEverythingLogHandler.LogStore() + let logger = Logger( + label: "RunLoopTest", + factory: { _ in CollectEverythingLogHandler(logStore: logStore) } + ) group.addTask { try await Lambda.runLoop( runtimeClient: mockClient, handler: failingHandler, - logger: Logger( - label: "RunLoopTest", - factory: { _ in CollectEverythingLogHandler(logStore: logStore) } - ) + loggingConfiguration: LoggingConfiguration(logger: logger), + logger: logger ) } diff --git a/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift new file mode 100644 index 000000000..74bc891a6 --- /dev/null +++ b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift @@ -0,0 +1,306 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright SwiftAWSLambdaRuntime project authors +// Copyright (c) Amazon.com, Inc. or its affiliates. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import Testing + +@testable import AWSLambdaRuntime + +#if canImport(Darwin) +import Darwin.C +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +// These tests manipulate process-wide environment variables, so they must run serially. +@Suite(.serialized) +struct LoggingConfigurationTests { + + // MARK: - Helpers + + /// Sets environment variables for the duration of a closure, then restores them. + private func withEnvironment( + _ vars: [String: String?], + body: () throws -> Void + ) rethrows { + var originals: [String: String?] = [:] + for (key, value) in vars { + originals[key] = getenv(key).map { String(cString: $0) } + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + defer { + for (key, original) in originals { + if let original { + setenv(key, original, 1) + } else { + unsetenv(key) + } + } + } + try body() + } + + private let envKeys = ["AWS_LAMBDA_LOG_FORMAT", "AWS_LAMBDA_LOG_LEVEL", "LOG_LEVEL"] + + /// Clears all logging-related env vars, runs body, then restores. + private func withCleanEnvironment(body: () throws -> Void) rethrows { + try withEnvironment(Dictionary(uniqueKeysWithValues: envKeys.map { ($0, nil as String?) }), body: body) + } + + // MARK: - Format Parsing + + @Test("Default format is text when AWS_LAMBDA_LOG_FORMAT is not set") + @available(LambdaSwift 2.0, *) + func defaultFormatIsText() { + withCleanEnvironment { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.format == .text) + } + } + + @Test("Format is text when AWS_LAMBDA_LOG_FORMAT=Text") + @available(LambdaSwift 2.0, *) + func explicitTextFormat() { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_FORMAT": "Text"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.format == .text) + } + } + } + + @Test("Format is JSON when AWS_LAMBDA_LOG_FORMAT=JSON") + @available(LambdaSwift 2.0, *) + func jsonFormat() { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_FORMAT": "JSON"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.format == .json) + } + } + } + + @Test("Invalid format falls back to text") + @available(LambdaSwift 2.0, *) + func invalidFormatFallsBackToText() { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_FORMAT": "INVALID"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.format == .text) + } + } + } + + // MARK: - Default Log Level + + @Test("No log level when no env vars are set") + @available(LambdaSwift 2.0, *) + func noLogLevelByDefault() { + withCleanEnvironment { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == nil) + } + } + + // MARK: - JSON Format Precedence + + @Test("JSON format: AWS_LAMBDA_LOG_LEVEL takes precedence over LOG_LEVEL") + @available(LambdaSwift 2.0, *) + func jsonPrefersAwsLogLevel() { + withCleanEnvironment { + withEnvironment([ + "AWS_LAMBDA_LOG_FORMAT": "JSON", + "AWS_LAMBDA_LOG_LEVEL": "ERROR", + "LOG_LEVEL": "DEBUG", + ]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .error) + } + } + } + + @Test("JSON format: uses AWS_LAMBDA_LOG_LEVEL when only it is set") + @available(LambdaSwift 2.0, *) + func jsonUsesAwsLogLevelAlone() { + withCleanEnvironment { + withEnvironment([ + "AWS_LAMBDA_LOG_FORMAT": "JSON", + "AWS_LAMBDA_LOG_LEVEL": "TRACE", + ]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .trace) + } + } + } + + @Test("JSON format: falls back to LOG_LEVEL when AWS_LAMBDA_LOG_LEVEL is not set") + @available(LambdaSwift 2.0, *) + func jsonFallsBackToLogLevel() { + withCleanEnvironment { + withEnvironment([ + "AWS_LAMBDA_LOG_FORMAT": "JSON", + "LOG_LEVEL": "WARN", + ]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .warning) + } + } + } + + // MARK: - Text Format Precedence + + @Test("Text format: LOG_LEVEL takes precedence over AWS_LAMBDA_LOG_LEVEL") + @available(LambdaSwift 2.0, *) + func textPrefersLogLevel() { + withCleanEnvironment { + withEnvironment([ + "AWS_LAMBDA_LOG_FORMAT": "Text", + "AWS_LAMBDA_LOG_LEVEL": "ERROR", + "LOG_LEVEL": "DEBUG", + ]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .debug) + } + } + } + + @Test("Text format: uses LOG_LEVEL when only it is set") + @available(LambdaSwift 2.0, *) + func textUsesLogLevelAlone() { + withCleanEnvironment { + withEnvironment(["LOG_LEVEL": "ERROR"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .error) + } + } + } + + @Test("Text format: falls back to AWS_LAMBDA_LOG_LEVEL when LOG_LEVEL is not set") + @available(LambdaSwift 2.0, *) + func textFallsBackToAwsLogLevel() { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_LEVEL": "TRACE"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .trace) + } + } + } + + // MARK: - Log Level Parsing + + @Test("All log level strings are parsed correctly") + @available(LambdaSwift 2.0, *) + func logLevelParsing() { + let cases: [(String, Logger.Level)] = [ + ("TRACE", .trace), + ("DEBUG", .debug), + ("INFO", .info), + ("NOTICE", .notice), + ("WARN", .warning), + ("WARNING", .warning), + ("ERROR", .error), + ("FATAL", .critical), + ("CRITICAL", .critical), + ] + for (input, expected) in cases { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_LEVEL": input]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == expected, "Expected \(input) to parse as \(expected)") + } + } + } + } + + @Test("Unknown log level string defaults to nil") + @available(LambdaSwift 2.0, *) + func unknownLogLevelDefaultsToNil() { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_LEVEL": "UNKNOWN"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == nil) + } + } + } + + // MARK: - Logger Creation + + @Test("makeRuntimeLogger in text mode returns logger with configured level") + @available(LambdaSwift 2.0, *) + func makeRuntimeLoggerTextMode() { + withCleanEnvironment { + withEnvironment(["LOG_LEVEL": "ERROR"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + let logger = config.makeRuntimeLogger() + #expect(logger.logLevel == .error) + } + } + } + + @Test("makeRuntimeLogger in JSON mode returns logger with configured level") + @available(LambdaSwift 2.0, *) + func makeRuntimeLoggerJsonMode() { + withCleanEnvironment { + withEnvironment([ + "AWS_LAMBDA_LOG_FORMAT": "JSON", + "AWS_LAMBDA_LOG_LEVEL": "DEBUG", + ]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + let logger = config.makeRuntimeLogger() + #expect(logger.logLevel == .debug) + } + } + } + + @Test("makeLogger creates logger with request metadata in text mode") + @available(LambdaSwift 2.0, *) + func makeLoggerTextModeMetadata() { + withCleanEnvironment { + let logStore = CollectEverythingLogHandler.LogStore() + let baseLogger = Logger(label: "test") { _ in CollectEverythingLogHandler(logStore: logStore) } + + let config = LoggingConfiguration(logger: baseLogger) + let logger = config.makeLogger(label: "Lambda", requestID: "req-123", traceID: "trace-456") + + logger.info("test message") + + let logs = logStore.getAllLogs() + #expect(logs.count == 1) + #expect(logs[0].metadata["aws-request-id"] == "req-123") + #expect(logs[0].metadata["aws-trace-id"] == "trace-456") + } + } + + @Test("makeLogger in JSON mode applies configured log level") + @available(LambdaSwift 2.0, *) + func makeLoggerJsonModeLevel() { + withCleanEnvironment { + withEnvironment([ + "AWS_LAMBDA_LOG_FORMAT": "JSON", + "AWS_LAMBDA_LOG_LEVEL": "ERROR", + ]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + let logger = config.makeLogger(label: "Lambda", requestID: "req-123", traceID: "trace-456") + #expect(logger.logLevel == .error) + } + } + } +}