Skip to content
Open
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
74 changes: 73 additions & 1 deletion Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
import NIOHTTP1
import Tracing

#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
extension HTTPClient {
@inlinable
Expand All @@ -28,14 +34,80 @@ extension HTTPClient {

return try await tracer.withSpan(request.method.rawValue, ofKind: .client) { span in
let keys = self.configuration.tracing.attributeKeys
let allowedHeaders = Set(self.configuration.tracing.allowedHeaders.map { $0.lowercased() })
span.attributes[keys.requestMethod] = request.method.rawValue
// TODO: set more attributes on the span

// set explicitly allowed request headers
var allowedRequestHeaders: [String: [String]] = [:]

for header in request.headers {
guard allowedHeaders.contains(header.name.lowercased()) else {
continue
}
let normalizedHeaderName = normalizedTracingHeaderName(header.name)
allowedRequestHeaders[normalizedHeaderName, default: []].append(header.value)
}

for (headerName, values) in allowedRequestHeaders {
span.attributes["\(keys.requestHeader).\(headerName)"] = values
}

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

if let scheme = url.scheme {
span.attributes[keys.urlScheme] = scheme
}
if let query = url.query {
span.attributes[keys.urlQuery] = TracingSupport.sanitizeQuery(
query,
redactionComponents: self.configuration.tracing.sensitiveQueryComponents
)
}
if let fragment = url.fragment {
span.attributes[keys.urlFragment] = fragment
}
if let host = url.host {
span.attributes[keys.serverHostname] = host
}
if let port = url.port {
span.attributes[keys.serverPort] = port
}
}

let response = try await body()

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

// set explicitly allowed response headers
var allowedResponseHeaders: [String: [String]] = [:]

for header in response.headers {
guard allowedHeaders.contains(header.name.lowercased()) else {
continue
}
let normalizedHeaderName = normalizedTracingHeaderName(header.name)
allowedResponseHeaders[normalizedHeaderName, default: []].append(header.value)
}

for (headerName, values) in allowedResponseHeaders {
span.attributes["\(keys.responseHeader).\(headerName)"] = values
}

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

return response
}
}

@inlinable
func normalizedTracingHeaderName(_ name: String) -> String {
name.lowercased().replacingOccurrences(of: "-", with: "_")
}
}
25 changes: 22 additions & 3 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,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 Down Expand Up @@ -1133,12 +1142,22 @@ public final class HTTPClient: Sendable {
package struct AttributeKeys: Sendable {
@usableFromInline package var requestMethod: String = "http.request.method"
@usableFromInline package var requestBodySize: String = "http.request.body.size"

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

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

@usableFromInline package var networkProtocolVersion: String = "network.protocol.version"

@usableFromInline package var urlPath: String = "url.path"
@usableFromInline package var urlScheme: String = "url.scheme"
@usableFromInline package var urlQuery: String = "url.query"
@usableFromInline package var urlFragment: String = "url.fragment"

@usableFromInline package var serverHostname: String = "server.hostname"
@usableFromInline package var serverPort: String = "server.port"

@usableFromInline package init() {}
}
}
Expand Down
29 changes: 29 additions & 0 deletions Sources/AsyncHTTPClient/TracingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import NIOHTTP1
import NIOSSL
import Tracing

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

// MARK: - Centralized span attribute handling

@usableFromInline
Expand All @@ -32,8 +38,31 @@ struct TracingSupport {
if status.code >= 400 {
span.setStatus(.init(code: .error))
}

span.attributes[keys.responseStatusCode] = SpanAttribute.int64(Int64(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: "&")
}
}

// MARK: - HTTPHeadersInjector
Expand Down
212 changes: 212 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

@_spi(Tracing) import AsyncHTTPClient // NOT @testable - tests that need @testable go into HTTPClientTracingInternalTests.swift
import InMemoryTracing
import NIOCore
import NIOHTTP1
import Testing
import Tracing

@Suite("HTTPClient tracing attributes")
struct HTTPClientTracingAttributeTests {
@Test func traceAttributesURL() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
let httpClient = HTTPClient(
eventLoopGroupProvider: .singleton,
configuration: makeConfiguration(tracer: tracer)
)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)
let keys = HTTPClient.TracingConfiguration.AttributeKeys()

