Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
),
]
)
Expand Down
2 changes: 2 additions & 0 deletions Sources/OpenAPIRuntime/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
149 changes: 149 additions & 0 deletions Sources/OpenAPIRuntime/Middleware/ClientOTelLoggingMiddleware.swift
Original file line number Diff line number Diff line change
@@ -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.<name>` | 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.<name>` (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
88 changes: 88 additions & 0 deletions Sources/OpenAPIRuntime/Middleware/OTelCapturedHeaders.swift
Original file line number Diff line number Diff line change
@@ -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<HTTPField.Name>)
case include(Set<HTTPField.Name>)
}

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<HTTPField.Name>) -> 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<HTTPField.Name>) -> 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<HTTPField.Name>

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
22 changes: 22 additions & 0 deletions Sources/OpenAPIRuntime/Middleware/OTelExperimentalHTTPKeys.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading