Add support for JSON Structured Logging (#638)

This PR adds support for Structured Logging, as per [the design
document](https://github.com/awslabs/swift-aws-lambda-runtime/blob/feature/structured-json-logging/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md)

---------

Co-authored-by: Sebastien Stormacq <stormacq@amazon.lu>
This commit is contained in:
Sébastien Stormacq
2026-02-25 08:36:49 +01:00
committed by GitHub
parent 1df89fd39b
commit 553b5e3716
16 changed files with 1848 additions and 39 deletions
+1
View File
@@ -0,0 +1 @@
samconfig.toml
+29
View File
@@ -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"
)
]
)
+268
View File
@@ -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`)
+68
View File
@@ -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()
+24
View File
@@ -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
@@ -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:
@@ -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<Handler>: 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).
@@ -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)
+33 -8
View File
@@ -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: LambdaRuntimeClientProtocol, Handler>(
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: LambdaRuntimeClientProtocol, Handler>(
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
@@ -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<Void>(())
@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"
}
}
}
@@ -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
}
}
@@ -25,6 +25,9 @@ public final class LambdaManagedRuntime<Handler>: Sendable where Handler: Stream
@usableFromInline
let logger: Logger
@usableFromInline
let loggingConfiguration: LoggingConfiguration
@usableFromInline
let eventLoop: EventLoop
@@ -39,17 +42,21 @@ public final class LambdaManagedRuntime<Handler>: 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<Handler>: 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<Handler>: 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<Handler>: Sendable where Handler: Stream
try await LambdaRuntime.startLocalServer(
handler: self.handler,
eventLoop: self.eventLoop,
loggingConfiguration: self.loggingConfiguration,
logger: self.logger
)
}
@@ -46,6 +46,8 @@ public final class LambdaRuntime<Handler>: 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<Handler>: 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<Handler>: 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<Handler>: 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<Handler>: Sendable where Handler: StreamingLamb
endpoint: String,
handler: Handler,
eventLoop: EventLoop,
loggingConfiguration: LoggingConfiguration,
logger: Logger
) async throws {
@@ -133,6 +142,7 @@ public final class LambdaRuntime<Handler>: Sendable where Handler: StreamingLamb
try await Lambda.runLoop(
runtimeClient: runtimeClient,
handler: handler,
loggingConfiguration: loggingConfiguration,
logger: logger
)
}
@@ -155,6 +165,7 @@ public final class LambdaRuntime<Handler>: 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<Handler>: Sendable where Handler: StreamingLamb
try await Lambda.runLoop(
runtimeClient: runtimeClient,
handler: handler,
loggingConfiguration: loggingConfiguration,
logger: logger
)
}
@@ -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)
}
}
@@ -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
)
}
@@ -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)
}
}
}
}