Skip to content

Commit f0aee83

Browse files
authored
async await http instrumentation (#849)
1 parent 3e77614 commit f0aee83

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

Sources/Instrumentation/URLSession/URLSessionInstrumentation.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,45 @@ public class URLSessionInstrumentation {
721721
requestMap[taskId]?.setRequest(request)
722722
}
723723

724+
// For iOS 15+/macOS 12+, handle async/await methods differently
725+
if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) {
726+
// Check if we can determine if this is an async/await call
727+
// For iOS 15/macOS 12, we can't use Task.basePriority, so we check other indicators
728+
var isAsyncContext = false
729+
730+
if #available(OSX 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) {
731+
isAsyncContext = Task.basePriority != nil
732+
} else {
733+
// For iOS 15/macOS 12, check if the task has no delegate and no session delegate
734+
// This is a heuristic that works for async/await methods
735+
isAsyncContext = task.delegate == nil &&
736+
(task.value(forKey: "session") as? URLSession)?.delegate == nil &&
737+
task.state != .running
738+
}
739+
740+
if isAsyncContext {
741+
// This is likely an async/await call
742+
let instrumentedRequest = URLSessionLogger.processAndLogRequest(request,
743+
sessionTaskId: taskId,
744+
instrumentation: self,
745+
shouldInjectHeaders: true)
746+
if let instrumentedRequest {
747+
task.setValue(instrumentedRequest, forKey: "currentRequest")
748+
}
749+
self.setIdKey(value: taskId, for: task)
750+
751+
// For async/await methods, we need to ensure the delegate is set
752+
// to capture the completion, but only if there's no existing delegate
753+
// AND no session delegate (session delegates are called for async/await too)
754+
if task.delegate == nil,
755+
task.state != .running,
756+
(task.value(forKey: "session") as? URLSession)?.delegate == nil {
757+
task.delegate = AsyncTaskDelegate(instrumentation: self, sessionTaskId: taskId)
758+
}
759+
return
760+
}
761+
}
762+
724763
if #available(OSX 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) {
725764
guard Task.basePriority != nil else {
726765
// If not inside a Task basePriority is nil
@@ -770,3 +809,32 @@ class FakeDelegate: NSObject, URLSessionTaskDelegate {
770809
func urlSession(_ session: URLSession, task: URLSessionTask,
771810
didCompleteWithError error: Error?) {}
772811
}
812+
813+
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
814+
class AsyncTaskDelegate: NSObject, URLSessionTaskDelegate {
815+
private weak var instrumentation: URLSessionInstrumentation?
816+
private let sessionTaskId: String
817+
818+
init(instrumentation: URLSessionInstrumentation, sessionTaskId: String) {
819+
self.instrumentation = instrumentation
820+
self.sessionTaskId = sessionTaskId
821+
super.init()
822+
}
823+
824+
func urlSession(_ session: URLSession, task: URLSessionTask,
825+
didCompleteWithError error: Error?) {
826+
guard let instrumentation = instrumentation else { return }
827+
828+
// Get the task ID that was set when the task was created
829+
let taskId = sessionTaskId
830+
831+
if let error = error {
832+
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
833+
URLSessionLogger.logError(error, dataOrFile: nil, statusCode: status,
834+
instrumentation: instrumentation, sessionTaskId: taskId)
835+
} else if let response = task.response {
836+
URLSessionLogger.logResponse(response, dataOrFile: nil,
837+
instrumentation: instrumentation, sessionTaskId: taskId)
838+
}
839+
}
840+
}

Tests/InstrumentationTests/URLSessionTests/URLSessionInstrumentationTests.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,9 @@ class URLSessionInstrumentationTests: XCTestCase {
509509
let string = String(decoding: data, as: UTF8.self)
510510
print(string)
511511

512+
// Note: These tests were passing incorrectly. The async/await methods
513+
// introduced in iOS 15/macOS 12 are NOT instrumented at all, which is what
514+
// testAsyncAwaitMethodsAreNotInstrumented demonstrates.
512515
XCTAssertTrue(URLSessionInstrumentationTests.checker.shouldInstrumentCalled)
513516
XCTAssertTrue(URLSessionInstrumentationTests.checker.nameSpanCalled)
514517
XCTAssertTrue(URLSessionInstrumentationTests.checker.spanCustomizationCalled)
@@ -810,4 +813,69 @@ class URLSessionInstrumentationTests: XCTestCase {
810813

811814
try await task.value
812815
}
816+
817+
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
818+
public func testAsyncAwaitMethodsDoNotCompleteSpans() async throws {
819+
let request = URLRequest(url: URL(string: "http://localhost:33333/success")!)
820+
821+
// Test data(for:) method - the new async/await API introduced in iOS 15
822+
let (data, response) = try await URLSession.shared.data(for: request)
823+
824+
guard let httpResponse = response as? HTTPURLResponse else {
825+
XCTFail("Response should be HTTPURLResponse")
826+
return
827+
}
828+
829+
XCTAssertEqual(httpResponse.statusCode, 200, "Request should succeed")
830+
XCTAssertNotNil(data, "Should receive data")
831+
832+
XCTAssertTrue(URLSessionInstrumentationTests.checker.receivedResponseCalled, "receivedResponse should be called")
833+
XCTAssertNotNil(URLSessionInstrumentationTests.requestCopy?.allHTTPHeaderFields?[W3CTraceContextPropagator.traceparent], "Headers are injected")
834+
XCTAssertTrue(URLSessionInstrumentationTests.checker.shouldInstrumentCalled, "shouldInstrument should be called")
835+
XCTAssertTrue(URLSessionInstrumentationTests.checker.createdRequestCalled, "createdRequest should be called")
836+
}
837+
838+
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
839+
public func testAsyncAwaitDownloadMethodsAreNotInstrumented() async throws {
840+
let url = URL(string: "http://localhost:33333/success")!
841+
842+
// Test download(from:) method
843+
let (fileURL, response) = try await URLSession.shared.download(from: url)
844+
845+
guard let httpResponse = response as? HTTPURLResponse else {
846+
XCTFail("Response should be HTTPURLResponse")
847+
return
848+
}
849+
850+
XCTAssertEqual(httpResponse.statusCode, 200, "Request should succeed")
851+
XCTAssertNotNil(fileURL, "Should receive file URL")
852+
853+
XCTAssertTrue(URLSessionInstrumentationTests.checker.shouldInstrumentCalled, "shouldInstrument should be called")
854+
XCTAssertTrue(URLSessionInstrumentationTests.checker.createdRequestCalled, "createdRequest should be called")
855+
XCTAssertTrue(URLSessionInstrumentationTests.checker.receivedResponseCalled, "receivedResponse should be called")
856+
857+
// Clean up downloaded file
858+
try? FileManager.default.removeItem(at: fileURL)
859+
}
860+
861+
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
862+
public func testAsyncAwaitUploadMethodsAreNotInstrumented() async throws {
863+
let url = URL(string: "http://localhost:33333/success")!
864+
let request = URLRequest(url: url)
865+
866+
// Test upload(for:from:) method
867+
let (data, response) = try await URLSession.shared.upload(for: request, from: Data())
868+
869+
guard let httpResponse = response as? HTTPURLResponse else {
870+
XCTFail("Response should be HTTPURLResponse")
871+
return
872+
}
873+
874+
XCTAssertEqual(httpResponse.statusCode, 200, "Request should succeed")
875+
XCTAssertNotNil(data, "Should receive response data")
876+
877+
XCTAssertTrue(URLSessionInstrumentationTests.checker.shouldInstrumentCalled, "shouldInstrument should be called")
878+
XCTAssertTrue(URLSessionInstrumentationTests.checker.createdRequestCalled, "createdRequest should be called")
879+
XCTAssertTrue(URLSessionInstrumentationTests.checker.receivedResponseCalled, "receivedResponse should be called")
880+
}
813881
}

0 commit comments

Comments
 (0)