Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"),
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.3.0"),
.package(url: "https://github.com/swift-otel/swift-otel-semantic-conventions.git", from: "1.39.0"),
],
targets: [
.target(
Expand Down Expand Up @@ -72,6 +73,7 @@ let package = Package(
// Observability support
.product(name: "Logging", package: "swift-log"),
.product(name: "Tracing", package: "swift-distributed-tracing"),
.product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions"),
],
swiftSettings: strictConcurrencySettings
),
Expand All @@ -94,6 +96,7 @@ let package = Package(
.product(name: "Logging", package: "swift-log"),
.product(name: "InMemoryLogging", package: "swift-log"),
.product(name: "Tracing", package: "swift-distributed-tracing"),
.product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions"),
.product(name: "InMemoryTracing", package: "swift-distributed-tracing"),
],
resources: [
Expand Down
61 changes: 57 additions & 4 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
//===----------------------------------------------------------------------===//

import NIOHTTP1
import OTelSemanticConventions
import Tracing
import struct Foundation.URL

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
extension HTTPClient {
Expand All @@ -27,13 +29,64 @@ extension HTTPClient {
}

return try await tracer.withSpan(request.method.rawValue, ofKind: .client) { span in
let keys = self.configuration.tracing.attributeKeys
span.attributes[keys.requestMethod] = request.method.rawValue
// TODO: set more attributes on the span
span.attributes.http.request.method = .init(rawValue: request.method.rawValue)

// set explicitly allowed request headers
let allowedRequestHeaderNames = Set(request.headers.map(\.name)).intersection(configuration.tracing.allowedHeaders)

for headerName in allowedRequestHeaderNames {
let values = request.headers[headerName]

if !values.isEmpty {
span.attributes.http.request.header.set(headerName, to: values)
}
}

// set url attributes
if let url = URL(string: request.url) {
span.attributes.url.path = TracingSupport.sanitizePath(
url.path,
redactionComponents: self.configuration.tracing.sensitivePathComponents
)

if let scheme = url.scheme {
span.attributes.url.scheme = scheme
}
if let query = url.query {
span.attributes.url.query = TracingSupport.sanitizeQuery(
query,
redactionComponents: self.configuration.tracing.sensitiveQueryComponents
)
}
if let fragment = url.fragment {
span.attributes.url.fragment = fragment
}
if let host = url.host {
span.attributes.server.address = host
}
if let port = url.port {
span.attributes.server.port = port
}
}

let response = try await body()

// set response span attributes
TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys)
TracingSupport.handleResponseStatusCode(span, response.status)

// set explicitly allowed response headers
let allowedResponseHeaderNames = Set(response.headers.map(\.name)).intersection(configuration.tracing.allowedHeaders)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above, re making this less computational heavy.


for headerName in allowedResponseHeaderNames {
let values = response.headers[headerName]

if !values.isEmpty {
span.attributes.http.response.header.set(headerName, to: values)
}
}

// set network protocol version
span.attributes.network.protocol.version = "\(response.version.major).\(response.version.minor)"

return response
}
Expand Down
30 changes: 9 additions & 21 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,15 @@ public final class HTTPClient: Sendable {
@usableFromInline
var _tracer: Optional<any Sendable> // erasure trick so we don't have to make Configuration @available

public var allowedHeaders: Set<String> = []
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowed headers is one way, though I wonder if it should be "redacted headers" instead, similar to the path and query items below.

Copy link
Copy Markdown
Author

@candiun candiun Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would work well when it comes to request headers. But I'm thinking if for the cases like the response headers, where we're sometimes communicating with an external services that we have no control over, whether this wouldn't lead to leaking some secrets of custom names, either at the current time, or in case that service evolves and starts sending other headers in response. Maybe in that case ignoring everything unless explicitly allowed would be safer, what do you think?

public var sensitivePathComponents: Set<String> = []
public var sensitiveQueryComponents: Set<String> = [
"AWSAccessKeyId",
"Signature",
"sig",
"X-Goog-Signature"
]

/// Tracer that should be used by the HTTPClient.
///
/// This is selected at configuration creation time, and if no tracer is passed explicitly,
Expand All @@ -1089,33 +1098,12 @@ public final class HTTPClient: Sendable {
}
}

// TODO: Open up customization of keys we use?
/// Configuration for tracing attributes set by the HTTPClient.
@usableFromInline
package var attributeKeys: AttributeKeys

public init() {
if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) {
self._tracer = InstrumentationSystem.tracer
} else {
self._tracer = nil
}
self.attributeKeys = .init()
}

/// Span attribute keys that the HTTPClient should set automatically.
/// This struct allows the configuration of the attribute names (keys) which will be used for the apropriate values.
@usableFromInline
package struct AttributeKeys: Sendable {
@usableFromInline package var requestMethod: String = "http.request.method"
@usableFromInline package var requestBodySize: String = "http.request.body.size"

@usableFromInline package var responseBodySize: String = "http.response.body.size"
@usableFromInline package var responseStatusCode: String = "http.status_code"

@usableFromInline package var httpFlavor: String = "http.flavor"

@usableFromInline package init() {}
Comment on lines -1130 to -1142
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of those options and their use being removed. The goal here is to allow users to change the attribute keys.

}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/AsyncHTTPClient/RequestBag+Tracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ extension RequestBag.LoopBoundState {
return
}

TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys)
TracingSupport.handleResponseStatusCode(span, response.status)

span.end()
self.activeSpan = nil
Expand Down
30 changes: 27 additions & 3 deletions Sources/AsyncHTTPClient/TracingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
//
//===----------------------------------------------------------------------===//

import Foundation
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we had a change so that we only rely on FoundationEssentials.

import Logging
import NIOConcurrencyHelpers
import NIOCore
import NIOHTTP1
import NIOSSL
import OTelSemanticConventions
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of adding another package dependency just to import some Strings. As the OTEL strings should never change, we can just have those strings hardcoded here in this package.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the dependency and brought back the keys

import Tracing

// MARK: - Centralized span attribute handling
Expand All @@ -26,13 +28,35 @@ struct TracingSupport {
@inlinable
static func handleResponseStatusCode(
_ span: Span,
_ status: HTTPResponseStatus,
keys: HTTPClient.TracingConfiguration.AttributeKeys
_ status: HTTPResponseStatus
) {
if status.code >= 400 {
span.setStatus(.init(code: .error))
}
span.attributes[keys.responseStatusCode] = SpanAttribute.int64(Int64(status.code))

span.attributes.http.response.statusCode = Int(status.code)
}

@inlinable
static func sanitizePath(_ path: String, redactionComponents: Set<String>) -> String {
redactionComponents.reduce(path) { path, component in
path.replacingOccurrences(of: component, with: "REDACTED")
}
}

@inlinable
static func sanitizeQuery(_ query: String, redactionComponents: Set<String>) -> String {
query.components(separatedBy: "&").map {
let nameAndValue = $0
.trimmingCharacters(in: .whitespaces)
.components(separatedBy: "=")

if redactionComponents.contains(nameAndValue[0]) {
return "\(nameAndValue[0])=REDACTED"
}

return $0
}.joined(separator: "&")
}
}

Expand Down
Loading