#expect(span.attributes.get(keys.urlPath) == "/echo-method")
#expect(span.attributes.get(keys.urlScheme) == "http")
#expect(span.attributes.get(keys.urlQuery) == "foo=bar&Signature=REDACTED")
}

@Test func traceAttributesServer() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
let httpClient = HTTPClient(
eventLoopGroupProvider: .singleton,
configuration: makeConfiguration(tracer: tracer)
)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)
let defaultHTTPBinPort = try #require(httpBin.socketAddress.port)
let defaultHTTPBinAddress = try #require(httpBin.socketAddress.ipAddress)
let keys = HTTPClient.TracingConfiguration.AttributeKeys()

#expect(span.attributes.get(keys.serverHostname) == .string(defaultHTTPBinAddress.description))
#expect(span.attributes.get(keys.serverPort) == .int64(Int64(defaultHTTPBinPort)))
}

@Test func traceAttributesHTTP() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
var configuration = makeConfiguration(tracer: tracer)
configuration.tracing.allowedHeaders = ["Authorization"]
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)
let keys = HTTPClient.TracingConfiguration.AttributeKeys()

#expect(span.attributes.get(keys.requestMethod) == "GET")
#expect(span.attributes.get("\(keys.requestHeader).authorization") == .stringArray(["Bearer secret"]))
#expect(span.attributes.get("\(keys.requestHeader).password") == nil)
#expect(span.attributes.get(keys.responseStatusCode) == 200)
}

@Test func traceAttributesPathRedaction() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
var configuration = makeConfiguration(tracer: tracer)
configuration.tracing.sensitivePathComponents = ["nested-path"]
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method/nested-path")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)
let keys = HTTPClient.TracingConfiguration.AttributeKeys()

#expect(span.attributes.get(keys.urlPath) == "/echo-method/REDACTED")
}

@Test func traceAttributesQueryRedaction() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
var configuration = makeConfiguration(tracer: tracer)
configuration.tracing.sensitiveQueryComponents.insert("foo")
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature&bar=bar")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)
let keys = HTTPClient.TracingConfiguration.AttributeKeys()

#expect(span.attributes.get(keys.urlQuery) == "foo=REDACTED&Signature=REDACTED&bar=bar")
}

@Test func traceAttributesHTTPHeadersDisallowedByDefault() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
let httpClient = HTTPClient(
eventLoopGroupProvider: .singleton,
configuration: makeConfiguration(tracer: tracer)
)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)

#expect(span.operationName == "GET")
#expect(span.attributes.get("http.request.header.authorization") == nil)
#expect(span.attributes.get("http.request.header.password") == nil)
}

@Test func traceAttributesHTTPHeaders() async throws {
let httpBin = HTTPBin()
defer { #expect(throws: Never.self) { try httpBin.shutdown() } }

let tracer = InMemoryTracer()
var configuration = makeConfiguration(tracer: tracer)
configuration.tracing.allowedHeaders = ["Authorization", "Password", "X-Method-Used"]
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration)
defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } }

let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature")
_ = try await httpClient.execute(request, deadline: .distantFuture)

#expect(
tracer.activeSpans.isEmpty,
"Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)"
)
let span = try #require(tracer.finishedSpans.first)

#expect(span.operationName == "GET")
#expect(span.attributes.get("http.request.header.authorization") == .stringArray(["Bearer secret"]))
#expect(span.attributes.get("http.request.header.password") == .stringArray(["SuperSecretPassword"]))
#expect(span.attributes.get("http.response.header.x_method_used") == .stringArray(["GET"]))
}

private func makeConfiguration(tracer: InMemoryTracer) -> HTTPClient.Configuration {
var configuration = HTTPClient.Configuration()
configuration.httpVersion = .automatic
configuration.tracing.tracer = tracer
return configuration
}

private func makeRequest(url: String) -> HTTPClientRequest {
var request = HTTPClientRequest(url: url)
request.headers.add(name: "Authorization", value: "Bearer secret")
request.headers.add(name: "Password", value: "SuperSecretPassword")
return request
}
}