Files
Sébastien Stormacq 553b5e3716 Add support for JSON Structured Logging (#638)
This PR adds support for Structured Logging, as per [the design
document](https://github.com/awslabs/swift-aws-lambda-runtime/blob/feature/structured-json-logging/Sources/AWSLambdaRuntime/Docs.docc/Proposals/0002-logging.md)

---------

Co-authored-by: Sebastien Stormacq <stormacq@amazon.lu>
2026-02-25 08:36:49 +01:00

211 lines
7.8 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright SwiftAWSLambdaRuntime project authors
// Copyright (c) Amazon.com, Inc. or its affiliates.
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Logging
import Synchronization
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#endif
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
/// Serializes all stderr writes across JSONLogHandler instances so that
/// concurrent log calls (e.g. from multiple RICs on Lambda Managed Instances)
/// cannot interleave bytes mid-line. The lock is only held for the duration of
/// the POSIX write() syscall JSON encoding happens outside the lock.
@available(LambdaSwift 2.0, *)
private let _stderrLock = Mutex<Void>(())
@available(LambdaSwift 2.0, *)
public struct JSONLogHandler: LogHandler {
public var logLevel: Logger.Level
public var metadata: Logger.Metadata = [:]
private let label: String
private let requestID: String
private let traceID: String
public init(label: String, logLevel: Logger.Level = .info, requestID: String, traceID: String) {
self.label = label
self.logLevel = logLevel
self.requestID = requestID
self.traceID = traceID
}
public func log(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt
) {
// Merge metadata
var allMetadata = self.metadata
if let metadata = metadata {
allMetadata.merge(metadata) { _, new in new }
}
// Create log entry struct
let logEntry = LogEntry(
timestamp: Date(),
level: Self.mapLogLevel(level),
message: message.description,
requestId: self.requestID,
traceId: self.traceID,
file: file,
function: function,
line: line,
metadata: allMetadata.isEmpty ? nil : allMetadata.mapValues { $0.description }
)
// Encode to JSON and write to stderr using POSIX write() on fd 2.
// We avoid print() because Swift's stdout is fully buffered on Lambda (no TTY),
// causing log lines to never be flushed before the invocation completes.
// POSIX write() on fd 2 is unbuffered and avoids referencing the global
// `stderr` C pointer which is not concurrency-safe on Linux/Swift 6.
// We create a new encoder per call to avoid sharing a mutable reference type
// across concurrent log calls, since JSONEncoder is not thread-safe.
// JSONEncoder allocation is on the order of nanoseconds the JSON serialization
// and the write() syscall dominate the cost by orders of magnitude.
// If profiling ever shows this matters, consider manual JSON serialization
// which would also bypass the Codable overhead entirely.
if let jsonData = Self.encodeLogEntry(logEntry) {
var output = jsonData
output.append(contentsOf: "\n".utf8)
let bytesWritten = self.writeToStderr(output)
if bytesWritten != output.count {
let warning = Data(
"STDERR_WRITE_INCOMPLETE expected=\(output.count) written=\(bytesWritten) level=\(logEntry.level) message=\(logEntry.message)\n"
.utf8
)
self.writeToStderr(warning)
}
} else {
// JSON encoding failed emit a plain-text fallback to stderr so the log
// message is not silently lost. This should only happen if metadata contains
// values that cannot be encoded, which is unlikely with String-typed metadata.
let fallback = Data(
"JSON_ENCODE_ERROR level=\(logEntry.level) message=\(logEntry.message)\n".utf8
)
self.writeToStderr(fallback)
}
}
public subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { metadata[key] }
set { metadata[key] = newValue }
}
/// Writes raw bytes to stderr (fd 2) using POSIX write().
/// The write is serialized through `_stderrLock` so that concurrent log
/// calls from multiple tasks cannot interleave bytes within a single line.
/// Uses a loop to handle partial writes and EINTR retries, ensuring
/// large log lines are not silently truncated.
/// - Returns: The number of bytes successfully written.
@discardableResult
private func writeToStderr(_ data: Data) -> Int {
_stderrLock.withLock { _ in
self.writeAll(data) { pointer, count in
#if canImport(Darwin)
Darwin.write(2, pointer, count)
#elseif canImport(Glibc)
Glibc.write(2, pointer, count)
#elseif canImport(Musl)
Musl.write(2, pointer, count)
#endif
}
}
}
/// Write loop that handles partial writes and EINTR retries.
/// Accepts an injectable write function so tests can simulate partial writes.
/// - Parameters:
/// - data: The bytes to write.
/// - writeFn: A function matching the POSIX `write()` signature takes a pointer
/// and byte count, returns the number of bytes written or -1 on error.
/// - Returns: The total number of bytes successfully written.
internal func writeAll(
_ data: Data,
using writeFn: (_ pointer: UnsafeRawPointer, _ count: Int) -> Int
) -> Int {
data.withUnsafeBytes { buffer in
guard let baseAddress = buffer.baseAddress else { return 0 }
var remaining = buffer.count
var offset = 0
while remaining > 0 {
let written = writeFn(baseAddress + offset, remaining)
if written < 0 {
// Retry on EINTR; give up on any other error
if errno == EINTR { continue }
return offset
}
offset += written
remaining -= written
}
return offset
}
}
// MARK: - Log Entry Structure
struct LogEntry: Codable {
let timestamp: Date
let level: String
let message: String
let requestId: String
let traceId: String
let file: String
let function: String
let line: UInt
let metadata: [String: String]?
}
/// Encodes a log entry to JSON data. Extracted for testability.
/// Returns nil if encoding fails.
internal static func encodeLogEntry(_ logEntry: LogEntry) -> Data? {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .custom { date, encoder in
var container = encoder.singleValueContainer()
try container.encode(date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)))
}
encoder.outputFormatting = [] // Compact output (no pretty printing)
return try? encoder.encode(logEntry)
}
/// Maps a swift-log level to the AWS Lambda log level string.
internal static func mapLogLevel(_ level: Logger.Level) -> String {
switch level {
case .trace: return "TRACE"
case .debug: return "DEBUG"
case .info: return "INFO"
case .notice: return "INFO"
case .warning: return "WARN"
case .error: return "ERROR"
case .critical: return "FATAL"
}
}
}