-
Notifications
You must be signed in to change notification settings - Fork 122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Trace HTTPClient request execution #320
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,8 @@ | |
import Logging | ||
import NIOCore | ||
import NIOHTTP1 | ||
import ServiceContextModule | ||
import Tracing | ||
|
||
import struct Foundation.URL | ||
|
||
|
@@ -30,18 +32,20 @@ extension HTTPClient { | |
public func execute( | ||
_ request: HTTPClientRequest, | ||
deadline: NIODeadline, | ||
logger: Logger? = nil | ||
logger: Logger? = nil, | ||
context: ServiceContext? = nil | ||
) async throws -> HTTPClientResponse { | ||
try await self.executeAndFollowRedirectsIfNeeded( | ||
request, | ||
deadline: deadline, | ||
logger: logger ?? Self.loggingDisabled, | ||
context: context ?? .current ?? .topLevel, | ||
redirectState: RedirectState(self.configuration.redirectConfiguration.mode, initialURL: request.url) | ||
) | ||
} | ||
} | ||
|
||
// MARK: Connivence methods | ||
// MARK: Convenience methods | ||
|
||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) | ||
extension HTTPClient { | ||
|
@@ -55,12 +59,14 @@ extension HTTPClient { | |
public func execute( | ||
_ request: HTTPClientRequest, | ||
timeout: TimeAmount, | ||
logger: Logger? = nil | ||
logger: Logger? = nil, | ||
context: ServiceContext? = nil | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here re re-adding the previous variant for backwards compatibility. |
||
) async throws -> HTTPClientResponse { | ||
try await self.execute( | ||
request, | ||
deadline: .now() + timeout, | ||
logger: logger | ||
logger: logger, | ||
context: context | ||
) | ||
} | ||
} | ||
|
@@ -71,15 +77,23 @@ extension HTTPClient { | |
_ request: HTTPClientRequest, | ||
deadline: NIODeadline, | ||
logger: Logger, | ||
context: ServiceContext, | ||
redirectState: RedirectState? | ||
) async throws -> HTTPClientResponse { | ||
var currentRequest = request | ||
var currentRedirectState = redirectState | ||
var resendCount = 0 | ||
|
||
// this loop is there to follow potential redirects | ||
while true { | ||
let preparedRequest = try HTTPClientRequest.Prepared(currentRequest, dnsOverride: configuration.dnsOverride) | ||
let response = try await self.executeCancellable(preparedRequest, deadline: deadline, logger: logger) | ||
let response = try await self.executeCancellable( | ||
preparedRequest, | ||
deadline: deadline, | ||
logger: logger, | ||
context: context, | ||
resendCount: resendCount > 0 ? resendCount : nil | ||
) | ||
|
||
guard var redirectState = currentRedirectState else { | ||
// a `nil` redirectState means we should not follow redirects | ||
|
@@ -112,46 +126,76 @@ extension HTTPClient { | |
return response | ||
} | ||
|
||
resendCount += 1 | ||
currentRequest = newRequest | ||
} | ||
} | ||
|
||
private func executeCancellable( | ||
_ request: HTTPClientRequest.Prepared, | ||
deadline: NIODeadline, | ||
logger: Logger | ||
logger: Logger, | ||
context: ServiceContext, | ||
resendCount: Int? | ||
) async throws -> HTTPClientResponse { | ||
let cancelHandler = TransactionCancelHandler() | ||
|
||
return try await withTaskCancellationHandler( | ||
operation: { () async throws -> HTTPClientResponse in | ||
let eventLoop = self.eventLoopGroup.any() | ||
let deadlineTask = eventLoop.scheduleTask(deadline: deadline) { | ||
cancelHandler.cancel(reason: .deadlineExceeded) | ||
} | ||
defer { | ||
deadlineTask.cancel() | ||
} | ||
return try await withCheckedThrowingContinuation { | ||
(continuation: CheckedContinuation<HTTPClientResponse, Swift.Error>) -> Void in | ||
let transaction = Transaction( | ||
request: request, | ||
requestOptions: .fromClientConfiguration(self.configuration), | ||
logger: logger, | ||
connectionDeadline: .now() + (self.configuration.timeout.connectionCreationTimeout), | ||
preferredEventLoop: eventLoop, | ||
responseContinuation: continuation | ||
) | ||
|
||
cancelHandler.registerTransaction(transaction) | ||
|
||
self.poolManager.executeRequest(transaction) | ||
} | ||
}, | ||
onCancel: { | ||
cancelHandler.cancel(reason: .taskCanceled) | ||
return try await withSpan(request.head.method.rawValue, context: context, ofKind: .client) { span in | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM on using just the method as low cardinality name (matches otel well) 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could do that, yeah. Elsewhere we just use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unless there are downsides, I prefer the explicit dependency injection. Easy enough to debug and reason about. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good to me as long as we don't expose this initializer publicly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the downside of adding it to the public initializer? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It goes against the way Swift Distributed Tracing usually works, where a single tracer is chosen for the system and used transparently everywhere. Having said that, Logging also allows passing specific |
||
var request = request | ||
request.head.headers.propagate(span.context) | ||
span.updateAttributes { attributes in | ||
attributes["http.request.method"] = request.head.method.rawValue | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we also attach the path as a separate attribute, or is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Path may be nice tbh... it's not a "standardized" convention but I could see it be useful... Up to y'all if we want to duplicate; url.full seems to be the "expected one" to set otel wise... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I guess wouldn't hurt to add it, but I guess the client is expected not to have the full URL broken out into components, but the server does. I'm happy to keep it as-is, with just There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I think it's fine to just include https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client
What do you think about adding these? I'm a bit torn but I don't think we'd want to include these specific keys in |
||
attributes["server.address"] = request.poolKey.connectionTarget.host | ||
attributes["server.port"] = request.poolKey.connectionTarget.port | ||
attributes["url.full"] = request.url.absoluteString | ||
attributes["http.request.resend_count"] = resendCount | ||
} | ||
) | ||
|
||
do { | ||
let response = try await withTaskCancellationHandler( | ||
operation: { () async throws -> HTTPClientResponse in | ||
let eventLoop = self.eventLoopGroup.any() | ||
let deadlineTask = eventLoop.scheduleTask(deadline: deadline) { | ||
cancelHandler.cancel(reason: .deadlineExceeded) | ||
} | ||
defer { | ||
deadlineTask.cancel() | ||
} | ||
let response = try await withCheckedThrowingContinuation { | ||
(continuation: CheckedContinuation<HTTPClientResponse, Swift.Error>) -> Void in | ||
let transaction = Transaction( | ||
request: request, | ||
requestOptions: .fromClientConfiguration(self.configuration), | ||
logger: logger, | ||
connectionDeadline: .now() + (self.configuration.timeout.connectionCreationTimeout), | ||
preferredEventLoop: eventLoop, | ||
responseContinuation: continuation | ||
) | ||
|
||
cancelHandler.registerTransaction(transaction) | ||
|
||
self.poolManager.executeRequest(transaction) | ||
} | ||
if response.status.code >= 400 { | ||
span.setStatus(.init(code: .error)) | ||
span.attributes["error.type"] = "\(response.status.code)" | ||
} | ||
span.attributes["http.response.status_code"] = "\(response.status.code)" | ||
return response | ||
}, | ||
onCancel: { | ||
span.setStatus(.init(code: .error)) | ||
span.attributes["error.type"] = "\(CancellationError.self)" | ||
cancelHandler.cancel(reason: .taskCanceled) | ||
} | ||
) | ||
return response | ||
} catch { | ||
span.setStatus(.init(code: .error)) | ||
span.attributes["error.type"] = "\(type(of: error))" | ||
throw error | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this mean the span doesn't include the error description, and it's up to user code to attach it higher up the stack? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comes back to the entire "can we log errors" (not really since no idea what they contain) discussion we were having elsewhere recently. 😅 The otel guidance is the same tbh: https://opentelemetry.io/docs/specs/semconv/general/recording-errors/#recording-errors-on-spans but you'll notice it gets hand wavy with it with exception messages -- Java influenced wording there;
So that leaves us back at step 1 where we need to decide what is safe -- the "exception message" is not a thing in Swift after all, and "(error)" may or may not be safe... Unless we're able to confirm all errors that can be thrown here are "safe to be logged" and only from inside the HTTP infra and don't include request details it doesn't seem like we should log the "message" (that being the "(error)" in swift). I'm not sure if we're 100% confident about it here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsure, but I wouldn't want this PR to be blocked on that, I'm happy with the first iteration not including the message here. |
||
} | ||
} | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// This source file is part of the AsyncHTTPClient open source project | ||
// | ||
// Copyright (c) 2024 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 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
import Instrumentation | ||
import NIOHTTP1 | ||
import ServiceContextModule | ||
|
||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) | ||
extension HTTPHeaders { | ||
mutating func propagate(_ context: ServiceContext) { | ||
InstrumentationSystem.instrument.inject(context, into: &self, using: Self.injector) | ||
} | ||
|
||
private static let injector = HTTPHeadersInjector() | ||
} | ||
|
||
private struct HTTPHeadersInjector: Injector { | ||
func inject(_ value: String, forKey name: String, into headers: inout HTTPHeaders) { | ||
headers.add(name: name, value: value) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding a parameter is technically breaking, so we'll need one more variant of this function with the original signature and
@_disfavoredOverload
and a deprecation warning, calling out to this new method.