-
Notifications
You must be signed in to change notification settings - Fork 134
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
RUM-2355 fix: Network Instrumentation #1588
Changes from 15 commits
3fd3c25
1143e1c
6f90704
7168a9c
7948805
7854ae2
1af3f9a
ec07865
00bf6cc
9c6ab65
519646f
ef7a846
b8a1eb5
5b43ff1
738d599
9e46d77
a45c90e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -132,34 +132,6 @@ internal class DatadogTestsObserver: NSObject, XCTestObservation { | |
|
||
If all above conditions are met, this failure might indicate a memory leak in the implementation. | ||
""" | ||
), | ||
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. Removing these checks seems like a back step - in case of a memory leak we will not able to catch any lingering swizzlings. A check something like if there is an instance of swizzler in memory or not will be helpful. something like func checkInstancesOfType(_ type: AnyClass) -> Bool {
let typeString = String(describing: type)
for bundle in Bundle.allBundles {
if let className = bundle.classNamed(typeString) {
// Check if an instance of the class exists
if class_getInstanceSize(className) > 0 {
return true
}
}
}
return false
} 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 is already there, see my comment. These checks were redundant, we are already asserting that no swizzling is left here. Failure will print the method signature, so we will know which sizzling is at fault. |
||
.init( | ||
assert: { URLSessionTaskDelegateSwizzler.isBinded == false }, | ||
problem: "No URLSessionTaskDelegate swizzling must be applied.", | ||
solution: """ | ||
Make sure all the binded delegates are unbinded by the end of test with `URLSessionTaskDelegateSwizzler.unbind(delegate:)`. | ||
""" | ||
), | ||
.init( | ||
assert: { URLSessionDataDelegateSwizzler.isBinded == false }, | ||
problem: "No URLSessionDataDelegate swizzling must be applied.", | ||
solution: """ | ||
Make sure all the binded delegates are unbinded by the end of test with `URLSessionDataDelegateSwizzler.unbind(delegate:)`. | ||
""" | ||
), | ||
.init( | ||
assert: { URLSessionTaskSwizzler.isBinded == false }, | ||
problem: "No URLSessionTask swizzling must be applied.", | ||
solution: """ | ||
Make sure all the binded delegates are unbinded by the end of test with `URLSessionTaskSwizzler.unbind()`. | ||
""" | ||
), | ||
.init( | ||
assert: { URLSessionSwizzler.isBinded == false }, | ||
problem: "No URLSession swizzling must be applied.", | ||
solution: """ | ||
Make sure all the binded delegates are unbinded by the end of test with `URLSessionSwizzler.unbind()`. | ||
""" | ||
) | ||
] | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -39,20 +39,16 @@ public final class ReadWriteLock<Value> { | |
defer { pthread_rwlock_unlock(&rwlock) } | ||
return value | ||
} | ||
set { | ||
pthread_rwlock_wrlock(&rwlock) | ||
value = newValue | ||
pthread_rwlock_unlock(&rwlock) | ||
} | ||
set { mutate { $0 = newValue } } | ||
} | ||
|
||
/// Provides a non-escaping closure for mutation. | ||
/// The lock will be acquired once for writing before invoking the closure. | ||
/// | ||
/// - Parameter closure: The closure with the mutable value. | ||
public func mutate(_ closure: (inout Value) -> Void) { | ||
public func mutate(_ closure: (inout Value) throws -> Void) rethrows { | ||
pthread_rwlock_wrlock(&rwlock) | ||
closure(&value) | ||
pthread_rwlock_unlock(&rwlock) | ||
defer { pthread_rwlock_unlock(&rwlock) } | ||
try closure(&value) | ||
Comment on lines
-42
to
+52
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. Not related, but better :) |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,6 +38,9 @@ internal final class NetworkInstrumentationFeature: DatadogFeature { | |
@ReadWriteLock | ||
internal var handlers: [DatadogURLSessionHandler] = [] | ||
|
||
@ReadWriteLock | ||
private var swizzlers: [ObjectIdentifier: NetworkInstrumentationSwizzler] = [:] | ||
|
||
/// Maps `URLSessionTask` to its `TaskInterception` object. | ||
/// | ||
/// The interceptions **must** be accessed using the `queue`. | ||
|
@@ -49,80 +52,72 @@ internal final class NetworkInstrumentationFeature: DatadogFeature { | |
/// - Parameter configuration: The configuration to use for swizzling. | ||
/// Note: We are only concerned with type of the delegate here but to provide compile time safety, we | ||
/// use the instance of the delegate to get the type. | ||
internal func bindIfNeeded(configuration: URLSessionInstrumentation.Configuration) throws { | ||
internal func bind(configuration: URLSessionInstrumentation.Configuration) throws { | ||
let configuredFirstPartyHosts = FirstPartyHosts(firstPartyHosts: configuration.firstPartyHostsTracing) ?? .init() | ||
|
||
try URLSessionTaskDelegateSwizzler.bindIfNeeded( | ||
delegateClass: configuration.delegateClass, | ||
interceptDidFinishCollecting: { [weak self] session, task, metrics in | ||
self?.queue.async { [weak self, weak session] in | ||
self?._task(task, didFinishCollecting: metrics) | ||
session?.delegate?.interceptor?.task(task, didFinishCollecting: metrics) | ||
let identifier = ObjectIdentifier(configuration.delegateClass) | ||
|
||
// iOS 16 and above, didCompleteWithError is not called hence we use task state to detect task completion | ||
// while prior to iOS 15, task state doesn't change to completed hence we use didCompleteWithError to detect task completion | ||
if #available(iOS 15, tvOS 15, *) { | ||
self?._task(task, didCompleteWithError: task.error) | ||
session?.delegate?.interceptor?.task(task, didCompleteWithError: task.error) | ||
} | ||
if let swizzler = swizzlers[identifier] { | ||
DD.logger.warn( | ||
""" | ||
The delegate class \(configuration.delegateClass) is already instrumented. | ||
The previous instrumentation will be disabled in favor of the new one. | ||
""" | ||
) | ||
|
||
swizzler.unswizzle() | ||
} | ||
|
||
let swizzler = NetworkInstrumentationSwizzler() | ||
swizzlers[identifier] = swizzler | ||
|
||
try swizzler.swizzle( | ||
interceptResume: { [weak self] task in | ||
// intercept task if delegate match | ||
guard let self = self, task.dd.delegate?.isKind(of: configuration.delegateClass) == true else { | ||
return | ||
} | ||
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. How is the dd.delegate lifecycle is managed? 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's a computed |
||
}, interceptDidCompleteWithError: { [weak self] session, task, error in | ||
self?.queue.async { [weak self, weak session] in | ||
// prior to iOS 15, task state doesn't change to completed | ||
// hence we use didCompleteWithError to detect task completion | ||
self?._task(task, didCompleteWithError: task.error) | ||
session?.delegate?.interceptor?.task(task, didCompleteWithError: task.error) | ||
|
||
if let currentRequest = task.currentRequest { | ||
let request = self.intercept(request: currentRequest, additionalFirstPartyHosts: configuredFirstPartyHosts) | ||
task.dd.override(currentRequest: request) | ||
} | ||
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. question/ This condition is quite puzzling. In what circumstances we expect 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. That's a good question, I don't know what could nillify the |
||
|
||
self.intercept(task: task, additionalFirstPartyHosts: configuredFirstPartyHosts) | ||
} | ||
) | ||
|
||
try URLSessionDataDelegateSwizzler.bindIfNeeded(delegateClass: configuration.delegateClass, interceptDidReceive: { [weak self] session, task, data in | ||
// sync update to task prevents a race condition where the currentRequest could already be sent to the transport | ||
self?.queue.sync { [weak self, weak session] in | ||
self?._task(task, didReceive: data) | ||
session?.delegate?.interceptor?.task(task, didReceive: data) | ||
} | ||
}) | ||
try swizzler.swizzle( | ||
delegateClass: configuration.delegateClass, | ||
interceptDidReceive: { [weak self] session, task, data in | ||
self?.task(task, didReceive: data) | ||
}, | ||
interceptDidFinishCollecting: { [weak self] session, task, metrics in | ||
self?.task(task, didFinishCollecting: metrics) | ||
|
||
if #available(iOS 13, tvOS 13, *) { | ||
try URLSessionTaskSwizzler.bindIfNeeded(interceptResume: { [weak self] task in | ||
self?.queue.sync { [weak self] in | ||
let additionalFirstPartyHosts = configuredFirstPartyHosts + task.firstPartyHosts | ||
self?._intercept(task: task, additionalFirstPartyHosts: additionalFirstPartyHosts) | ||
} | ||
}) | ||
} else { | ||
try URLSessionSwizzler.bindIfNeeded(interceptURLRequest: { request in | ||
return self.intercept(request: request, additionalFirstPartyHosts: configuredFirstPartyHosts) | ||
}, interceptTask: { [weak self] task in | ||
self?.queue.async { [weak self] in | ||
let additionalFirstPartyHosts = configuredFirstPartyHosts + task.firstPartyHosts | ||
self?._intercept(task: task, additionalFirstPartyHosts: additionalFirstPartyHosts) | ||
if #available(iOS 15, tvOS 15, *) { | ||
// iOS 15 and above, didCompleteWithError is not called hence we use task state to detect task completion | ||
// while prior to iOS 15, task state doesn't change to completed hence we use didCompleteWithError to detect task completion | ||
self?.task(task, didCompleteWithError: task.error) | ||
} | ||
}) | ||
} | ||
} | ||
}, | ||
interceptDidCompleteWithError: { [weak self] session, task, error in | ||
self?.task(task, didCompleteWithError: error) | ||
} | ||
) | ||
|
||
internal func unbindAll() { | ||
URLSessionTaskDelegateSwizzler.unbindAll() | ||
URLSessionDataDelegateSwizzler.unbindAll() | ||
URLSessionTaskSwizzler.unbind() | ||
URLSessionSwizzler.unbind() | ||
try swizzler.swizzle( | ||
interceptCompletionHandler: { [weak self] task, _, error in | ||
self?.task(task, didCompleteWithError: error) | ||
} | ||
) | ||
} | ||
|
||
/// Unswizzles `URLSessionTaskDelegate`, `URLSessionDataDelegate`, `URLSessionTask` and `URLSession` methods | ||
/// - Parameter delegateClass: The delegate class to unswizzle. | ||
internal func unbind(delegateClass: URLSessionDataDelegate.Type) { | ||
URLSessionTaskDelegateSwizzler.unbind(delegateClass: delegateClass) | ||
URLSessionDataDelegateSwizzler.unbind(delegateClass: delegateClass) | ||
|
||
guard URLSessionTaskDelegateSwizzler.didFinishCollectingMap.isEmpty, | ||
URLSessionDataDelegateSwizzler.didReceiveMap.isEmpty else { | ||
return | ||
} | ||
|
||
URLSessionTaskSwizzler.unbind() | ||
URLSessionSwizzler.unbind() | ||
let identifier = ObjectIdentifier(delegateClass) | ||
swizzlers.removeValue(forKey: identifier) | ||
} | ||
} | ||
|
||
|
@@ -152,47 +147,32 @@ extension NetworkInstrumentationFeature { | |
/// - task: The created task. | ||
/// - additionalFirstPartyHosts: Extra hosts to consider in the interception. | ||
func intercept(task: URLSessionTask, additionalFirstPartyHosts: FirstPartyHosts?) { | ||
// sync update to task prevents a race condition where the currentRequest could already be sent to the transport | ||
queue.sync { [weak self] in | ||
self?._intercept(task: task, additionalFirstPartyHosts: additionalFirstPartyHosts) | ||
} | ||
} | ||
|
||
private func _intercept(task: URLSessionTask, additionalFirstPartyHosts: FirstPartyHosts?) { | ||
guard let request = task.currentRequest ?? task.originalRequest else { | ||
return | ||
} | ||
queue.async { [weak self] in | ||
guard let self = self, let request = task.currentRequest else { | ||
return | ||
} | ||
|
||
var interceptedRequest: URLRequest | ||
/// task.setValue is not available on iOS 12, hence for iOS 12 we modify the request by swizzling URLSession methods | ||
if #available(iOS 13, tvOS 13, *) { | ||
let request = self.intercept(request: request, additionalFirstPartyHosts: additionalFirstPartyHosts) | ||
interceptedRequest = request | ||
task.setValue(interceptedRequest, forKey: "currentRequest") | ||
} else { | ||
interceptedRequest = request | ||
} | ||
let firstPartyHosts = self.firstPartyHosts(with: additionalFirstPartyHosts) | ||
|
||
let firstPartyHosts = self.firstPartyHosts(with: additionalFirstPartyHosts) | ||
let interception = self.interceptions[task] ?? | ||
URLSessionTaskInterception( | ||
request: request, | ||
isFirstParty: firstPartyHosts.isFirstParty(url: request.url) | ||
) | ||
|
||
let interception = self.interceptions[task] ?? | ||
URLSessionTaskInterception( | ||
request: interceptedRequest, | ||
isFirstParty: firstPartyHosts.isFirstParty(url: interceptedRequest.url) | ||
) | ||
interception.register(request: request) | ||
|
||
interception.register(request: interceptedRequest) | ||
if let trace = self.extractTrace(firstPartyHosts: firstPartyHosts, request: request) { | ||
interception.register(traceID: trace.traceID, spanID: trace.spanID, parentSpanID: trace.parentSpanID) | ||
} | ||
|
||
if let trace = self.extractTrace(firstPartyHosts: firstPartyHosts, request: interceptedRequest) { | ||
interception.register(traceID: trace.traceID, spanID: trace.spanID, parentSpanID: trace.parentSpanID) | ||
} | ||
if let origin = request.value(forHTTPHeaderField: TracingHTTPHeaders.originField) { | ||
interception.register(origin: origin) | ||
} | ||
|
||
if let origin = interceptedRequest.value(forHTTPHeaderField: TracingHTTPHeaders.originField) { | ||
interception.register(origin: origin) | ||
self.interceptions[task] = interception | ||
self.handlers.forEach { $0.interceptionDidStart(interception: interception) } | ||
} | ||
|
||
self.interceptions[task] = interception | ||
self.handlers.forEach { $0.interceptionDidStart(interception: interception) } | ||
} | ||
|
||
/// Tells the interceptors that metrics were collected for the given task. | ||
|
@@ -202,21 +182,17 @@ extension NetworkInstrumentationFeature { | |
/// - metrics: The collected metrics. | ||
func task(_ task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { | ||
queue.async { [weak self] in | ||
self?._task(task, didFinishCollecting: metrics) | ||
} | ||
} | ||
|
||
private func _task(_ task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { | ||
guard let interception = self.interceptions[task] else { | ||
return | ||
} | ||
guard let self = self, let interception = self.interceptions[task] else { | ||
return | ||
} | ||
|
||
interception.register( | ||
metrics: ResourceMetrics(taskMetrics: metrics) | ||
) | ||
interception.register( | ||
metrics: ResourceMetrics(taskMetrics: metrics) | ||
) | ||
|
||
if interception.isDone { | ||
self.finish(task: task, interception: interception) | ||
if interception.isDone { | ||
self.finish(task: task, interception: interception) | ||
} | ||
} | ||
} | ||
|
||
|
@@ -227,40 +203,29 @@ extension NetworkInstrumentationFeature { | |
/// - data: A data object containing the transferred data. | ||
func task(_ task: URLSessionTask, didReceive data: Data) { | ||
queue.async { [weak self] in | ||
self?._task(task, didReceive: data) | ||
self?.interceptions[task]?.register(nextData: data) | ||
} | ||
} | ||
|
||
private func _task(_ task: URLSessionTask, didReceive data: Data) { | ||
guard let interception = self.interceptions[task] else { | ||
return | ||
} | ||
interception.register(nextData: data) | ||
} | ||
|
||
/// Tells the interceptors that the task did complete. | ||
/// | ||
/// - Parameters: | ||
/// - task: The task that has finished transferring data. | ||
/// - error: If an error occurred, an error object indicating how the transfer failed, otherwise NULL. | ||
func task(_ task: URLSessionTask, didCompleteWithError error: Error?) { | ||
queue.async { [weak self] in | ||
self?._task(task, didCompleteWithError: error) | ||
} | ||
} | ||
|
||
private func _task(_ task: URLSessionTask, didCompleteWithError error: Error?) { | ||
guard let interception = self.interceptions[task] else { | ||
return | ||
} | ||
guard let self = self, let interception = self.interceptions[task] else { | ||
return | ||
} | ||
|
||
interception.register( | ||
response: task.response, | ||
error: error | ||
) | ||
interception.register( | ||
response: task.response, | ||
error: error | ||
) | ||
|
||
if interception.isDone { | ||
self.finish(task: task, interception: interception) | ||
if interception.isDone { | ||
self.finish(task: task, interception: interception) | ||
} | ||
} | ||
} | ||
|
||
|
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.
blocking/ The new logic introduces new global swizzlings that need to be covered in integrity checks. Here we remove old checks instead of updating them ⛔. Balancing following calls must be enforced by
DatadogTestsObserver
, otherwise we will run into flakiness ❄️: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.
Previously, it was actually covered twice. Swizzling integrity is already checked by this which will fail if any swizzling is left, printing the method signatures 👍
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.
Nice, can you add some simple test case?
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.
I have added this to
MethodSwizzlerTests
👍