diff --git a/Sources/OTel/Logging/OTelLogHandler.swift b/Sources/OTel/Logging/OTelLogHandler.swift new file mode 100644 index 00000000..520df5b7 --- /dev/null +++ b/Sources/OTel/Logging/OTelLogHandler.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OTel open source project +// +// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AsyncAlgorithms +import Logging +import NIOConcurrencyHelpers +import ServiceLifecycle +import Tracing + +@_spi(Logging) +public struct OTelLogHandler: Sendable, LogHandler { + public var metadata: Logger.Metadata + public var logLevel: Logger.Level + private let processor: any OTelLogRecordProcessor + private let nanosecondsSinceEpoch: @Sendable () -> UInt64 + + public init( + processor: any OTelLogRecordProcessor, + logLevel: Logger.Level, + metadata: Logger.Metadata = [:] + ) { + self.init( + processor: processor, + logLevel: logLevel, + metadata: metadata, + nanosecondsSinceEpoch: { DefaultTracerClock.now.nanosecondsSinceEpoch } + ) + } + + package init( + processor: any OTelLogRecordProcessor, + logLevel: Logger.Level, + metadata: Logger.Metadata, + nanosecondsSinceEpoch: @escaping @Sendable () -> UInt64 + ) { + self.processor = processor + self.logLevel = logLevel + self.metadata = metadata + self.nanosecondsSinceEpoch = nanosecondsSinceEpoch + } + + public subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let effectiveMetadata: Logger.Metadata? + if let metadata { + effectiveMetadata = self.metadata.merging(metadata, uniquingKeysWith: { $1 }) + } else { + effectiveMetadata = self.metadata.isEmpty ? nil : self.metadata + } + + let record = OTelLogRecord( + body: message.description, + level: level, + metadata: effectiveMetadata, + timeNanosecondsSinceEpoch: nanosecondsSinceEpoch() + ) + + processor.onEmit(record) + } +} diff --git a/Sources/OTel/Logging/OTelLogRecord.swift b/Sources/OTel/Logging/OTelLogRecord.swift new file mode 100644 index 00000000..b460e327 --- /dev/null +++ b/Sources/OTel/Logging/OTelLogRecord.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OTel open source project +// +// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging + +@_spi(Logging) +public struct OTelLogRecord: Equatable, Sendable { + public let body: String + public let level: Logger.Level + public let metadata: Logger.Metadata? + public let timeNanosecondsSinceEpoch: UInt64 + + package init(body: String, level: Logger.Level, metadata: Logger.Metadata?, timeNanosecondsSinceEpoch: UInt64) { + self.body = body + self.level = level + self.metadata = metadata + self.timeNanosecondsSinceEpoch = timeNanosecondsSinceEpoch + } +} diff --git a/Sources/OTel/Logging/Processing/OTelLogRecordProcessor.swift b/Sources/OTel/Logging/Processing/OTelLogRecordProcessor.swift new file mode 100644 index 00000000..d629989d --- /dev/null +++ b/Sources/OTel/Logging/Processing/OTelLogRecordProcessor.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OTel open source project +// +// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import ServiceContextModule +import ServiceLifecycle + +/// Log processors allow for processing logs throughout their lifetime via ``onStart(_:parentContext:)`` and ``onEnd(_:)`` calls. +/// Usually, log processors will forward logs to a configurable ``OTelLogEntryExporter``. +/// +/// [OpenTelemetry specification: LogRecord processor](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/logs/sdk.md#logrecordprocessor) +/// +/// ### Implementation Notes +/// +/// On shutdown, processors forwarding logs to an ``OTelLogEntryExporter`` MUST shutdown that exporter. +@_spi(Logging) +public protocol OTelLogRecordProcessor: Service & Sendable { + func onEmit(_ record: OTelLogRecord) + + /// Force log processors that batch logs to flush immediately. + func forceFlush() async throws +} diff --git a/Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift b/Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift new file mode 100644 index 00000000..71c89e60 --- /dev/null +++ b/Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OTel open source project +// +// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import ServiceContextModule + +/// A log record processor that ignores all operations, used when no logs should be processed. +@_spi(Logging) +public struct OTelNoOpLogRecordProcessor: OTelLogRecordProcessor, CustomStringConvertible { + public let description = "OTelNoOpLogRecordProcessor" + + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + /// Initialize a no-op log entry processor. + public init() { + (stream, continuation) = AsyncStream.makeStream() + } + + public func run() async { + for await _ in stream.cancelOnGracefulShutdown() {} + } + + public func onEmit(_ log: OTelLogRecord) { + // no-op + } + + public func forceFlush() async throws { + // no-op + } +} diff --git a/Sources/OTelTesting/OTelInMemoryLogRecordProcessor.swift b/Sources/OTelTesting/OTelInMemoryLogRecordProcessor.swift new file mode 100644 index 00000000..5a25f0dd --- /dev/null +++ b/Sources/OTelTesting/OTelInMemoryLogRecordProcessor.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OTel open source project +// +// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOConcurrencyHelpers +@_spi(Logging) import OTel + +/// An in-memory log record processor, collecting emitted log records into ``onEmit(_:)``. +package final class OTelInMemoryLogRecordProcessor: OTelLogRecordProcessor { + package var records: [OTelLogRecord] { _records.withLockedValue { $0 } } + + private let _records = NIOLockedValueBox([OTelLogRecord]()) + private let _numberOfShutdowns = NIOLockedValueBox(0) + private let _numberOfForceFlushes = NIOLockedValueBox(0) + + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + package init() { + (stream, continuation) = AsyncStream.makeStream() + } + + package func run() async throws { + for await _ in stream.cancelOnGracefulShutdown() {} + _numberOfShutdowns.withLockedValue { $0 += 1 } + } + + package func onEmit(_ record: OTelLogRecord) { + _records.withLockedValue { $0.append(record) } + } + + package func forceFlush() async throws { + _numberOfForceFlushes.withLockedValue { $0 += 1 } + } +} diff --git a/Tests/OTelTests/Logging/OTelLogHandlerTests.swift b/Tests/OTelTests/Logging/OTelLogHandlerTests.swift new file mode 100644 index 00000000..362e041f --- /dev/null +++ b/Tests/OTelTests/Logging/OTelLogHandlerTests.swift @@ -0,0 +1,113 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OTel open source project +// +// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import Logging +@_spi(Logging) import OTel +import OTelTesting +import XCTest + +final class OTelLogHandlerTests: XCTestCase { + func test_log_withoutMetadata_forwardsLogEntryToProcessor() { + let processor = OTelInMemoryLogRecordProcessor() + let logger = Logger(label: #function) { _ in + OTelLogHandler(processor: processor, logLevel: .info, metadata: [:], nanosecondsSinceEpoch: { 42 }) + } + + logger.info("🏎️") + + XCTAssertEqual(processor.records, [ + OTelLogRecord(body: "🏎️", level: .info, metadata: nil, timeNanosecondsSinceEpoch: 42), + ]) + } + + func test_log_withLoggerMetadata_includesMetadataInLogRecord() { + let processor = OTelInMemoryLogRecordProcessor() + var logger = Logger(label: #function) { _ in + OTelLogHandler(processor: processor, logLevel: .info, metadata: [:], nanosecondsSinceEpoch: { 42 }) + } + logger[metadataKey: "logger"] = "42" + + logger.info("🏎️") + + XCTAssertEqual(processor.records, [ + OTelLogRecord(body: "🏎️", level: .info, metadata: ["logger": "42"], timeNanosecondsSinceEpoch: 42), + ]) + } + + func test_log_withHandlerMetadata_includesMetadataInLogRecord() { + let processor = OTelInMemoryLogRecordProcessor() + let logger = Logger(label: #function) { _ in + OTelLogHandler( + processor: processor, + logLevel: .info, + metadata: ["handler": "42"], + nanosecondsSinceEpoch: { 42 } + ) + } + + logger.info("🏎️") + + XCTAssertEqual(processor.records, [ + OTelLogRecord(body: "🏎️", level: .info, metadata: ["handler": "42"], timeNanosecondsSinceEpoch: 42), + ]) + } + + func test_log_withHandlerAndLoggerMetadata_overridesHandlerWithLoggerMetadata() { + let processor = OTelInMemoryLogRecordProcessor() + var logger = Logger(label: #function) { _ in + OTelLogHandler( + processor: processor, + logLevel: .info, + metadata: ["shared": "handler"], + nanosecondsSinceEpoch: { 42 } + ) + } + logger[metadataKey: "shared"] = "logger" + + logger.info("🏎️") + + XCTAssertEqual(processor.records, [ + OTelLogRecord(body: "🏎️", level: .info, metadata: ["shared": "logger"], timeNanosecondsSinceEpoch: 42), + ]) + } + + func test_log_withLoggerAndAdHocMetadata_overridesLoggerWithAdHocMetadata() { + let processor = OTelInMemoryLogRecordProcessor() + var logger = Logger(label: #function) { _ in + OTelLogHandler(processor: processor, logLevel: .info, metadata: [:], nanosecondsSinceEpoch: { 42 }) + } + logger[metadataKey: "shared"] = "logger" + + logger.info("🏎️", metadata: ["shared": "ad-hoc"]) + + XCTAssertEqual(processor.records, [ + OTelLogRecord(body: "🏎️", level: .info, metadata: ["shared": "ad-hoc"], timeNanosecondsSinceEpoch: 42), + ]) + } + + func test_loggerMetadataProxiesToHandlerMetadata() throws { + let processor = OTelInMemoryLogRecordProcessor() + var logger = Logger(label: #function) { _ in + OTelLogHandler(processor: processor, logLevel: .info, metadata: ["shared": "handler"]) + } + + logger[metadataKey: "shared"] = "logger" + let handler = try XCTUnwrap(logger.handler as? OTelLogHandler) + + XCTAssertEqual(handler[metadataKey: "shared"], "logger") + + logger.info("🏎️") + + XCTAssertEqual(try XCTUnwrap(processor.records.first).metadata, ["shared": "logger"]) + } +}