Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
28 changes: 24 additions & 4 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//===----------------------------------------------------------------------===//

import NIOHTTP1
import OTelSemanticConventions
import Tracing

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
Expand All @@ -27,13 +28,32 @@ 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 request headers
for header in request.headers {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does OTel recommend redacting any headers? For example, should this include Authorization/Cookie headers?

Copy link
Copy Markdown
Author

@candiun candiun Jan 23, 2026

Choose a reason for hiding this comment

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

Yes and thanks for the question. It seems like OTel does not recommend capturing anything by default

Instrumentations SHOULD require an explicit configuration of which headers are to be captured. Including all request headers can be a security risk - explicit configuration helps avoid leaking sensitive information.

I have changed the implementation to an opt-in array residing inside TracingConfiguration.

One thing I am unsure about is how these headers would be configured in frameworks like Vapor for instance where a default client is supplied in the Request object. Maybe in that case the configuration would be easier once swift-configuration is adopted? For newly instantiated clients that should be easier, though.

span.attributes.http.request.header.set(header.name, to: [header.value])
}

// set url attributes
if let deconstructedURL = try? DeconstructedURL(url: request.url) {
span.attributes.url.path = deconstructedURL.uri
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The query (part of the path, I believe here?) could also include a secret token, but it's tricky to try to filter it out as it can have any name.

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 have changed the implementation to redact query names based on a list inside TracingConfiguration.

OTel says:

Query string values for the following keys SHOULD be redacted by default and replaced by the value REDACTED:

[AWSAccessKeyId](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth)
[Signature](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth)
[sig](https://learn.microsoft.com/azure/storage/common/storage-sas-overview#sas-token)
[X-Goog-Signature](https://cloud.google.com/storage/docs/access-control/signed-urls)

So I have added them by default to the redaction list, with the possibility to extend it upon creating a client.

span.attributes.url.scheme = deconstructedURL.scheme.rawValue
span.attributes.server.address = deconstructedURL.connectionTarget.host
span.attributes.server.port = deconstructedURL.connectionTarget.port
}

let response = try await body()

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

for header in response.headers {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same question as on the request side, I wonder if we should redact or remove Set-Cookie here.

Copy link
Copy Markdown
Author

@candiun candiun Jan 23, 2026

Choose a reason for hiding this comment

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

Response headers are treated now the same way as request headers and are filtered through an allow list. I wasn't sure whether keeping two separate (one for request, other for response) would be beneficial, so I stayed with one

span.attributes.http.response.header.set(header.name, to: [header.value])
}

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

return response
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/AsyncHTTPClient/ConnectionTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

import enum NIOCore.SocketAddress

enum ConnectionTarget: Equatable, Hashable {
@usableFromInline
enum ConnectionTarget: Equatable, Hashable, Sendable {
// We keep the IP address serialization precisely as it is in the URL.
// Some platforms have quirks in their implementations of 'ntop', for example
// writing IPv6 addresses as having embedded IPv4 sections (e.g. [::192.168.0.1] vs [::c0a8:1]).
Expand Down Expand Up @@ -44,6 +45,7 @@ enum ConnectionTarget: Equatable, Hashable {
extension ConnectionTarget {
/// The host name which will be send as an HTTP `Host` header.
/// Only returns nil if the `self` is a `unixSocket`.
@usableFromInline
var host: String? {
switch self {
case .ipAddress(let serialization, _): return serialization
Expand All @@ -54,6 +56,7 @@ extension ConnectionTarget {

/// The host name which will be send as an HTTP host header.
/// Only returns nil if the `self` is a `unixSocket`.
@usableFromInline
var port: Int? {
switch self {
case .ipAddress(_, let address): return address.port!
Expand Down
8 changes: 7 additions & 1 deletion Sources/AsyncHTTPClient/DeconstructedURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@

import struct Foundation.URL

struct DeconstructedURL {
@usableFromInline
struct DeconstructedURL: Sendable {
@usableFromInline
var scheme: Scheme
@usableFromInline
var connectionTarget: ConnectionTarget
@usableFromInline
var uri: String

@usableFromInline
init(
scheme: Scheme,
connectionTarget: ConnectionTarget,
Expand All @@ -31,6 +36,7 @@ struct DeconstructedURL {
}

extension DeconstructedURL {
@usableFromInline
init(url: String) throws {
guard let url = URL(string: url) else {
throw HTTPClientError.invalidURL
Expand Down
21 changes: 0 additions & 21 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1089,33 +1089,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
3 changes: 2 additions & 1 deletion Sources/AsyncHTTPClient/Scheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
//===----------------------------------------------------------------------===//

/// List of schemes `HTTPClient` currently supports
enum Scheme: String {
@usableFromInline
enum Scheme: String, Sendable {
case http
case https
case unix
Expand Down
7 changes: 4 additions & 3 deletions Sources/AsyncHTTPClient/TracingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 +27,13 @@ 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)
}
}

Expand Down
5 changes: 3 additions & 2 deletions Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import OTelSemanticConventions
import Tracing
import XCTest

Expand Down Expand Up @@ -109,7 +110,7 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {

XCTAssertEqual(span.operationName, "POST")
XCTAssertTrue(span.errors.isEmpty, "Should have recorded error")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404)
XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 404)
}

func testTrace_execute_async() async throws {
Expand Down Expand Up @@ -145,6 +146,6 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {

XCTAssertEqual(span.operationName, "GET")
XCTAssertTrue(span.errors.isEmpty, "Should have recorded error")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404)
XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 404)
}
}