diff --git a/Package.swift b/Package.swift index 60f04bb3..615f0759 100644 --- a/Package.swift +++ b/Package.swift @@ -27,21 +27,32 @@ let package = Package( ], traits: [ .trait(name: "FullFoundation"), - .default(enabledTraits: ["FullFoundation"]) + .trait(name: "OTelSemanticConventions"), + .trait(name: "Logging", enabledTraits: ["OTelSemanticConventions"]), + .default(enabledTraits: ["FullFoundation", "Logging"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-http-types", from: "1.0.0"), + // 1.14.0 is the first release with the task-local `Logger.current`. + .package(url: "https://github.com/apple/swift-log.git", from: "1.14.0"), + // 1.34.0 is the earliest release; it already defines every attribute the middlewares log. + .package(url: "https://github.com/swift-otel/swift-otel-semantic-conventions.git", from: "1.34.0"), ], targets: [ .target( name: "OpenAPIRuntime", dependencies: [ - .product(name: "HTTPTypes", package: "swift-http-types") + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "Logging", package: "swift-log", condition: .when(traits: ["Logging"])), + .product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions", condition: .when(traits: ["OTelSemanticConventions"])), ] ), .testTarget( name: "OpenAPIRuntimeTests", - dependencies: ["OpenAPIRuntime"] + dependencies: [ + "OpenAPIRuntime", + .product(name: "Logging", package: "swift-log"), + ] ), ] ) diff --git a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md index 69350fd5..8e77e59b 100644 --- a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md +++ b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md @@ -95,6 +95,8 @@ You can also publish your transport or middleware as a Swift package to allow ot ### Additional middlewares - ``ErrorHandlingMiddleware`` - ``QuerySpaceNormalizingMiddleware`` +- ``ClientOTelLoggingMiddleware`` +- ``ServerOTelLoggingMiddleware`` [0]: https://github.com/apple/swift-openapi-generator [1]: https://swiftpackageindex.com/apple/swift-openapi-generator/documentation diff --git a/Sources/OpenAPIRuntime/Middleware/ClientOTelLoggingMiddleware.swift b/Sources/OpenAPIRuntime/Middleware/ClientOTelLoggingMiddleware.swift new file mode 100644 index 00000000..fd73a9ae --- /dev/null +++ b/Sources/OpenAPIRuntime/Middleware/ClientOTelLoggingMiddleware.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if Logging && OTelSemanticConventions + +public import HTTPTypes +public import Logging +import OTelSemanticConventions + +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else +public import Foundation +#endif + +/// A Middleware that logs outgoing HTTP requests and their responses, annotated with +/// [OpenTelemetry Semantic Convention HTTP Attributes][https://opentelemetry.io/docs/specs/semconv/registry/attributes/http]. +/// +/// A successful call emits two records at the configured log level: `HTTP Client Request` before the request is +/// handed to the transport, and `HTTP Client Response` once the response arrives. If the request +/// fails, the second record is `HTTP Client Request Error`, logged at the specified error log level. +/// +/// - Note: The middleware is gated behind the `Logging` package trait, which is enabled by default. +/// +/// ## Logged attributes +/// +/// The request record carries: +/// +/// | Attribute | Notes | +/// | --- | --- | +/// | `network.transport` | The value passed to the initializer. | +/// | `http.request.method` | | +/// | `server.address`, `server.port`, `url.scheme`, `url.full` | Derived from the base URL and the request path. | +/// | `http.request.body.size` | Only when the body length is known. | +/// | `user_agent.original` | Only when the request carries a `User-Agent` field. | +/// | `http.request.header.` | One per captured field name, listing every value of that field. | +/// +/// The response record additionally carries `http.response.status_code`, +/// `http.response.body.size` (only when the body length is known), and +/// `http.response.header.` (one per captured field name). The error record carries +/// no extra attributes; the error itself is attached to the log record, leaving it to the log +/// handler to report it. +/// +/// ## Example usage +/// +/// ```swift +/// let client = Client( +/// serverURL: try Servers.Server1.url(), +/// transport: transport, +/// middlewares: [ClientOTelLoggingMiddleware(requestHeaders: [.accept], responseHeaders: [.contentType])] +/// ) +/// ``` +public struct ClientOTelLoggingMiddleware: ClientMiddleware { + private let logLevel: Logger.Level + private let errorLevel: Logger.Level + private let requestHeaders: OTelCapturedHeaders + private let responseHeaders: OTelCapturedHeaders + + private let networkTransport: String + + /// Creates a new middleware. + /// - Parameters: + /// - logLevel: The level at which the request and response records are logged. + /// - errorLevel: The level at which a failed request is logged. + /// - requestHeaders: The request header fields to log. + /// - responseHeaders: The response header fields to log. + /// - networkTransport: The value logged as the `network.transport` attribute. Provide the + /// transport actually in use, such as `"unix"`, when it is not TCP. + public init( + logLevel: Logger.Level = .debug, + errorLevel: Logger.Level = .error, + requestHeaders: OTelCapturedHeaders = .none, + responseHeaders: OTelCapturedHeaders = .none, + networkTransport: String = "tcp", + ) { + self.logLevel = logLevel + self.errorLevel = errorLevel + self.requestHeaders = requestHeaders + self.responseHeaders = responseHeaders + self.networkTransport = networkTransport + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var logger = Logger.current + + logger[metadataKey: OTelAttribute.network.transport] = "\(networkTransport)" + logger[metadataKey: OTelAttribute.http.request.method] = "\(request.method)" + + if var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), + let requestComponents = URLComponents(string: request.path ?? "") + { + if components.percentEncodedPath.hasSuffix("/") { components.percentEncodedPath.removeLast() } + components.percentEncodedPath += requestComponents.percentEncodedPath + components.percentEncodedQuery = requestComponents.percentEncodedQuery + + logger[metadataKey: OTelAttribute.server.address] = components.host.map { "\($0)" } + logger[metadataKey: OTelAttribute.server.port] = components.port.map { "\($0)" } + logger[metadataKey: OTelAttribute.url.scheme] = components.scheme.map { "\($0)" } + logger[metadataKey: OTelAttribute.url.full] = components.string.map { "\($0)" } + } + + if case let .known(length) = body?.length { + logger[metadataKey: OTelExperimentalHTTPKeys.httpRequestBodySize] = "\(length)" + } + + logger[metadataKey: OTelAttribute.userAgent.original] = request.headerFields[.userAgent].map { "\($0)" } + + requestHeaders.addMetadata(for: request.headerFields, prefix: OTelAttribute.http.request.header, to: &logger) + + logger.log(level: self.logLevel, "HTTP Client Request") + + let (response, responseBody): (HTTPResponse, HTTPBody?) + do { (response, responseBody) = try await next(request, body, baseURL) } catch { + logger.log(level: self.errorLevel, "HTTP Client Request Error", error: error) + throw error + } + + logger[metadataKey: OTelAttribute.http.response.statusCode] = "\(response.status.code)" + if case let .known(length) = responseBody?.length { + logger[metadataKey: OTelExperimentalHTTPKeys.httpResponseBodySize] = "\(length)" + } + + responseHeaders.addMetadata(for: response.headerFields, prefix: OTelAttribute.http.response.header, to: &logger) + + logger.log(level: self.logLevel, "HTTP Client Response") + + return (response, responseBody) + } +} + +#endif diff --git a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift b/Sources/OpenAPIRuntime/Middleware/ErrorHandlingMiddleware.swift similarity index 100% rename from Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift rename to Sources/OpenAPIRuntime/Middleware/ErrorHandlingMiddleware.swift diff --git a/Sources/OpenAPIRuntime/Middleware/OTelCapturedHeaders.swift b/Sources/OpenAPIRuntime/Middleware/OTelCapturedHeaders.swift new file mode 100644 index 00000000..874b2cd8 --- /dev/null +++ b/Sources/OpenAPIRuntime/Middleware/OTelCapturedHeaders.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if Logging && OTelSemanticConventions + +public import HTTPTypes +import Logging + +/// Define the strategy for the list of headers a Logging Middleware should collect. +public struct OTelCapturedHeaders: Sendable, Hashable { + private enum Storage: Sendable, Hashable { + case none + case all + case exclude(Set) + case include(Set) + } + + private let storage: Storage + + /// Captures no header fields. + public static var none: OTelCapturedHeaders { OTelCapturedHeaders(storage: .none) } + + /// Captures every header field. + /// + /// - Important: This records the values of `Authorization`, `Cookie`, and `Set-Cookie`, among + /// others. Prefer naming the header fields to capture. + public static var all: OTelCapturedHeaders { OTelCapturedHeaders(storage: .all) } + + /// Captures the named header fields. + /// - Parameter names: The names of the header fields to capture. + /// - Returns: A value that captures the named header fields, and no others. + public static func include(_ names: Set) -> OTelCapturedHeaders { + OTelCapturedHeaders(storage: names.isEmpty ? .none : .include(names)) + } + + /// Captures all headers except the named header fields. + /// - Parameter names: The names of the header fields not to capture. + /// - Returns: A value that does not capture the named header fields. + public static func exclude(_ names: Set) -> OTelCapturedHeaders { + OTelCapturedHeaders(storage: names.isEmpty ? .all : .exclude(names)) + } +} + +extension OTelCapturedHeaders: ExpressibleByArrayLiteral { + /// Captures the named header fields. + /// - Parameter elements: The names of the header fields to capture. + public init(arrayLiteral elements: HTTPField.Name...) { self = .include(Set(elements)) } +} + +extension OTelCapturedHeaders { + /// Attaches the logging metadata for the given `HTTPFields` according to the given configuration. + /// - Parameters: + /// - fields: The header fields to capture from. + /// - prefix: The attribute name the field name is appended to, such as `http.request.header`. + /// - logger: The logger to attach metadata to. + func addMetadata(for fields: HTTPFields, prefix: String, to logger: inout Logger) { + // A name repeated across fields must produce a single attribute, so the fields are reduced + // to their distinct names before the values of each are looked up. + let names: Set + + switch self.storage { + case .none: return + case .all: names = Set(fields.map(\.name)) + case let .include(captured): names = captured + case let .exclude(captured): names = Set(fields.filter { !captured.contains($0.name) }.map(\.name)) + } + + for name in names { + let values = fields[values: name] + guard !values.isEmpty else { continue } + + logger[metadataKey: "\(prefix).\(name.canonicalName)"] = .array(values.map { .string($0) }) + } + } +} + +#endif diff --git a/Sources/OpenAPIRuntime/Middleware/OTelExperimentalHTTPKeys.swift b/Sources/OpenAPIRuntime/Middleware/OTelExperimentalHTTPKeys.swift new file mode 100644 index 00000000..6c3b6558 --- /dev/null +++ b/Sources/OpenAPIRuntime/Middleware/OTelExperimentalHTTPKeys.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if OTelSemanticConventions + +enum OTelExperimentalHTTPKeys { + static let httpRequestBodySize = "http.request.body.size" + static let httpResponseBodySize = "http.response.body.size" +} + +#endif diff --git a/Sources/OpenAPIRuntime/Interface/QuerySpaceNormalizingMiddleware.swift b/Sources/OpenAPIRuntime/Middleware/QuerySpaceNormalizingMiddleware.swift similarity index 100% rename from Sources/OpenAPIRuntime/Interface/QuerySpaceNormalizingMiddleware.swift rename to Sources/OpenAPIRuntime/Middleware/QuerySpaceNormalizingMiddleware.swift diff --git a/Sources/OpenAPIRuntime/Middleware/ServerOTelLoggingMiddleware.swift b/Sources/OpenAPIRuntime/Middleware/ServerOTelLoggingMiddleware.swift new file mode 100644 index 00000000..e689ba08 --- /dev/null +++ b/Sources/OpenAPIRuntime/Middleware/ServerOTelLoggingMiddleware.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if Logging && OTelSemanticConventions + +public import HTTPTypes +public import Logging +import OTelSemanticConventions + +/// A Middleware that logs incoming HTTP requests and the responses produced for them, annotated with +/// [OpenTelemetry Semantic Convention HTTP Attributes][https://opentelemetry.io/docs/specs/semconv/registry/attributes/http]. +/// +/// A successfully handled request emits two records at the configured log level: `HTTP Server Request` before the +/// request reaches the handler, and `HTTP Server Response` once the handler returns. If the handler +/// throws, the second record is `HTTP Server Request Error`, logged at the specified error log level. +/// +/// - Note: The middleware is gated behind the `Logging` package trait. +/// +/// ## Logged attributes +/// +/// The request record carries: +/// +/// | Attribute | Notes | +/// | --- | --- | +/// | `server.address`, `server.port` | The values passed to the initializer; omitted when `nil`. | +/// | `network.transport` | The value passed to the initializer. | +/// | `http.request.method` | | +/// | `url.scheme` | The scheme of the request, when present. | +/// | `url.full` | Composed from the scheme, authority, and path; omitted unless all three are present. | +/// | `http.request.body.size` | Only when the body length is known. | +/// | `user_agent.original` | Only when the request carries a `User-Agent` field. | +/// | `http.request.header.` | One per captured field name, listing every value of that field. | +/// +/// The response record additionally carries `http.response.status_code`, +/// `http.response.body.size` (only when the body length is known), and +/// `http.response.header.` (one per captured field name). The error record carries +/// no extra attributes; the error itself is attached to the log record, leaving it to the log +/// handler to report it. +/// +/// ## Example usage +/// +/// ```swift +/// let handler = RequestHandler() +/// let middleware = ServerOTelLoggingMiddleware(requestHeaders: [.accept], responseHeaders: [.contentType]) +/// try handler.registerHandlers(on: transport, middlewares: [middleware]) +/// ``` +public struct ServerOTelLoggingMiddleware: ServerMiddleware { + private let logLevel: Logger.Level + private let errorLevel: Logger.Level + private let requestHeaders: OTelCapturedHeaders + private let responseHeaders: OTelCapturedHeaders + + private let serverAddress: String? + private let serverPort: Int? + private let networkTransport: String + + /// Creates a new middleware. + /// - Parameters: + /// - logLevel: The level at which the request and response records are logged. + /// - errorLevel: The level at which a failed request is logged. + /// - requestHeaders: The request header fields to log. + /// - responseHeaders: The response header fields to log. + /// - serverAddress: The value logged as the `server.address` attribute. The incoming request + /// does not carry the address the server is reachable at, so it has to be provided here. + /// - serverPort: The value logged as the `server.port` attribute. As with `serverAddress`, it + /// has to be provided here. + /// - networkTransport: The value logged as the `network.transport` attribute. Provide the + /// transport actually in use, such as `"unix"`, when it is not TCP. + public init( + logLevel: Logger.Level = .debug, + errorLevel: Logger.Level = .error, + requestHeaders: OTelCapturedHeaders = .none, + responseHeaders: OTelCapturedHeaders = .none, + serverAddress: String? = nil, + serverPort: Int? = nil, + networkTransport: String = "tcp", + ) { + self.logLevel = logLevel + self.errorLevel = errorLevel + self.requestHeaders = requestHeaders + self.responseHeaders = responseHeaders + self.serverAddress = serverAddress + self.serverPort = serverPort + self.networkTransport = networkTransport + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + metadata: ServerRequestMetadata, + operationID: String, + next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var logger = Logger.current + + logger[metadataKey: OTelAttribute.server.address] = serverAddress.map { "\($0)" } + logger[metadataKey: OTelAttribute.server.port] = serverPort.map { "\($0)" } + logger[metadataKey: OTelAttribute.network.transport] = "\(networkTransport)" + + logger[metadataKey: OTelAttribute.http.request.method] = "\(request.method)" + logger[metadataKey: OTelAttribute.url.scheme] = request.scheme.map { "\($0)" } + if let scheme = request.scheme, let authority = request.authority, let path = request.path { + logger[metadataKey: OTelAttribute.url.full] = "\(scheme)://\(authority)\(path)" + } + + if case let .known(length) = body?.length { + logger[metadataKey: OTelExperimentalHTTPKeys.httpRequestBodySize] = "\(length)" + } + + logger[metadataKey: OTelAttribute.userAgent.original] = request.headerFields[.userAgent].map { "\($0)" } + + requestHeaders.addMetadata(for: request.headerFields, prefix: OTelAttribute.http.request.header, to: &logger) + + logger.log(level: self.logLevel, "HTTP Server Request") + + let (response, responseBody): (HTTPResponse, HTTPBody?) + do { (response, responseBody) = try await next(request, body, metadata) } catch { + logger.log(level: self.errorLevel, "HTTP Server Request Error", error: error) + throw error + } + + logger[metadataKey: OTelAttribute.http.response.statusCode] = "\(response.status.code)" + if case let .known(length) = responseBody?.length { + logger[metadataKey: OTelExperimentalHTTPKeys.httpResponseBodySize] = "\(length)" + } + + responseHeaders.addMetadata(for: response.headerFields, prefix: OTelAttribute.http.response.header, to: &logger) + + logger.log(level: self.logLevel, "HTTP Server Response") + + return (response, responseBody) + } +} + +#endif diff --git a/Tests/OpenAPIRuntimeTests/Middleware/RecordingLogHandler.swift b/Tests/OpenAPIRuntimeTests/Middleware/RecordingLogHandler.swift new file mode 100644 index 00000000..e9725e5d --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Middleware/RecordingLogHandler.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if Logging && OTelSemanticConventions + +import Logging +@testable import OpenAPIRuntime + +/// A log handler that records every event it is asked to emit, for asserting on the metadata +/// produced by a middleware. +/// +/// `Logger` copies its handler on every metadata mutation, so the recorded events are kept in a +/// shared reference-typed sink that outlives those copies. +struct RecordingLogHandler: LogHandler { + /// A recorded log event. + struct Event { + var level: Logger.Level + var message: String + var metadata: Logger.Metadata + var error: (any Error)? + } + + final class Sink: @unchecked Sendable { + private let lock = Lock() + private var storage: [Event] = [] + + var events: [Event] { self.lock.withLock { self.storage } } + + func append(_ event: Event) { self.lock.withLock { self.storage.append(event) } } + } + + let sink: Sink + + var logLevel: Logger.Level = .trace + var metadata: Logger.Metadata = [:] + var metadataProvider: Logger.MetadataProvider? + + init(sink: Sink) { self.sink = sink } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log(event: LogEvent) { + self.sink.append( + Event( + level: event.level, + message: "\(event.message)", + metadata: self.metadata.merging(event.metadata ?? [:]) { _, new in new }, + error: event.error + ) + ) + } +} + +extension RecordingLogHandler.Sink { + /// Runs `operation` with a logger that records into this sink bound as the task-local logger. + /// - Parameters: + /// - logLevel: The level the recording handler is configured with. + /// - operation: The work to run with the recording logger bound. + /// - Returns: The value returned by `operation`. + /// - Throws: Whatever `operation` throws. + func record(logLevel: Logger.Level = .trace, _ operation: nonisolated(nonsending) () async throws -> Result) + async throws -> Result + { + // Only `handler:` is passed: `withLogger(logLevel:handler:)` assigns its `logLevel` to the + // handler it is about to replace, so the level has to be carried by the handler itself. + var handler = RecordingLogHandler(sink: self) + handler.logLevel = logLevel + return try await withLogger(handler: handler) { _ in try await operation() } + } +} + +#endif diff --git a/Tests/OpenAPIRuntimeTests/Middleware/Test_ClientOTelLoggingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Middleware/Test_ClientOTelLoggingMiddleware.swift new file mode 100644 index 00000000..f547d5d4 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Middleware/Test_ClientOTelLoggingMiddleware.swift @@ -0,0 +1,328 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if Logging && OTelSemanticConventions + +import HTTPTypes +import Logging +import XCTest +@_spi(Generated) import OpenAPIRuntime + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +final class Test_ClientOTelLoggingMiddleware: XCTestCase { + func testRequestAndResponseAttributes() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.accept], responseHeaders: [.contentType]), + request: HTTPRequest( + soar_path: "/pets?limit=1", + method: .get, + headerFields: [.accept: "application/json", .userAgent: "Test/1.0"] + ), + body: HTTPBody("{}"), + baseURL: try XCTUnwrap(URL(string: "https://example.com/api")) + ) { _, _, _ in (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), HTTPBody("[]")) } + + XCTAssertEqual(events.count, 2) + + let requestAttributes: Logger.Metadata = [ + "network.transport": "tcp", "http.request.method": "GET", "server.address": "example.com", + "url.scheme": "https", "url.full": "https://example.com/api/pets?limit=1", "http.request.body.size": "2", + "user_agent.original": "Test/1.0", "http.request.header.accept": .array(["application/json"]), + ] + XCTAssertEqual(events.first?.level, .debug) + XCTAssertEqual(events.first?.message, "HTTP Client Request") + XCTAssertEqual(events.first?.metadata, requestAttributes) + XCTAssertNil(events.first?.error) + + // The response is logged through the same logger, so it carries the request attributes too. + XCTAssertEqual(events.last?.level, .debug) + XCTAssertEqual(events.last?.message, "HTTP Client Response") + XCTAssertEqual( + events.last?.metadata, + requestAttributes.merging([ + "http.response.status_code": "200", "http.response.body.size": "2", + "http.response.header.content-type": .array(["application/json"]), + ]) { _, new in new } + ) + } + + func testURLComposition() async throws { + try await assertURLAttributes( + baseURL: "https://example.com/api", + path: "/pets?limit=1", + full: "https://example.com/api/pets?limit=1", + address: "example.com", + scheme: "https" + ) + try await assertURLAttributes( + baseURL: "https://example.com", + path: "/pets", + full: "https://example.com/pets", + address: "example.com", + scheme: "https" + ) + try await assertURLAttributes( + baseURL: "https://example.com/", + path: "/pets", + full: "https://example.com/pets", + address: "example.com", + scheme: "https" + ) + try await assertURLAttributes( + baseURL: "http://localhost:8080/v1/", + path: "/pets/42", + full: "http://localhost:8080/v1/pets/42", + address: "localhost", + port: "8080", + scheme: "http" + ) + try await assertURLAttributes( + baseURL: "https://example.com/api", + path: "/pets/a%20b?q=x%20y", + full: "https://example.com/api/pets/a%20b?q=x%20y", + address: "example.com", + scheme: "https" + ) + try await assertURLAttributes( + baseURL: "https://example.com/api", + path: "/pets", + full: "https://example.com/api/pets", + address: "example.com", + scheme: "https" + ) + } + + func testNilRequestPath() async throws { + let events = try await recordedEvents( + request: HTTPRequest(method: .get, scheme: nil, authority: nil, path: nil), + baseURL: try XCTUnwrap(URL(string: "https://example.com/api")) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["url.full"], "https://example.com/api") + } + + func testHeadersAreNotCapturedByDefault() async throws { + let events = try await recordedEvents( + request: HTTPRequest(soar_path: "/pets", method: .get, headerFields: [.accept: "application/json"]), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), nil) } + + let headerKeys = events.last?.metadata.keys.filter { $0.contains(".header.") } + XCTAssertEqual(headerKeys, []) + XCTAssertEqual(events.last?.metadata["http.response.status_code"], "200") + } + + func testRepeatedFieldIsCapturedAsOneAttribute() async throws { + var headerFields = HTTPFields() + headerFields[values: .init("X-Forwarded-For")!] = ["1.2.3.4", "1.2.3.5"] + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.init("x-forwarded-for")!]), + request: HTTPRequest(soar_path: "/pets", method: .get, headerFields: headerFields), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.x-forwarded-for"], .array(["1.2.3.4", "1.2.3.5"])) + } + + func testCapturedFieldsThatAreAbsentAreOmitted() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.accept, .authorization], responseHeaders: [.eTag]), + request: HTTPRequest(soar_path: "/pets", method: .get, headerFields: [.accept: "application/json"]), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.accept"], .array(["application/json"])) + XCTAssertNil(events.first?.metadata["http.request.header.authorization"]) + XCTAssertNil(events.last?.metadata["http.response.header.etag"]) + } + + func testCapturedFieldNamesMatchCaseInsensitively() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.init("X-Custom")!]), + request: HTTPRequest(soar_path: "/pets", method: .get, headerFields: [.init("x-custom")!: "value"]), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.x-custom"], .array(["value"])) + } + + func testAllCapturesEveryField() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: .all, responseHeaders: .all), + request: HTTPRequest( + soar_path: "/pets", + method: .get, + headerFields: [.accept: "application/json", .userAgent: "Test/1.0"] + ), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in + (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json", .eTag: "\"abc\""]), nil) + } + + XCTAssertEqual(events.first?.metadata["http.request.header.accept"], .array(["application/json"])) + // The `User-Agent` field is reported as `user_agent.original`, and, having been asked for + // explicitly, as a header attribute too. + XCTAssertEqual(events.first?.metadata["user_agent.original"], "Test/1.0") + XCTAssertEqual(events.first?.metadata["http.request.header.user-agent"], .array(["Test/1.0"])) + XCTAssertEqual(events.last?.metadata["http.response.header.content-type"], .array(["application/json"])) + XCTAssertEqual(events.last?.metadata["http.response.header.etag"], .array(["\"abc\""])) + } + + func testNoAttributesPropagateToTheTransport() async throws { + let sink = RecordingLogHandler.Sink() + _ = try await sink.record { + try await ClientOTelLoggingMiddleware(requestHeaders: .all) + .intercept( + HTTPRequest(soar_path: "/pets", method: .get, headerFields: [.accept: "application/json"]), + body: nil, + baseURL: try XCTUnwrap(URL(string: "https://example.com")), + operationID: "getPets", + next: { _, _, _ in + Logger.current.info("Sending") + return (HTTPResponse(status: .ok), nil) + } + ) + } + + // The middleware annotates its own records only; the transport keeps the logger it was + // given, and correlating the records is left to the trace identifiers. + let transportEvent = try XCTUnwrap(sink.events.first { $0.message == "Sending" }) + XCTAssertEqual(transportEvent.metadata, [:]) + } + + func testCustomLogLevels() async throws { + let events = try await recordedEvents( + middleware: .init(logLevel: .info, errorLevel: .critical), + request: HTTPRequest(soar_path: "/pets", method: .get), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + XCTAssertEqual(events.map(\.level), [.info, .info]) + + let sink = RecordingLogHandler.Sink() + do { + _ = try await sink.record { + try await ClientOTelLoggingMiddleware(logLevel: .info, errorLevel: .critical) + .intercept( + HTTPRequest(soar_path: "/pets", method: .get), + body: nil, + baseURL: try XCTUnwrap(URL(string: "https://example.com")), + operationID: "getPets", + next: { _, _, _ in throw TestError() } + ) + } + XCTFail("Expected the middleware to rethrow.") + } catch is TestError {} + // The request is already logged by the time `next` throws, so the error path emits two + // records: the request at `logLevel` and the failure at `errorLevel`. + XCTAssertEqual(sink.events.map(\.level), [.info, .critical]) + } + + func testUnknownAndAbsentBodyLengthOmitSize() async throws { + let events = try await recordedEvents( + request: HTTPRequest(soar_path: "/pets", method: .get), + body: nil, + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok), HTTPBody(ArraySlice([0x7b, 0x7d]), length: .unknown)) } + + XCTAssertNil(events.first?.metadata["http.request.body.size"]) + XCTAssertNil(events.last?.metadata["http.response.body.size"]) + } + + func testEmptyBodyReportsZeroSize() async throws { + let events = try await recordedEvents( + request: HTTPRequest(soar_path: "/pets", method: .post), + body: HTTPBody(), + baseURL: try XCTUnwrap(URL(string: "https://example.com")) + ) { _, _, _ in (HTTPResponse(status: .ok), HTTPBody()) } + + XCTAssertEqual(events.first?.metadata["http.request.body.size"], "0") + XCTAssertEqual(events.last?.metadata["http.response.body.size"], "0") + } + + func testErrorPath() async throws { + let sink = RecordingLogHandler.Sink() + do { + _ = try await sink.record { + try await ClientOTelLoggingMiddleware() + .intercept( + HTTPRequest(soar_path: "/pets", method: .get), + body: nil, + baseURL: try XCTUnwrap(URL(string: "https://example.com")), + operationID: "getPets", + next: { _, _, _ in throw TestError() } + ) + } + XCTFail("Expected the middleware to rethrow.") + } catch is TestError {} + + // The request record is emitted before `next` runs, so a failure produces two records and + // no "HTTP Client Response". + let events = sink.events + XCTAssertEqual(events.map(\.message), ["HTTP Client Request", "HTTP Client Request Error"]) + XCTAssertEqual(events.last?.level, .error) + XCTAssertNil(events.last?.metadata["http.response.status_code"]) + XCTAssertTrue(events.last?.error is TestError) + + // The failure record keeps the full request context; the error is carried by the record + // itself rather than by an attribute. + let requestAttributes = try XCTUnwrap(events.first?.metadata) + XCTAssertEqual(events.last?.metadata, requestAttributes) + } + + // MARK: - Helpers + + private func recordedEvents( + middleware: ClientOTelLoggingMiddleware = .init(), + request: HTTPRequest, + body: HTTPBody? = nil, + baseURL: URL, + operationID: String = "getPets", + next: @escaping @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> [RecordingLogHandler.Event] { + let sink = RecordingLogHandler.Sink() + _ = try await sink.record { + try await middleware.intercept(request, body: body, baseURL: baseURL, operationID: operationID, next: next) + } + return sink.events + } + + private func assertURLAttributes( + baseURL: String, + path: String, + full: String, + address: String, + port: Logger.Metadata.Value? = nil, + scheme: String, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let events = try await recordedEvents( + request: HTTPRequest(soar_path: path, method: .get), + baseURL: try XCTUnwrap(URL(string: baseURL), file: file, line: line) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + let metadata = try XCTUnwrap(events.first?.metadata, file: file, line: line) + XCTAssertEqual(metadata["url.full"], "\(full)", file: file, line: line) + XCTAssertEqual(metadata["server.address"], "\(address)", file: file, line: line) + XCTAssertEqual(metadata["server.port"], port, file: file, line: line) + XCTAssertEqual(metadata["url.scheme"], "\(scheme)", file: file, line: line) + } +} + +#endif diff --git a/Tests/OpenAPIRuntimeTests/Middleware/Test_ServerOTelLoggingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Middleware/Test_ServerOTelLoggingMiddleware.swift new file mode 100644 index 00000000..d582f597 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Middleware/Test_ServerOTelLoggingMiddleware.swift @@ -0,0 +1,297 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if Logging && OTelSemanticConventions + +import HTTPTypes +import Logging +import XCTest +@_spi(Generated) import OpenAPIRuntime + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +final class Test_ServerOTelLoggingMiddleware: XCTestCase { + func testRequestAndResponseAttributes() async throws { + let events = try await recordedEvents( + middleware: .init( + requestHeaders: [.accept], + responseHeaders: [.contentType], + serverAddress: "0.0.0.0", + serverPort: 8080 + ), + request: HTTPRequest( + method: .get, + scheme: "https", + authority: "example.com", + path: "/api/pets?limit=1", + headerFields: [.accept: "application/json", .userAgent: "Test/1.0"] + ), + body: HTTPBody("{}") + ) { _, _, _ in (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), HTTPBody("[]")) } + + XCTAssertEqual(events.count, 2) + + let requestAttributes: Logger.Metadata = [ + "server.address": "0.0.0.0", "server.port": "8080", "network.transport": "tcp", + "http.request.method": "GET", "url.scheme": "https", "url.full": "https://example.com/api/pets?limit=1", + "http.request.body.size": "2", "user_agent.original": "Test/1.0", + "http.request.header.accept": .array(["application/json"]), + ] + XCTAssertEqual(events.first?.level, .debug) + XCTAssertEqual(events.first?.message, "HTTP Server Request") + XCTAssertEqual(events.first?.metadata, requestAttributes) + XCTAssertNil(events.first?.error) + + // The response is logged through the same logger, so it carries the request attributes too. + XCTAssertEqual(events.last?.level, .debug) + XCTAssertEqual(events.last?.message, "HTTP Server Response") + XCTAssertEqual( + events.last?.metadata, + requestAttributes.merging([ + "http.response.status_code": "200", "http.response.body.size": "2", + "http.response.header.content-type": .array(["application/json"]), + ]) { _, new in new } + ) + } + + func testPeerAttributesAreOmittedWhenNotConfigured() async throws { + let events = try await recordedEvents(request: HTTPRequest(soar_path: "/api/pets", method: .get)) { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + + XCTAssertNil(events.first?.metadata["server.address"]) + XCTAssertNil(events.first?.metadata["server.port"]) + XCTAssertEqual(events.first?.metadata["network.transport"], "tcp") + } + + func testCustomNetworkTransport() async throws { + let events = try await recordedEvents( + middleware: .init(networkTransport: "unix"), + request: HTTPRequest(soar_path: "/api/pets", method: .get) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["network.transport"], "unix") + } + + func testURLAttributesRequireSchemeAndAuthority() async throws { + // A request built without a scheme or authority — as `soar_path` produces — has no + // absolute URL to report. + let events = try await recordedEvents(request: HTTPRequest(soar_path: "/api/pets", method: .get)) { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + XCTAssertNil(events.first?.metadata["url.full"]) + XCTAssertNil(events.first?.metadata["url.scheme"]) + + // The scheme alone is reported even when the authority is missing. + let schemeOnly = try await recordedEvents( + request: HTTPRequest(method: .get, scheme: "https", authority: nil, path: "/api/pets") + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + XCTAssertEqual(schemeOnly.first?.metadata["url.scheme"], "https") + XCTAssertNil(schemeOnly.first?.metadata["url.full"]) + } + + func testHeadersAreNotCapturedByDefault() async throws { + let events = try await recordedEvents( + request: HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: [.accept: "application/json"]) + ) { _, _, _ in (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), nil) } + + let headerKeys = events.last?.metadata.keys.filter { $0.contains(".header.") } + XCTAssertEqual(headerKeys, []) + XCTAssertEqual(events.last?.metadata["http.response.status_code"], "200") + } + + func testRepeatedFieldIsCapturedAsOneAttribute() async throws { + var headerFields = HTTPFields() + headerFields[values: .init("X-Forwarded-For")!] = ["1.2.3.4", "1.2.3.5"] + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.init("x-forwarded-for")!]), + request: HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: headerFields) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.x-forwarded-for"], .array(["1.2.3.4", "1.2.3.5"])) + } + + func testCapturedFieldsThatAreAbsentAreOmitted() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.accept, .authorization], responseHeaders: [.eTag]), + request: HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: [.accept: "application/json"]) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.accept"], .array(["application/json"])) + XCTAssertNil(events.first?.metadata["http.request.header.authorization"]) + XCTAssertNil(events.last?.metadata["http.response.header.etag"]) + } + + func testCapturedFieldNamesMatchCaseInsensitively() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: [.init("X-Custom")!]), + request: HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: [.init("x-custom")!: "value"]) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.x-custom"], .array(["value"])) + } + + func testExcludeCapturesEveryOtherField() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: .exclude([.authorization]), responseHeaders: .exclude([.setCookie])), + request: HTTPRequest( + soar_path: "/api/pets", + method: .get, + headerFields: [.accept: "application/json", .authorization: "Bearer secret"] + ) + ) { _, _, _ in + (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json", .setCookie: "session=1"]), nil) + } + + XCTAssertEqual(events.first?.metadata["http.request.header.accept"], .array(["application/json"])) + XCTAssertNil(events.first?.metadata["http.request.header.authorization"]) + XCTAssertEqual(events.last?.metadata["http.response.header.content-type"], .array(["application/json"])) + XCTAssertNil(events.last?.metadata["http.response.header.set-cookie"]) + } + + func testExcludingNothingCapturesEveryField() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: .exclude([])), + request: HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: [.accept: "application/json"]) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata["http.request.header.accept"], .array(["application/json"])) + } + + func testIncludingNothingCapturesNoField() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: .include([])), + request: HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: [.accept: "application/json"]) + ) { _, _, _ in (HTTPResponse(status: .ok), nil) } + + XCTAssertEqual(events.first?.metadata.keys.filter { $0.contains(".header.") }, []) + } + + func testAllCapturesEveryField() async throws { + let events = try await recordedEvents( + middleware: .init(requestHeaders: .all, responseHeaders: .all), + request: HTTPRequest( + soar_path: "/api/pets", + method: .get, + headerFields: [.accept: "application/json", .userAgent: "Test/1.0"] + ) + ) { _, _, _ in + (HTTPResponse(status: .ok, headerFields: [.contentType: "application/json", .eTag: "\"abc\""]), nil) + } + + XCTAssertEqual(events.first?.metadata["http.request.header.accept"], .array(["application/json"])) + // The `User-Agent` field is reported as `user_agent.original`, and, having been asked for + // explicitly, as a header attribute too. + XCTAssertEqual(events.first?.metadata["user_agent.original"], "Test/1.0") + XCTAssertEqual(events.first?.metadata["http.request.header.user-agent"], .array(["Test/1.0"])) + XCTAssertEqual(events.last?.metadata["http.response.header.content-type"], .array(["application/json"])) + XCTAssertEqual(events.last?.metadata["http.response.header.etag"], .array(["\"abc\""])) + } + + func testNoAttributesPropagateToTheHandler() async throws { + let sink = RecordingLogHandler.Sink() + _ = try await sink.record { + try await ServerOTelLoggingMiddleware(requestHeaders: .all) + .intercept( + HTTPRequest(soar_path: "/api/pets", method: .get, headerFields: [.accept: "application/json"]), + body: nil, + metadata: .init(), + operationID: "getPets", + next: { _, _, _ in + Logger.current.info("Handling") + return (HTTPResponse(status: .ok), nil) + } + ) + } + + // The middleware annotates its own records only; the handler keeps the logger it was given, + // and correlating the records is left to the trace identifiers. + let handlerEvent = try XCTUnwrap(sink.events.first { $0.message == "Handling" }) + XCTAssertEqual(handlerEvent.metadata, [:]) + } + + func testUnknownAndAbsentBodyLengthOmitSize() async throws { + let streamedBody = HTTPBody(ArraySlice([0x7b, 0x7d]), length: .unknown) + let request = HTTPRequest(soar_path: "/api/pets", method: .get) + let events = try await recordedEvents(request: request, body: nil) { _, _, _ in + (HTTPResponse(status: .ok), streamedBody) + } + + XCTAssertNil(events.first?.metadata["http.request.body.size"]) + XCTAssertNil(events.last?.metadata["http.response.body.size"]) + } + + func testEmptyBodyReportsZeroSize() async throws { + let events = try await recordedEvents( + request: HTTPRequest(soar_path: "/api/pets", method: .post), + body: HTTPBody() + ) { _, _, _ in (HTTPResponse(status: .ok), HTTPBody()) } + + XCTAssertEqual(events.first?.metadata["http.request.body.size"], "0") + XCTAssertEqual(events.last?.metadata["http.response.body.size"], "0") + } + + func testErrorPath() async throws { + let sink = RecordingLogHandler.Sink() + do { + _ = try await sink.record { + try await ServerOTelLoggingMiddleware() + .intercept( + HTTPRequest(soar_path: "/api/pets", method: .get), + body: nil, + metadata: .init(), + operationID: "getPets", + next: { _, _, _ in throw TestError() } + ) + } + XCTFail("Expected the middleware to rethrow.") + } catch is TestError {} + + let events = sink.events + XCTAssertEqual(events.map(\.message), ["HTTP Server Request", "HTTP Server Request Error"]) + XCTAssertEqual(events.last?.level, .error) + XCTAssertNil(events.last?.metadata["http.response.status_code"]) + XCTAssertTrue(events.last?.error is TestError) + + // The failure record keeps the full request context; the error is carried by the record + // itself rather than by an attribute. + let requestAttributes = try XCTUnwrap(events.first?.metadata) + XCTAssertEqual(events.last?.metadata, requestAttributes) + } + + // MARK: - Helpers + + private func recordedEvents( + middleware: ServerOTelLoggingMiddleware = .init(), + request: HTTPRequest, + body: HTTPBody? = nil, + operationID: String = "getPets", + next: + @escaping @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> ( + HTTPResponse, HTTPBody? + ) + ) async throws -> [RecordingLogHandler.Event] { + let sink = RecordingLogHandler.Sink() + _ = try await sink.record { + try await middleware.intercept(request, body: body, metadata: .init(), operationID: operationID, next: next) + } + return sink.events + } +} + +#endif