Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OTelLogHandler that sends log records to an OTelLogRecordProcessor #108

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
82 changes: 82 additions & 0 deletions 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)
}
}
29 changes: 29 additions & 0 deletions 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
}
}
31 changes: 31 additions & 0 deletions 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
}
40 changes: 40 additions & 0 deletions 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"

Check warning on line 19 in Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift#L19

Added line #L19 was not covered by tests

private let stream: AsyncStream<Void>
private let continuation: AsyncStream<Void>.Continuation

/// Initialize a no-op log entry processor.
public init() {
(stream, continuation) = AsyncStream.makeStream()

Check warning on line 26 in Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift#L25-L26

Added lines #L25 - L26 were not covered by tests
}

public func run() async {
for await _ in stream.cancelOnGracefulShutdown() {}

Check warning on line 30 in Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift#L29-L30

Added lines #L29 - L30 were not covered by tests
}

public func onEmit(_ log: OTelLogRecord) {
// no-op

Check warning on line 34 in Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift#L33-L34

Added lines #L33 - L34 were not covered by tests
}

public func forceFlush() async throws {
// no-op

Check warning on line 38 in Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/OTel/Logging/Processing/OTelNoOpLogRecordProcessor.swift#L37-L38

Added lines #L37 - L38 were not covered by tests
}
}
44 changes: 44 additions & 0 deletions 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<Void>
private let continuation: AsyncStream<Void>.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 }
}
}
113 changes: 113 additions & 0 deletions 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"])
}
}