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
4 changes: 1 addition & 3 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ 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
TracingSupport.handleRequestTracingAttributes(span, request, configuration: self.tracing)
let response = try await body()

// set response span attributes
Expand Down
4 changes: 3 additions & 1 deletion Sources/AsyncHTTPClient/DeconstructedURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import struct FoundationEssentials.URL
import struct Foundation.URL
#endif

struct DeconstructedURL {
@usableFromInline
struct DeconstructedURL: Sendable {
var scheme: Scheme
var connectionTarget: ConnectionTarget
var uri: String
Expand All @@ -42,6 +43,7 @@ extension DeconstructedURL {
try self.init(url: url)
}

@usableFromInline
init(url: URL) throws {
guard let schemeString = url.scheme else {
throw HTTPClientError.emptyScheme
Expand Down
10 changes: 9 additions & 1 deletion Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1143,7 +1143,7 @@ public final class HTTPClient: Sendable {
}

/// 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.
/// This struct allows the configuration of the attribute names (keys) which will be used for the appropriate values.
@usableFromInline
package struct AttributeKeys: Sendable {
@usableFromInline package var requestMethod: String = "http.request.method"
Expand All @@ -1154,6 +1154,14 @@ public final class HTTPClient: Sendable {

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

@usableFromInline package var serverAddress: String = "server.address"
@usableFromInline package var serverPort: String = "server.port"

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

@usableFromInline package init() {}
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/AsyncHTTPClient/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ extension HTTPClient {
public var tlsConfiguration: TLSConfiguration?

/// Parsed, validated and deconstructed URL.
@usableFromInline
let deconstructedURL: DeconstructedURL

/// Create HTTP request.
Expand Down
3 changes: 3 additions & 0 deletions Sources/AsyncHTTPClient/RequestBag+Tracing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ extension RequestBag.LoopBoundState {
"Unexpected active span when starting new request span! Was: \(String(describing: self.activeSpan))"
)
self.activeSpan = tracer.startSpan("\(request.method)", ofKind: .client)
if let activeSpan {
TracingSupport.handleRequestTracingAttributes(activeSpan, request, configuration: tracing)
}
}

/// Fails the active overall span given some internal error, e.g. timeout, pool shutdown etc.
Expand Down
75 changes: 75 additions & 0 deletions Sources/AsyncHTTPClient/TracingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,85 @@ import NIOHTTP1
import NIOSSL
import Tracing

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

// MARK: - Centralized span attribute handling

@usableFromInline
struct TracingSupport {
@inlinable
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
static func handleRequestTracingAttributes(
_ span: Span,
_ request: HTTPClientRequest,
configuration: HTTPClient.TracingConfiguration
) {
let requestBodySize: Int? =
switch request.body?.mode {
case .asyncSequence(.known(let length), _), .sequence(.known(let length), _, _):
Int(length)
case .byteBuffer(let byteBuffer):
byteBuffer.readableBytes
case .asyncSequence(.unknown, _), .sequence(.unknown, _, _), nil:
nil
}
let url = URL(string: request.url)
let deconstructedURL: DeconstructedURL? =
if let url {
try? DeconstructedURL(url: url)
} else {
nil
}
handleRequestTracingAttributes(
span,
requestMethod: request.method.rawValue,
url: url,
deconstructedURL: deconstructedURL,
requestBodySize: requestBodySize,
configuration: configuration
)
}

@inlinable
static func handleRequestTracingAttributes(
_ span: Span,
_ request: HTTPClient.Request,
configuration: HTTPClient.TracingConfiguration
) {
handleRequestTracingAttributes(
span,
requestMethod: request.method.rawValue,
url: request.url,
deconstructedURL: request.deconstructedURL,
requestBodySize: request.body?.contentLength.map { Int($0) },
configuration: configuration
)
}

@usableFromInline
static func handleRequestTracingAttributes(
_ span: Span,
requestMethod: String,
url: URL?,
deconstructedURL: DeconstructedURL?,
requestBodySize: Int?,
configuration: HTTPClient.TracingConfiguration
) {
let keys = configuration.attributeKeys
span.attributes[keys.requestMethod] = requestMethod
span.attributes[keys.urlScheme] = deconstructedURL?.scheme.rawValue
span.attributes[keys.serverAddress] = deconstructedURL?.connectionTarget.host
span.attributes[keys.serverPort] = deconstructedURL?.connectionTarget.port
span.attributes[keys.requestBodySize] = requestBodySize
span.attributes[keys.urlPath] = url?.path
span.attributes[keys.fullUrl] = url?.absoluteString
span.attributes[keys.urlQuery] = url?.query
}

@inlinable
static func handleResponseStatusCode(
_ span: Span,
Expand Down
65 changes: 45 additions & 20 deletions Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,19 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {
XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)")
return
}
guard let span = tracer.finishedSpans.first else {
XCTFail("No span was recorded!")
return
}
let span = try XCTUnwrap(tracer.finishedSpans.first, "No span was recorded!")

XCTAssertEqual(span.operationName, "GET")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestMethod), "GET")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlScheme), "http")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlPath), "/echo-method")
XCTAssertNotNil(span.attributes.get(client.tracing.attributeKeys.serverAddress))
XCTAssertEqual(
span.attributes.get(client.tracing.attributeKeys.serverPort),
SpanAttribute.int64(Int64(self.defaultHTTPBin.port))
)
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.fullUrl), "\(url)")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestBodySize), nil)
}

