From d6c178c8ca0d9849ca0029e4943fc711ff9e8c4e Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Fri, 16 Jan 2026 14:26:30 +0100 Subject: [PATCH 01/32] initial implementation of the new loghandler --- .../Logging/JSONLogHandler.swift | 106 ++++++++++++++ .../Logging/LoggingConfiguration.swift | 129 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift create mode 100644 Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift new file mode 100644 index 00000000..eef9f494 --- /dev/null +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +import Logging + +@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 + private let encoder: JSONEncoder + + public init(label: String, requestID: String, traceID: String) { + self.label = label + self.logLevel = .info + self.requestID = requestID + self.traceID = traceID + + // Configure encoder for consistent output + self.encoder = JSONEncoder() + // Use ISO8601 format with fractional seconds + self.encoder.dateEncodingStrategy = .iso8601 + self.encoder.outputFormatting = [] // Compact output (no pretty printing) + } + + 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, + metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } + ) + + // Encode and emit JSON to stdout + if let jsonData = try? encoder.encode(logEntry), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + } + + private 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" + } + } + + public subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + + // MARK: - Log Entry Structure + + private struct LogEntry: Codable { + let timestamp: Date + let level: String + let message: String + let requestId: String + let traceId: String + let metadata: [String: String]? + } +} diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift new file mode 100644 index 00000000..de76885a --- /dev/null +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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? + + public init(logger: Logger) { + // Read AWS_LAMBDA_LOG_FORMAT (default: Text) + self.format = LogFormat( + rawValue: Lambda.env("AWS_LAMBDA_LOG_FORMAT") ?? "Text" + ) ?? .text + + // Determine log level with proper precedence + let awsLambdaLogLevel = Lambda.env("AWS_LAMBDA_LOG_LEVEL") + let logLevel = Lambda.env("LOG_LEVEL") + + switch (self.format, awsLambdaLogLevel, logLevel) { + case (.json, .some(let awsLevel), .some(let legacyLevel)): + // JSON format with both env vars set - use AWS_LAMBDA_LOG_LEVEL and warn + self.applicationLogLevel = Self.parseLogLevel(awsLevel) + logger.warning( + "Both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set. Using AWS_LAMBDA_LOG_LEVEL for JSON format.", + metadata: [ + "AWS_LAMBDA_LOG_LEVEL": .string(awsLevel), + "LOG_LEVEL": .string(legacyLevel) + ] + ) + + case (.json, .some(let awsLevel), .none): + // JSON format with AWS_LAMBDA_LOG_LEVEL only + self.applicationLogLevel = Self.parseLogLevel(awsLevel) + + case (.json, .none, .some(let legacyLevel)): + // JSON format with LOG_LEVEL only - use it but warn + self.applicationLogLevel = Self.parseLogLevel(legacyLevel) + logger.warning( + "Using LOG_LEVEL with JSON format. Consider using AWS_LAMBDA_LOG_LEVEL instead.", + metadata: ["LOG_LEVEL": .string(legacyLevel)] + ) + + case (.text, .some(let awsLevel), .some(let legacyLevel)): + // Text format with both - prefer LOG_LEVEL for backward compatibility + self.applicationLogLevel = Self.parseLogLevel(legacyLevel) + logger.debug( + "Both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set. Using LOG_LEVEL for Text format.", + metadata: [ + "AWS_LAMBDA_LOG_LEVEL": .string(awsLevel), + "LOG_LEVEL": .string(legacyLevel) + ] + ) + + case (.text, .some(let awsLevel), .none): + // Text format with AWS_LAMBDA_LOG_LEVEL only + self.applicationLogLevel = Self.parseLogLevel(awsLevel) + + case (.text, .none, .some(let legacyLevel)): + // Text format with LOG_LEVEL only - existing behavior + self.applicationLogLevel = Self.parseLogLevel(legacyLevel) + + case (_, .none, .none): + // No log level configured - use default + self.applicationLogLevel = 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 "WARN", "WARNING": return .warning + case "ERROR": return .error + case "FATAL", "CRITICAL": return .critical + default: return .info + } + } + + /// Create a logger for a specific invocation + public func makeLogger( + label: String, + requestID: String, + traceID: String + ) -> Logger { + switch self.format { + case .text: + // Use existing default logger + var logger = Logger(label: label) + 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 + } + } +} From 0b9298eb2f61f9f50b2a3ff35b36b75c9ce65236 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sun, 18 Jan 2026 21:10:17 +0100 Subject: [PATCH 02/32] add proposal doc --- .../Docs.docc/Proposals/0002-logging.md | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md 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 00000000..da011465 --- /dev/null +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -0,0 +1,355 @@ +# Structured JSON Logging Support for swift-aws-lambda-runtime + +AWS Lambda supports [advanced logging controls](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-logformat.html) 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 + +Versions: + +- 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", + "xrayTraceId": "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 +internal struct JSONLogHandler: LogHandler, Sendable { + public var logLevel: Logger.Level + public var metadata: Logger.Metadata + + internal init( + label: String, + level: Logger.Level, + requestID: String? = nil, + traceID: String? = nil + ) + + 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/Lambda.swift` + - Update run loop to create request-specific loggers + - Pass enhanced context to handlers + +3. `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 From d3611e5ef9844216a9df82ee5070d101f03e23d2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 20 Jan 2026 17:02:23 +0100 Subject: [PATCH 03/32] make the log handler public --- .../Docs.docc/Proposals/0002-logging.md | 10 +++++----- Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md index da011465..6c4953c5 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -161,15 +161,15 @@ Key features: A new `LogHandler` implementation for JSON format logging: ```swift -internal struct JSONLogHandler: LogHandler, Sendable { +public struct JSONLogHandler: LogHandler, Sendable { public var logLevel: Logger.Level public var metadata: Logger.Metadata - internal init( + public init( label: String, - level: Logger.Level, - requestID: String? = nil, - traceID: String? = nil + logLevel: Logger.Level = .info, + requestID: String, + traceID: String ) public func log( diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index eef9f494..40d1cf02 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -31,9 +31,9 @@ public struct JSONLogHandler: LogHandler { private let traceID: String private let encoder: JSONEncoder - public init(label: String, requestID: String, traceID: String) { + public init(label: String, logLevel: Logger.Level = .info, requestID: String, traceID: String) { self.label = label - self.logLevel = .info + self.logLevel = logLevel self.requestID = requestID self.traceID = traceID From 9f693c3fc745d521150d1543754b96148a777f27 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 20 Jan 2026 17:43:08 +0100 Subject: [PATCH 04/32] change version --- Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md index 6c4953c5..f3075b3b 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -6,6 +6,7 @@ AWS Lambda supports [advanced logging controls](https://docs.aws.amazon.com/lamb Versions: +- v2 (2025-01-20): Make `LogHandler` public - v1 (2025-01-18): Initial version ### Motivation From be0430dcdeb5c51c3fef39c4666f84a49f4b9966 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 15:53:21 +0100 Subject: [PATCH 05/32] Update design to factor in Lambda Managed Instances --- .../AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md index f3075b3b..87092c32 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -6,6 +6,7 @@ AWS Lambda supports [advanced logging controls](https://docs.aws.amazon.com/lamb Versions: +- v3 (2025-02-12): Add `LambdaManagedRuntime` in teh list of struct to modify - v2 (2025-01-20): Make `LogHandler` public - v1 (2025-01-18): Initial version @@ -302,11 +303,15 @@ This ensures consistent JSON formatting and log level control across all runtime - Add `LoggingConfiguration` parameter to initializers - Integrate per-request logger creation -2. `Sources/AWSLambdaRuntime/Lambda.swift` +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 -3. `Sources/AWSLambdaRuntime/LambdaContext.swift` +4. `Sources/AWSLambdaRuntime/LambdaContext.swift` - Ensure logger property uses request-specific instance ### Migration Considerations From 4b086e6988b68d7d5eba92e1a8f92eb9e91ab221 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 16:36:07 +0100 Subject: [PATCH 06/32] initial implementation --- Examples/JSONLogging/Package.swift | 27 +++ Examples/JSONLogging/README.md | 214 ++++++++++++++++++ Examples/JSONLogging/Sources/main.swift | 65 ++++++ Sources/AWSLambdaRuntime/Lambda.swift | 21 +- .../Logging/LoggingConfiguration.swift | 10 +- .../ManagedRuntime/LambdaManagedRuntime.swift | 21 +- .../Runtime/LambdaRuntime.swift | 23 +- .../LambdaRunLoopTests.swift | 20 +- 8 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 Examples/JSONLogging/Package.swift create mode 100644 Examples/JSONLogging/README.md create mode 100644 Examples/JSONLogging/Sources/main.swift diff --git a/Examples/JSONLogging/Package.swift b/Examples/JSONLogging/Package.swift new file mode 100644 index 00000000..a7911c5a --- /dev/null +++ b/Examples/JSONLogging/Package.swift @@ -0,0 +1,27 @@ +// 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) + .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 00000000..22dcf737 --- /dev/null +++ b/Examples/JSONLogging/README.md @@ -0,0 +1,214 @@ +# 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.al2 + Architectures: + - arm64 + LoggingConfig: + LogFormat: JSON + ApplicationLogLevel: INFO # TRACE | DEBUG | INFO | WARN | ERROR | FATAL + SystemLogLevel: INFO # DEBUG | INFO | WARN +``` + +## Test Locally + +Start the local server: + +```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 +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.al2 + MemorySize: 128 + Architectures: + - arm64 + LoggingConfig: + LogFormat: JSON + ApplicationLogLevel: DEBUG + SystemLogLevel: INFO + +Outputs: + FunctionName: + Description: Lambda Function Name + Value: !Ref JSONLoggingFunction +``` + +Deploy: + +```bash +sam build +sam deploy --guided +``` + +## Deploy with AWS CLI + +```bash +aws lambda create-function \ + --function-name JSONLoggingExample \ + --zip-file fileb://.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip \ + --runtime provided.al2 \ + --handler swift.bootstrap \ + --architectures arm64 \ + --role arn:aws:iam:::role/lambda_basic_execution \ + --logging-config LogFormat=JSON,ApplicationLogLevel=DEBUG,SystemLogLevel=INFO +``` + +## Invoke + +```bash +aws lambda invoke \ + --function-name JSONLoggingExample \ + --payload '{"name":"Alice","level":"debug"}' \ + response.json && cat response.json +``` + +## Query Logs with CloudWatch Logs Insights + +With JSON formatted logs, you can use powerful queries: + +``` +# 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" +``` + +## 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 00000000..75e8c487 --- /dev/null +++ b/Examples/JSONLogging/Sources/main.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// 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/Sources/AWSLambdaRuntime/Lambda.swift b/Sources/AWSLambdaRuntime/Lambda.swift index 1c499774..13417a3c 100644 --- a/Sources/AWSLambdaRuntime/Lambda.swift +++ b/Sources/AWSLambdaRuntime/Lambda.swift @@ -36,23 +36,29 @@ public enum Lambda { 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 +67,7 @@ public enum Lambda { ) ] } - logger.trace( + requestLogger.trace( "Sending invocation event to lambda handler", metadata: metadata ) @@ -78,16 +84,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/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift index de76885a..87e66e49 100644 --- a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -24,6 +24,7 @@ public struct LoggingConfiguration: Sendable { public let format: LogFormat public let applicationLogLevel: Logger.Level? + private let baseLogger: Logger public init(logger: Logger) { // Read AWS_LAMBDA_LOG_FORMAT (default: Text) @@ -31,6 +32,9 @@ public struct LoggingConfiguration: Sendable { rawValue: Lambda.env("AWS_LAMBDA_LOG_FORMAT") ?? "Text" ) ?? .text + // Store the base logger for cloning + self.baseLogger = logger + // Determine log level with proper precedence let awsLambdaLogLevel = Lambda.env("AWS_LAMBDA_LOG_LEVEL") let logLevel = Lambda.env("LOG_LEVEL") @@ -104,8 +108,10 @@ public struct LoggingConfiguration: Sendable { ) -> Logger { switch self.format { case .text: - // Use existing default logger - var logger = Logger(label: label) + // 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 } diff --git a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift index 2b163865..012f49bd 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,24 @@ 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 + // Apply log level from configuration if available + if let level = self.loggingConfiguration.applicationLogLevel { + log.logLevel = level + } 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 +98,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 +115,7 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream endpoint: runtimeEndpoint, handler: self.handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: logger ) } @@ -119,6 +131,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 72a6aca6..2c82214b 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,24 @@ 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 + // Apply log level from configuration if available + if let level = self.loggingConfiguration.applicationLogLevel { + log.logLevel = level + } 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 +107,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb endpoint: runtimeEndpoint, handler: handler, eventLoop: self.eventLoop, + loggingConfiguration: self.loggingConfiguration, logger: self.logger ) @@ -107,6 +117,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 +128,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb endpoint: String, handler: Handler, eventLoop: EventLoop, + loggingConfiguration: LoggingConfiguration, logger: Logger ) async throws { @@ -133,6 +145,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb try await Lambda.runLoop( runtimeClient: runtimeClient, handler: handler, + loggingConfiguration: loggingConfiguration, logger: logger ) } @@ -155,6 +168,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 +195,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/LambdaRunLoopTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaRunLoopTests.swift index c508fb7a..d2484f2a 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 ) } From f1661fbf0cd4600cddd9e2be080c6707d7d1c6ce Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 16:36:36 +0100 Subject: [PATCH 07/32] swift-format --- Examples/JSONLogging/Sources/main.swift | 21 +++++----- Sources/AWSLambdaRuntime/Lambda.swift | 2 +- .../Logging/JSONLogHandler.swift | 29 +++++++------- .../Logging/LoggingConfiguration.swift | 39 ++++++++++--------- .../ManagedRuntime/LambdaManagedRuntime.swift | 11 ++++-- .../Runtime/LambdaRuntime.swift | 11 ++++-- 6 files changed, 62 insertions(+), 51 deletions(-) diff --git a/Examples/JSONLogging/Sources/main.swift b/Examples/JSONLogging/Sources/main.swift index 75e8c487..14dafc2b 100644 --- a/Examples/JSONLogging/Sources/main.swift +++ b/Examples/JSONLogging/Sources/main.swift @@ -36,26 +36,29 @@ struct Response: Encodable { 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") - ]) + 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() diff --git a/Sources/AWSLambdaRuntime/Lambda.swift b/Sources/AWSLambdaRuntime/Lambda.swift index 13417a3c..0a9872ee 100644 --- a/Sources/AWSLambdaRuntime/Lambda.swift +++ b/Sources/AWSLambdaRuntime/Lambda.swift @@ -46,7 +46,7 @@ public enum Lambda { logger.trace("Waiting for next invocation") let (invocation, writer) = try await runtimeClient.nextInvocation() - + // Create a per-request logger with request-specific metadata let requestLogger = loggingConfiguration.makeLogger( label: "Lambda", diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 40d1cf02..020150db 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -13,37 +13,37 @@ // //===----------------------------------------------------------------------===// +import Logging + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Logging - @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 private let encoder: JSONEncoder - + public init(label: String, logLevel: Logger.Level = .info, requestID: String, traceID: String) { self.label = label self.logLevel = logLevel self.requestID = requestID self.traceID = traceID - + // Configure encoder for consistent output self.encoder = JSONEncoder() // Use ISO8601 format with fractional seconds self.encoder.dateEncodingStrategy = .iso8601 - self.encoder.outputFormatting = [] // Compact output (no pretty printing) + self.encoder.outputFormatting = [] // Compact output (no pretty printing) } - + public func log( level: Logger.Level, message: Logger.Message, @@ -58,7 +58,7 @@ public struct JSONLogHandler: LogHandler { if let metadata = metadata { allMetadata.merge(metadata) { _, new in new } } - + // Create log entry struct let logEntry = LogEntry( timestamp: Date(), @@ -68,14 +68,15 @@ public struct JSONLogHandler: LogHandler { traceId: self.traceID, metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } ) - + // Encode and emit JSON to stdout if let jsonData = try? encoder.encode(logEntry), - let jsonString = String(data: jsonData, encoding: .utf8) { + let jsonString = String(data: jsonData, encoding: .utf8) + { print(jsonString) } } - + private static func mapLogLevel(_ level: Logger.Level) -> String { switch level { case .trace: return "TRACE" @@ -87,14 +88,14 @@ public struct JSONLogHandler: LogHandler { case .critical: return "FATAL" } } - + public subscript(metadataKey key: String) -> Logger.Metadata.Value? { get { metadata[key] } set { metadata[key] = newValue } } - + // MARK: - Log Entry Structure - + private struct LogEntry: Codable { let timestamp: Date let level: String diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift index 87e66e49..4b9535e1 100644 --- a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -21,24 +21,25 @@ public struct LoggingConfiguration: Sendable { case text = "Text" case json = "JSON" } - + public let format: LogFormat public let applicationLogLevel: Logger.Level? private let baseLogger: Logger - + public init(logger: Logger) { // Read AWS_LAMBDA_LOG_FORMAT (default: Text) - self.format = LogFormat( - rawValue: Lambda.env("AWS_LAMBDA_LOG_FORMAT") ?? "Text" - ) ?? .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 let awsLambdaLogLevel = Lambda.env("AWS_LAMBDA_LOG_LEVEL") let logLevel = Lambda.env("LOG_LEVEL") - + switch (self.format, awsLambdaLogLevel, logLevel) { case (.json, .some(let awsLevel), .some(let legacyLevel)): // JSON format with both env vars set - use AWS_LAMBDA_LOG_LEVEL and warn @@ -47,14 +48,14 @@ public struct LoggingConfiguration: Sendable { "Both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set. Using AWS_LAMBDA_LOG_LEVEL for JSON format.", metadata: [ "AWS_LAMBDA_LOG_LEVEL": .string(awsLevel), - "LOG_LEVEL": .string(legacyLevel) + "LOG_LEVEL": .string(legacyLevel), ] ) - + case (.json, .some(let awsLevel), .none): // JSON format with AWS_LAMBDA_LOG_LEVEL only self.applicationLogLevel = Self.parseLogLevel(awsLevel) - + case (.json, .none, .some(let legacyLevel)): // JSON format with LOG_LEVEL only - use it but warn self.applicationLogLevel = Self.parseLogLevel(legacyLevel) @@ -62,7 +63,7 @@ public struct LoggingConfiguration: Sendable { "Using LOG_LEVEL with JSON format. Consider using AWS_LAMBDA_LOG_LEVEL instead.", metadata: ["LOG_LEVEL": .string(legacyLevel)] ) - + case (.text, .some(let awsLevel), .some(let legacyLevel)): // Text format with both - prefer LOG_LEVEL for backward compatibility self.applicationLogLevel = Self.parseLogLevel(legacyLevel) @@ -70,24 +71,24 @@ public struct LoggingConfiguration: Sendable { "Both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set. Using LOG_LEVEL for Text format.", metadata: [ "AWS_LAMBDA_LOG_LEVEL": .string(awsLevel), - "LOG_LEVEL": .string(legacyLevel) + "LOG_LEVEL": .string(legacyLevel), ] ) - + case (.text, .some(let awsLevel), .none): // Text format with AWS_LAMBDA_LOG_LEVEL only self.applicationLogLevel = Self.parseLogLevel(awsLevel) - + case (.text, .none, .some(let legacyLevel)): // Text format with LOG_LEVEL only - existing behavior self.applicationLogLevel = Self.parseLogLevel(legacyLevel) - + case (_, .none, .none): // No log level configured - use default self.applicationLogLevel = nil } } - + private static func parseLogLevel(_ level: String) -> Logger.Level { switch level.uppercased() { case "TRACE": return .trace @@ -99,7 +100,7 @@ public struct LoggingConfiguration: Sendable { default: return .info } } - + /// Create a logger for a specific invocation public func makeLogger( label: String, @@ -116,7 +117,7 @@ public struct LoggingConfiguration: Sendable { logger.logLevel = level } return logger - + case .json: // Use JSON log handler var logger = Logger(label: label) { label in diff --git a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift index 012f49bd..c1305501 100644 --- a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift +++ b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift @@ -56,10 +56,13 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream } self.logger = log - self.logger.debug("LambdaManagedRuntime initialized", metadata: [ - "logFormat": "\(self.loggingConfiguration.format)", - "logLevel": "\(log.logLevel)" - ]) + self.logger.debug( + "LambdaManagedRuntime initialized", + metadata: [ + "logFormat": "\(self.loggingConfiguration.format)", + "logLevel": "\(log.logLevel)", + ] + ) } #if !ServiceLifecycleSupport diff --git a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift index 2c82214b..89f60807 100644 --- a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift +++ b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift @@ -72,10 +72,13 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb } self.logger = log - self.logger.debug("LambdaRuntime initialized", metadata: [ - "logFormat": "\(self.loggingConfiguration.format)", - "logLevel": "\(log.logLevel)" - ]) + self.logger.debug( + "LambdaRuntime initialized", + metadata: [ + "logFormat": "\(self.loggingConfiguration.format)", + "logLevel": "\(log.logLevel)", + ] + ) } #if !ServiceLifecycleSupport From 3643ffcc4de7b302f23229059b4ba8b24866046f Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 18:39:13 +0100 Subject: [PATCH 08/32] fix the example --- Examples/JSONLogging/.gitignore | 1 + Examples/JSONLogging/Package.swift | 2 ++ Examples/JSONLogging/README.md | 13 +++++++------ Examples/JSONLogging/template.yaml | 24 ++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 Examples/JSONLogging/.gitignore create mode 100644 Examples/JSONLogging/template.yaml diff --git a/Examples/JSONLogging/.gitignore b/Examples/JSONLogging/.gitignore new file mode 100644 index 00000000..62fdfc74 --- /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 index a7911c5a..07786ee9 100644 --- a/Examples/JSONLogging/Package.swift +++ b/Examples/JSONLogging/Package.swift @@ -10,6 +10,8 @@ let package = Package( ], 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: diff --git a/Examples/JSONLogging/README.md b/Examples/JSONLogging/README.md index 22dcf737..189fbf51 100644 --- a/Examples/JSONLogging/README.md +++ b/Examples/JSONLogging/README.md @@ -55,7 +55,7 @@ Resources: ## Test Locally -Start the local server: +Start the local server with TEXT logging: ```bash swift run @@ -84,7 +84,7 @@ AWS_LAMBDA_LOG_FORMAT=JSON swift run ```bash swift build -swift package archive --allow-network-connections docker +LAMBDA_USE_LOCAL_DEPS=../.. swift package archive --allow-network-connections docker ``` The deployment package will be at: @@ -124,20 +124,20 @@ Outputs: Deploy: ```bash -sam build sam deploy --guided ``` ## Deploy with 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.al2 \ + --runtime provided.al2023 \ --handler swift.bootstrap \ --architectures arm64 \ - --role arn:aws:iam:::role/lambda_basic_execution \ + --role arn:aws:iam::${ACCOUNT_ID}:role/lambda_basic_execution \ --logging-config LogFormat=JSON,ApplicationLogLevel=DEBUG,SystemLogLevel=INFO ``` @@ -146,8 +146,9 @@ aws lambda create-function \ ```bash aws lambda invoke \ --function-name JSONLoggingExample \ + --cli-binary-format raw-in-base64-out \ --payload '{"name":"Alice","level":"debug"}' \ - response.json && cat response.json + response.json && cat response.json && rm response.json ``` ## Query Logs with CloudWatch Logs Insights diff --git a/Examples/JSONLogging/template.yaml b/Examples/JSONLogging/template.yaml new file mode 100644 index 00000000..bf948a11 --- /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 \ No newline at end of file From 653b46899d1929f889c9fe4d4173de02ebd37fd5 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 18:39:32 +0100 Subject: [PATCH 09/32] use fwrite() and fflush() instead of print() --- .../Logging/JSONLogHandler.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 020150db..125fc4c0 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -15,6 +15,14 @@ import Logging +#if canImport(Darwin) +import Darwin.C +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -69,11 +77,20 @@ public struct JSONLogHandler: LogHandler { metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } ) - // Encode and emit JSON to stdout + // Encode and emit JSON to stderr (matching StreamLogHandler behavior). + // We use fwrite + fflush rather than print() because Swift's print() + // writes to stdout which may be fully buffered on Lambda (no TTY), + // causing log lines to never be flushed before the invocation completes. if let jsonData = try? encoder.encode(logEntry), - let jsonString = String(data: jsonData, encoding: .utf8) + var jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) + jsonString.append("\n") + jsonString.withCString { ptr in + flockfile(stderr) + defer { funlockfile(stderr) } + _ = fwrite(ptr, 1, strlen(ptr), stderr) + fflush(stderr) + } } } From 239580400596ac295fd96620a9fa0723d09ccefe Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 18:42:53 +0100 Subject: [PATCH 10/32] add ref to swift log's StreamLogHandler --- Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 125fc4c0..58d26dae 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -81,6 +81,7 @@ public struct JSONLogHandler: LogHandler { // We use fwrite + fflush rather than print() because Swift's print() // writes to stdout which may be fully buffered on Lambda (no TTY), // causing log lines to never be flushed before the invocation completes. + // See: https://github.com/apple/swift-log/blob/main/Sources/Logging/Logging.swift#L1404-L1432 if let jsonData = try? encoder.encode(logEntry), var jsonString = String(data: jsonData, encoding: .utf8) { From 1c1ea5ddfd903a729c322222cd10c553bd4c95f4 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 18:56:47 +0100 Subject: [PATCH 11/32] Fix the buffering issue on Lambda --- .../Logging/JSONLogHandler.swift | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 58d26dae..96c100cc 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -47,7 +47,6 @@ public struct JSONLogHandler: LogHandler { // Configure encoder for consistent output self.encoder = JSONEncoder() - // Use ISO8601 format with fractional seconds self.encoder.dateEncodingStrategy = .iso8601 self.encoder.outputFormatting = [] // Compact output (no pretty printing) } @@ -77,20 +76,22 @@ public struct JSONLogHandler: LogHandler { metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } ) - // Encode and emit JSON to stderr (matching StreamLogHandler behavior). - // We use fwrite + fflush rather than print() because Swift's print() - // writes to stdout which may be fully buffered on Lambda (no TTY), + // 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. - // See: https://github.com/apple/swift-log/blob/main/Sources/Logging/Logging.swift#L1404-L1432 - if let jsonData = try? encoder.encode(logEntry), - var jsonString = String(data: jsonData, encoding: .utf8) - { - jsonString.append("\n") - jsonString.withCString { ptr in - flockfile(stderr) - defer { funlockfile(stderr) } - _ = fwrite(ptr, 1, strlen(ptr), stderr) - fflush(stderr) + // POSIX write() on fd 2 is unbuffered and avoids referencing the global + // `stderr` C pointer which is not concurrency-safe on Linux/Swift 6. + if let jsonData = try? encoder.encode(logEntry) { + var output = jsonData + output.append(contentsOf: "\n".utf8) + output.withUnsafeBytes { buffer in + #if canImport(Darwin) + _ = Darwin.write(2, buffer.baseAddress!, buffer.count) + #elseif canImport(Glibc) + _ = Glibc.write(2, buffer.baseAddress!, buffer.count) + #elseif canImport(Musl) + _ = Musl.write(2, buffer.baseAddress!, buffer.count) + #endif } } } From e153f4aa748489d9884a88a6c7c11520f822d54b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 19:02:36 +0100 Subject: [PATCH 12/32] create a runtime level logger for logging before invocations --- .../Logging/LoggingConfiguration.swift | 27 +++++++++++++++++++ .../Runtime/LambdaRuntime.swift | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift index 4b9535e1..92290275 100644 --- a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -133,4 +133,31 @@ public struct LoggingConfiguration: Sendable { 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 { + switch self.format { + case .text: + var logger = self.baseLogger + if let level = self.applicationLogLevel { + logger.logLevel = level + } + return logger + + case .json: + var 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 + } + return logger + } + } } diff --git a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift index 89f60807..6132eb8d 100644 --- a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift +++ b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift @@ -64,7 +64,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb // 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 + var log = self.loggingConfiguration.makeRuntimeLogger() // Apply log level from configuration if available if let level = self.loggingConfiguration.applicationLogLevel { From 7bbd5f3319a555dfaf5977a6c81a0f350b210630 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 19:12:08 +0100 Subject: [PATCH 13/32] fix YAML lint --- Examples/JSONLogging/template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/JSONLogging/template.yaml b/Examples/JSONLogging/template.yaml index bf948a11..67bd9fb5 100644 --- a/Examples/JSONLogging/template.yaml +++ b/Examples/JSONLogging/template.yaml @@ -21,4 +21,4 @@ Resources: Outputs: FunctionName: Description: Lambda Function Name - Value: !Ref JSONLoggingFunction \ No newline at end of file + Value: !Ref JSONLoggingFunction From b8cefe06f79a9d8077d3dd20266df4489830d2ab Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 19:56:03 +0100 Subject: [PATCH 14/32] fix docc errors and warnings --- Sources/AWSLambdaRuntime/Docs.docc/Deployment.md | 2 +- .../Docs.docc/managed-instances.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Deployment.md b/Sources/AWSLambdaRuntime/Docs.docc/Deployment.md index cadeee48..48d85cd5 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/managed-instances.md b/Sources/AWSLambdaRuntime/Docs.docc/managed-instances.md index d95c458f..7d56f094 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) From b221ff7051f718b469348d9eb2d821036f6a7a06 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 21:05:36 +0100 Subject: [PATCH 15/32] LambdaManagedRuntime.init() uses self.loggingConfiguration.makeRuntimeLogger() instead of the raw logger parameter --- .../AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift index c1305501..16617867 100644 --- a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift +++ b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift @@ -48,7 +48,7 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream // 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 + var log = self.loggingConfiguration.makeRuntimeLogger() // Apply log level from configuration if available if let level = self.loggingConfiguration.applicationLogLevel { From 51ea9d02ca9e123a82535a683bf1a7c94ca90398 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 21:08:02 +0100 Subject: [PATCH 16/32] fix docc errors --- Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md index 87092c32..17bc70a2 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -1,9 +1,11 @@ # Structured JSON Logging Support for swift-aws-lambda-runtime -AWS Lambda supports [advanced logging controls](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-logformat.html) 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. +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 teh list of struct to modify From b6c6955b69b2b0c183fb4ac04102a086d9e611a3 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Thu, 12 Feb 2026 21:16:43 +0100 Subject: [PATCH 17/32] deprcate package-level API to make API checker happy --- Sources/AWSLambdaRuntime/Lambda.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Sources/AWSLambdaRuntime/Lambda.swift b/Sources/AWSLambdaRuntime/Lambda.swift index 0a9872ee..c4bcf39f 100644 --- a/Sources/AWSLambdaRuntime/Lambda.swift +++ b/Sources/AWSLambdaRuntime/Lambda.swift @@ -32,6 +32,26 @@ 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, From 3e9655bb87fa11f3b7db20260af5dfc74b007d31 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 12:50:22 +0100 Subject: [PATCH 18/32] tiny change on the example README --- Examples/JSONLogging/README.md | 56 +++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/Examples/JSONLogging/README.md b/Examples/JSONLogging/README.md index 189fbf51..b0e95c9c 100644 --- a/Examples/JSONLogging/README.md +++ b/Examples/JSONLogging/README.md @@ -129,6 +129,8 @@ 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 \ @@ -153,7 +155,15 @@ aws lambda invoke \ ## Query Logs with CloudWatch Logs Insights -With JSON formatted logs, you can use powerful queries: +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 @@ -174,6 +184,50 @@ 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: From 26fab1a9670060a1035d5c61539a7c76e1bd2db8 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:23:33 +0100 Subject: [PATCH 19/32] consistent use of traceId --- Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md index 17bc70a2..902acb54 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -83,7 +83,7 @@ When JSON format is enabled, application logs will follow this structure: "level": "INFO", "message": "User authentication successful", "requestId": "8286a188-ba32-4475-8077-530cd35c09a9", - "xrayTraceId": "1-5e1b4151-43a0913a12345678901234567" + "traceId": "1-5e1b4151-43a0913a12345678901234567" } ``` From 27f9ba10e8b616f3a1f78ef8dd23f8619fbc7379 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:26:54 +0100 Subject: [PATCH 20/32] remove duplicate setting of the log level --- .../ManagedRuntime/LambdaManagedRuntime.swift | 8 +------- Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift index 16617867..0ad06cba 100644 --- a/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift +++ b/Sources/AWSLambdaRuntime/ManagedRuntime/LambdaManagedRuntime.swift @@ -48,13 +48,7 @@ public final class LambdaManagedRuntime: Sendable where Handler: Stream // 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 = self.loggingConfiguration.makeRuntimeLogger() - - // Apply log level from configuration if available - if let level = self.loggingConfiguration.applicationLogLevel { - log.logLevel = level - } - + let log = self.loggingConfiguration.makeRuntimeLogger() self.logger = log self.logger.debug( "LambdaManagedRuntime initialized", diff --git a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift index 6132eb8d..953ec98e 100644 --- a/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift +++ b/Sources/AWSLambdaRuntime/Runtime/LambdaRuntime.swift @@ -64,13 +64,7 @@ public final class LambdaRuntime: Sendable where Handler: StreamingLamb // 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 = self.loggingConfiguration.makeRuntimeLogger() - - // Apply log level from configuration if available - if let level = self.loggingConfiguration.applicationLogLevel { - log.logLevel = level - } - + let log = self.loggingConfiguration.makeRuntimeLogger() self.logger = log self.logger.debug( "LambdaRuntime initialized", From d76c53aebc05bb0bfcf496bfb2ca5c738957aa9b Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:30:55 +0100 Subject: [PATCH 21/32] remove logging before logger is initialized --- .../Logging/LoggingConfiguration.swift | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift index 92290275..635c64a6 100644 --- a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -26,6 +26,12 @@ public struct LoggingConfiguration: Sendable { public let applicationLogLevel: Logger.Level? 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 = @@ -37,52 +43,29 @@ public struct LoggingConfiguration: Sendable { 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") switch (self.format, awsLambdaLogLevel, logLevel) { - case (.json, .some(let awsLevel), .some(let legacyLevel)): - // JSON format with both env vars set - use AWS_LAMBDA_LOG_LEVEL and warn - self.applicationLogLevel = Self.parseLogLevel(awsLevel) - logger.warning( - "Both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set. Using AWS_LAMBDA_LOG_LEVEL for JSON format.", - metadata: [ - "AWS_LAMBDA_LOG_LEVEL": .string(awsLevel), - "LOG_LEVEL": .string(legacyLevel), - ] - ) - - case (.json, .some(let awsLevel), .none): - // JSON format with AWS_LAMBDA_LOG_LEVEL only + case (.json, .some(let awsLevel), _): + // JSON format: prefer AWS_LAMBDA_LOG_LEVEL self.applicationLogLevel = Self.parseLogLevel(awsLevel) case (.json, .none, .some(let legacyLevel)): - // JSON format with LOG_LEVEL only - use it but warn + // JSON format with LOG_LEVEL only - use it as fallback self.applicationLogLevel = Self.parseLogLevel(legacyLevel) - logger.warning( - "Using LOG_LEVEL with JSON format. Consider using AWS_LAMBDA_LOG_LEVEL instead.", - metadata: ["LOG_LEVEL": .string(legacyLevel)] - ) - case (.text, .some(let awsLevel), .some(let legacyLevel)): - // Text format with both - prefer LOG_LEVEL for backward compatibility + case (.text, _, .some(let legacyLevel)): + // Text format: prefer LOG_LEVEL for backward compatibility self.applicationLogLevel = Self.parseLogLevel(legacyLevel) - logger.debug( - "Both AWS_LAMBDA_LOG_LEVEL and LOG_LEVEL are set. Using LOG_LEVEL for Text format.", - metadata: [ - "AWS_LAMBDA_LOG_LEVEL": .string(awsLevel), - "LOG_LEVEL": .string(legacyLevel), - ] - ) case (.text, .some(let awsLevel), .none): // Text format with AWS_LAMBDA_LOG_LEVEL only self.applicationLogLevel = Self.parseLogLevel(awsLevel) - case (.text, .none, .some(let legacyLevel)): - // Text format with LOG_LEVEL only - existing behavior - self.applicationLogLevel = Self.parseLogLevel(legacyLevel) - case (_, .none, .none): // No log level configured - use default self.applicationLogLevel = nil From d847a5b00398e37b994fe09041eb9caa5665a5a2 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:34:54 +0100 Subject: [PATCH 22/32] create a JSONEncoder for each log() for thread-safety --- .../AWSLambdaRuntime/Logging/JSONLogHandler.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 96c100cc..ff37ccc1 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -37,18 +37,12 @@ public struct JSONLogHandler: LogHandler { private let label: String private let requestID: String private let traceID: String - private let encoder: JSONEncoder public init(label: String, logLevel: Logger.Level = .info, requestID: String, traceID: String) { self.label = label self.logLevel = logLevel self.requestID = requestID self.traceID = traceID - - // Configure encoder for consistent output - self.encoder = JSONEncoder() - self.encoder.dateEncodingStrategy = .iso8601 - self.encoder.outputFormatting = [] // Compact output (no pretty printing) } public func log( @@ -81,6 +75,15 @@ public struct JSONLogHandler: LogHandler { // 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. + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [] // Compact output (no pretty printing) if let jsonData = try? encoder.encode(logEntry) { var output = jsonData output.append(contentsOf: "\n".utf8) From 4036379d4b84e92b523a59a1f9790c9e25d251e0 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:36:39 +0100 Subject: [PATCH 23/32] consistently uses amazon linux 2023 --- Examples/JSONLogging/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Examples/JSONLogging/README.md b/Examples/JSONLogging/README.md index b0e95c9c..9d40cf03 100644 --- a/Examples/JSONLogging/README.md +++ b/Examples/JSONLogging/README.md @@ -44,7 +44,7 @@ Resources: Properties: CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip Handler: swift.bootstrap - Runtime: provided.al2 + Runtime: provided.al2023 Architectures: - arm64 LoggingConfig: @@ -106,8 +106,7 @@ Resources: CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/JSONLogging/JSONLogging.zip Timeout: 60 Handler: swift.bootstrap - Runtime: provided.al2 - MemorySize: 128 + Runtime: provided.al2023 Architectures: - arm64 LoggingConfig: From 4a0218af9db836f575eeae62848ae4d35ddf7cec Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:47:47 +0100 Subject: [PATCH 24/32] add unit tests for the LogHandler and LoggingConfiguration classes --- .../Logging/JSONLogHandler.swift | 41 ++- .../JSONLogHandlerTests.swift | 217 +++++++++++++ .../LoggingConfigurationTests.swift | 305 ++++++++++++++++++ 3 files changed, 546 insertions(+), 17 deletions(-) create mode 100644 Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift create mode 100644 Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index ff37ccc1..70c8e852 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -81,10 +81,7 @@ public struct JSONLogHandler: LogHandler { // 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. - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [] // Compact output (no pretty printing) - if let jsonData = try? encoder.encode(logEntry) { + if let jsonData = Self.encodeLogEntry(logEntry) { var output = jsonData output.append(contentsOf: "\n".utf8) output.withUnsafeBytes { buffer in @@ -99,18 +96,6 @@ public struct JSONLogHandler: LogHandler { } } - private 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" - } - } - public subscript(metadataKey key: String) -> Logger.Metadata.Value? { get { metadata[key] } set { metadata[key] = newValue } @@ -118,7 +103,7 @@ public struct JSONLogHandler: LogHandler { // MARK: - Log Entry Structure - private struct LogEntry: Codable { + struct LogEntry: Codable { let timestamp: Date let level: String let message: String @@ -126,4 +111,26 @@ public struct JSONLogHandler: LogHandler { let traceId: String 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 = .iso8601 + 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/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift new file mode 100644 index 00000000..46d83ff4 --- /dev/null +++ b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift @@ -0,0 +1,217 @@ +//===----------------------------------------------------------------------===// +// +// 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(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 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", + 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, + 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: - 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 (e.g. "2024-01-16T10:30:45Z") + let iso8601Pattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\\d+)?Z$"# + let matches = timestamp?.range(of: iso8601Pattern, options: .regularExpression) != nil + #expect(matches, "Timestamp '\(timestamp ?? "")' should be in ISO 8601 format") + } + + // 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", + metadata: nil + ) + let data = JSONLogHandler.encodeLogEntry(logEntry) + #expect(data != nil) + #expect(data?.isEmpty == false) + } +} diff --git a/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift new file mode 100644 index 00000000..d82bab87 --- /dev/null +++ b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift @@ -0,0 +1,305 @@ +//===----------------------------------------------------------------------===// +// +// 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), + ("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 info") + @available(LambdaSwift 2.0, *) + func unknownLogLevelDefaultsToInfo() { + withCleanEnvironment { + withEnvironment(["AWS_LAMBDA_LOG_LEVEL": "UNKNOWN"]) { + let config = LoggingConfiguration(logger: Logger(label: "test")) + #expect(config.applicationLogLevel == .info) + } + } + } + + // 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) + } + } + } +} From 2ecdca495886d8a8bd2d87b2b21e9ed21ccdadc0 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:49:41 +0100 Subject: [PATCH 25/32] fix typo --- Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md index 902acb54..8d505075 100644 --- a/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md +++ b/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md @@ -8,7 +8,7 @@ For more details, see the [AWS Lambda advanced logging controls documentation](h Versions: -- v3 (2025-02-12): Add `LambdaManagedRuntime` in teh list of struct to modify +- 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 From b869fc35ab8319330e7b0b69ddec6dd8185b141f Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Sat, 14 Feb 2026 13:54:31 +0100 Subject: [PATCH 26/32] fix silent error ignoring --- .../Logging/JSONLogHandler.swift | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 70c8e852..7ed958ba 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -84,15 +84,15 @@ public struct JSONLogHandler: LogHandler { if let jsonData = Self.encodeLogEntry(logEntry) { var output = jsonData output.append(contentsOf: "\n".utf8) - output.withUnsafeBytes { buffer in - #if canImport(Darwin) - _ = Darwin.write(2, buffer.baseAddress!, buffer.count) - #elseif canImport(Glibc) - _ = Glibc.write(2, buffer.baseAddress!, buffer.count) - #elseif canImport(Musl) - _ = Musl.write(2, buffer.baseAddress!, buffer.count) - #endif - } + self.writeToStderr(output) + } 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) } } @@ -101,6 +101,21 @@ public struct JSONLogHandler: LogHandler { set { metadata[key] = newValue } } + /// Writes raw bytes to stderr (fd 2) using POSIX write(). + /// We avoid print() because Swift's stdout is fully buffered on Lambda (no TTY), + /// and we avoid the global `stderr` C pointer which is not concurrency-safe on Linux/Swift 6. + private func writeToStderr(_ data: Data) { + data.withUnsafeBytes { buffer in + #if canImport(Darwin) + _ = Darwin.write(2, buffer.baseAddress!, buffer.count) + #elseif canImport(Glibc) + _ = Glibc.write(2, buffer.baseAddress!, buffer.count) + #elseif canImport(Musl) + _ = Musl.write(2, buffer.baseAddress!, buffer.count) + #endif + } + } + // MARK: - Log Entry Structure struct LogEntry: Codable { From c89f117c2b3229dc24d260d03af5dcdf26e961fa Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 24 Feb 2026 19:06:29 +0100 Subject: [PATCH 27/32] Iterate on buffer to make sure large logging messages are correctly written entirely --- .../Logging/JSONLogHandler.swift | 54 +++++++++-- .../JSONLogHandlerTests.swift | 97 +++++++++++++++++++ 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index 7ed958ba..b15d4d12 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -84,7 +84,14 @@ public struct JSONLogHandler: LogHandler { if let jsonData = Self.encodeLogEntry(logEntry) { var output = jsonData output.append(contentsOf: "\n".utf8) - self.writeToStderr(output) + 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 @@ -102,20 +109,51 @@ public struct JSONLogHandler: LogHandler { } /// Writes raw bytes to stderr (fd 2) using POSIX write(). - /// We avoid print() because Swift's stdout is fully buffered on Lambda (no TTY), - /// and we avoid the global `stderr` C pointer which is not concurrency-safe on Linux/Swift 6. - private func writeToStderr(_ data: Data) { - data.withUnsafeBytes { buffer in + /// 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 { + self.writeAll(data) { pointer, count in #if canImport(Darwin) - _ = Darwin.write(2, buffer.baseAddress!, buffer.count) + Darwin.write(2, pointer, count) #elseif canImport(Glibc) - _ = Glibc.write(2, buffer.baseAddress!, buffer.count) + Glibc.write(2, pointer, count) #elseif canImport(Musl) - _ = Musl.write(2, buffer.baseAddress!, buffer.count) + 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 { diff --git a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift index 46d83ff4..70f1d19e 100644 --- a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift +++ b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift @@ -18,6 +18,14 @@ 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 @@ -214,4 +222,93 @@ struct JSONLogHandlerTests { #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) + } } From c19febc00b2dcbb6fd733438e5f1bb3768c703f0 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 24 Feb 2026 19:20:35 +0100 Subject: [PATCH 28/32] add millisecond precision --- Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift | 7 +++++-- Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index b15d4d12..a48a3189 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -16,7 +16,7 @@ import Logging #if canImport(Darwin) -import Darwin.C +import Darwin #elseif canImport(Glibc) import Glibc #elseif canImport(Musl) @@ -169,7 +169,10 @@ public struct JSONLogHandler: LogHandler { /// Returns nil if encoding fails. internal static func encodeLogEntry(_ logEntry: LogEntry) -> Data? { let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 + 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) } diff --git a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift index 70f1d19e..c9571311 100644 --- a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift +++ b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift @@ -183,10 +183,10 @@ struct JSONLogHandlerTests { let timestamp = entry?.timestamp #expect(timestamp != nil) - // Verify it matches ISO 8601 format (e.g. "2024-01-16T10:30:45Z") - let iso8601Pattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\\d+)?Z$"# + // 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") + #expect(matches, "Timestamp '\(timestamp ?? "")' should be in ISO 8601 format with fractional seconds") } // MARK: - Metadata subscript From 47feea9c33baa3fc6bec14c96efce76980a9cb14 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 24 Feb 2026 19:39:39 +0100 Subject: [PATCH 29/32] issue a warning when LOG_LEVEL or AWS_LAMBDA_LOG_LEVEL is unknown --- .../Logging/LoggingConfiguration.swift | 54 +++++++++---------- .../LoggingConfigurationTests.swift | 6 +-- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift index 635c64a6..91bf3f9a 100644 --- a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -24,6 +24,9 @@ public struct LoggingConfiguration: Sendable { 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 @@ -49,30 +52,26 @@ public struct LoggingConfiguration: Sendable { 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), _): - // JSON format: prefer AWS_LAMBDA_LOG_LEVEL - self.applicationLogLevel = Self.parseLogLevel(awsLevel) - + rawLevel = awsLevel case (.json, .none, .some(let legacyLevel)): - // JSON format with LOG_LEVEL only - use it as fallback - self.applicationLogLevel = Self.parseLogLevel(legacyLevel) - + rawLevel = legacyLevel case (.text, _, .some(let legacyLevel)): - // Text format: prefer LOG_LEVEL for backward compatibility - self.applicationLogLevel = Self.parseLogLevel(legacyLevel) - + rawLevel = legacyLevel case (.text, .some(let awsLevel), .none): - // Text format with AWS_LAMBDA_LOG_LEVEL only - self.applicationLogLevel = Self.parseLogLevel(awsLevel) - + rawLevel = awsLevel case (_, .none, .none): - // No log level configured - use default - self.applicationLogLevel = nil + 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 { + private static func parseLogLevel(_ level: String) -> Logger.Level? { switch level.uppercased() { case "TRACE": return .trace case "DEBUG": return .debug @@ -80,7 +79,7 @@ public struct LoggingConfiguration: Sendable { case "WARN", "WARNING": return .warning case "ERROR": return .error case "FATAL", "CRITICAL": return .critical - default: return .info + default: return nil } } @@ -121,26 +120,27 @@ public struct LoggingConfiguration: Sendable { /// 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: - var logger = self.baseLogger - if let level = self.applicationLogLevel { - logger.logLevel = level - } - return logger - + logger = self.baseLogger case .json: - var logger = Logger(label: self.baseLogger.label) { label in + 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 - } - return logger } + 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, WARN, ERROR, FATAL." + ) + } + return logger } } diff --git a/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift index d82bab87..5f302a17 100644 --- a/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift +++ b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift @@ -229,13 +229,13 @@ struct LoggingConfigurationTests { } } - @Test("Unknown log level string defaults to info") + @Test("Unknown log level string defaults to nil") @available(LambdaSwift 2.0, *) - func unknownLogLevelDefaultsToInfo() { + func unknownLogLevelDefaultsToNil() { withCleanEnvironment { withEnvironment(["AWS_LAMBDA_LOG_LEVEL": "UNKNOWN"]) { let config = LoggingConfiguration(logger: Logger(label: "test")) - #expect(config.applicationLogLevel == .info) + #expect(config.applicationLogLevel == nil) } } } From 184fc6213dd1fd2dd0574dcbed6daaa0eb4f5a90 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 24 Feb 2026 19:44:01 +0100 Subject: [PATCH 30/32] add function, line number and file in the log entry --- .../Logging/JSONLogHandler.swift | 6 ++++ .../JSONLogHandlerTests.swift | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index a48a3189..e98c40c7 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -67,6 +67,9 @@ public struct JSONLogHandler: LogHandler { message: message.description, requestId: self.requestID, traceId: self.traceID, + file: file, + function: function, + line: line, metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } ) @@ -162,6 +165,9 @@ public struct JSONLogHandler: LogHandler { let message: String let requestId: String let traceId: String + let file: String + let function: String + let line: UInt let metadata: [String: String]? } diff --git a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift index c9571311..c1cd5681 100644 --- a/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift +++ b/Tests/AWSLambdaRuntimeTests/JSONLogHandlerTests.swift @@ -44,6 +44,9 @@ struct JSONLogHandlerTests { let message: String let requestId: String let traceId: String + let file: String? + let function: String? + let line: UInt? let metadata: [String: String]? } @@ -54,6 +57,9 @@ struct JSONLogHandlerTests { 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?) { @@ -69,6 +75,9 @@ struct JSONLogHandlerTests { message: message, requestId: requestID, traceId: traceID, + file: file, + function: function, + line: line, metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description } ) @@ -174,6 +183,22 @@ struct JSONLogHandlerTests { #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") @@ -216,6 +241,9 @@ struct JSONLogHandlerTests { message: "test", requestId: "r", traceId: "t", + file: "Test.swift", + function: "test()", + line: 1, metadata: nil ) let data = JSONLogHandler.encodeLogEntry(logEntry) From 51b82e746a5a7de70b250b47477b34b91b410fa0 Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 24 Feb 2026 20:24:31 +0100 Subject: [PATCH 31/32] hold a lock on stderr while writing --- .../Logging/JSONLogHandler.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift index e98c40c7..d8bdfc38 100644 --- a/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift +++ b/Sources/AWSLambdaRuntime/Logging/JSONLogHandler.swift @@ -14,6 +14,7 @@ //===----------------------------------------------------------------------===// import Logging +import Synchronization #if canImport(Darwin) import Darwin @@ -29,6 +30,13 @@ import FoundationEssentials 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 @@ -112,19 +120,23 @@ public struct JSONLogHandler: LogHandler { } /// 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 { - 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 + _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 + } } } From 9a6694d4555d7745ac5403f98fbd6e99e72b6cdd Mon Sep 17 00:00:00 2001 From: Sebastien Stormacq Date: Tue, 24 Feb 2026 22:23:35 +0100 Subject: [PATCH 32/32] Add Notice level --- Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift | 3 ++- Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift index 91bf3f9a..dd842166 100644 --- a/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift +++ b/Sources/AWSLambdaRuntime/Logging/LoggingConfiguration.swift @@ -76,6 +76,7 @@ public struct LoggingConfiguration: Sendable { 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 @@ -138,7 +139,7 @@ public struct LoggingConfiguration: Sendable { } if let unrecognized = self.unrecognizedLogLevel { logger.warning( - "Unrecognized log level '\(unrecognized)'. Using default log level. Valid values: TRACE, DEBUG, INFO, WARN, ERROR, FATAL." + "Unrecognized log level '\(unrecognized)'. Using default log level. Valid values: TRACE, DEBUG, INFO, NOTICE, WARN, ERROR, FATAL." ) } return logger diff --git a/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift index 5f302a17..74bc891a 100644 --- a/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift +++ b/Tests/AWSLambdaRuntimeTests/LoggingConfigurationTests.swift @@ -213,6 +213,7 @@ struct LoggingConfigurationTests { ("TRACE", .trace), ("DEBUG", .debug), ("INFO", .info), + ("NOTICE", .notice), ("WARN", .warning), ("WARNING", .warning), ("ERROR", .error),