func testTrace_post_sync() throws {
Expand All @@ -86,12 +93,14 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {
XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)")
return
}
guard let span = tracer.finishedSpans.first else {
XCTFail("No span was recorded!")
return
}
let span = try XCTUnwrap(tracer.finishedSpans.first, "No span was recorded!")

XCTAssertEqual(span.operationName, "POST")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestMethod), "POST")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlScheme), "http")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlPath), "/echo-method")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.fullUrl), "\(url)")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestBodySize), nil)
}

func testTrace_post_sync_404_error() throws {
Expand All @@ -102,10 +111,7 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {
XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)")
return
}
guard let span = tracer.finishedSpans.first else {
XCTFail("No span was recorded!")
return
}
let span = try XCTUnwrap(tracer.finishedSpans.first, "No span was recorded!")

XCTAssertEqual(span.operationName, "POST")
XCTAssertTrue(span.errors.isEmpty, "Should have recorded error")
Expand All @@ -121,12 +127,16 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {
XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)")
return
}
guard let span = tracer.finishedSpans.first else {
XCTFail("No span was recorded!")
return
}
let span = try XCTUnwrap(tracer.finishedSpans.first, "No span was recorded!")

XCTAssertEqual(span.operationName, "GET")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestMethod), "GET")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlScheme), "http")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlPath), "/echo-method")
XCTAssertNotNil(span.attributes.get(client.tracing.attributeKeys.serverAddress))
XCTAssertNotNil(span.attributes.get(client.tracing.attributeKeys.serverPort))
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.fullUrl), "\(url)")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestBodySize), nil)
}

func testTrace_execute_async_404_error() async throws {
Expand All @@ -138,13 +148,28 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass {
XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)")
return
}
guard let span = tracer.finishedSpans.first else {
XCTFail("No span was recorded!")
return
}
let span = try XCTUnwrap(tracer.finishedSpans.first, "No span was recorded!")

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

func testTrace_record_request_body_size_async() async throws {
let url = self.defaultHTTPBinURLPrefix + "echo-method"
var request = HTTPClientRequest(url: url)
request.body = .bytes(ByteBuffer(string: "test"))
let _ = try await client.execute(request, deadline: .distantFuture)

guard tracer.activeSpans.isEmpty else {
XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)")
return
}
let span = try XCTUnwrap(tracer.finishedSpans.first, "No span was recorded!")

XCTAssertEqual(span.operationName, "GET")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestMethod), "GET")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.urlPath), "/echo-method")
XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.requestBodySize), 4)
}
}
Loading