diff --git a/docs/ios/README.md b/docs/ios/README.md index 91ee829c1..04ddc15e9 100644 --- a/docs/ios/README.md +++ b/docs/ios/README.md @@ -7,3 +7,4 @@ All the features supported by the Measure SDK are listed below. * [App launch](features/feature_app_launch.md) +* [Network monitoring](features/feature_network_monitoring.md) diff --git a/docs/ios/features/feature_network_monitoring.md b/docs/ios/features/feature_network_monitoring.md new file mode 100644 index 000000000..21bebd613 --- /dev/null +++ b/docs/ios/features/feature_network_monitoring.md @@ -0,0 +1,21 @@ +# Feature - Network Monitoring + +Measure SDK can capture network requests, responses, and failures along with useful metrics to help understand how APIs are performing in production from an end-user perspective. Network monitoring is currently supported for URLSession's data tasks. + +### How It Works + +Measure uses two techniques to intercept network requests. The first method relies on swizzling `NSURLSessionTask`'s `setState:` method, while the second method involves adding a custom implementation of `URLProtocol` to `URLSessionConfiguration`'s `protocolClasses`. + +While the swizzling of the `setState:` method provides sufficient information about network requests, such as the request URL, headers, and status, it does not give access to the response data. However, the advantage of this approach is that no additional code needs to be added on the app side. + +To address the limitation of not being able to track response objects, Measure also provides an option for developers to enable network tracking for a given `URLSession`. All you need to do is add the following code: + +```swift +NetworkInterceptor.enable(on: configuration) +``` + +If the NetworkInterceptor is enabled for a particular URLSession, automated network tracking is disabled, and only the network requests of the enabled URLSession are tracked. + +### Data collected + +Checkout the data collected by Measure for each HTTP request in the [HTTP Event](../../api/sdk/README.md#http) section. \ No newline at end of file diff --git a/ios/MeasureSDK.xcodeproj/project.pbxproj b/ios/MeasureSDK.xcodeproj/project.pbxproj index 63a683a90..d1174e2a1 100644 --- a/ios/MeasureSDK.xcodeproj/project.pbxproj +++ b/ios/MeasureSDK.xcodeproj/project.pbxproj @@ -38,6 +38,14 @@ 52159E2D2CC8FAA500486F54 /* TimeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52159E2C2CC8FAA500486F54 /* TimeProviderTests.swift */; }; 5224ECE02C88057A00D1B1F7 /* FatalErrorUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5224ECDF2C88057A00D1B1F7 /* FatalErrorUtil.swift */; }; 5224ECE32C880FA400D1B1F7 /* XCTextCase+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5224ECE22C880FA300D1B1F7 /* XCTextCase+Extension.swift */; }; + 5225D02E2D088B7100FD240D /* HttpData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D0272D088B7100FD240D /* HttpData.swift */; }; + 5225D02F2D088B7100FD240D /* HttpEventCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D0282D088B7100FD240D /* HttpEventCollector.swift */; }; + 5225D0302D088B7100FD240D /* HttpInterceptorCallbacks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D0292D088B7100FD240D /* HttpInterceptorCallbacks.swift */; }; + 5225D0312D088B7100FD240D /* NetworkInterceptorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D02A2D088B7100FD240D /* NetworkInterceptorProtocol.swift */; }; + 5225D0322D088B7100FD240D /* URLSessionTaskInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D02B2D088B7100FD240D /* URLSessionTaskInterceptor.swift */; }; + 5225D0332D088B7100FD240D /* URLSessionTaskSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D02C2D088B7100FD240D /* URLSessionTaskSwizzler.swift */; }; + 5225D0352D0AEB1A00FD240D /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D0342D0AEB1A00FD240D /* String+Extension.swift */; }; + 5225D0502D0FECFF00FD240D /* InputStream+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D04F2D0FECFF00FD240D /* InputStream+Extension.swift */; }; 5229D16E2CCB533C00EFFE44 /* RecentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5229D16D2CCB533C00EFFE44 /* RecentSession.swift */; }; 523287692C85E07B000EE268 /* LifecycleObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287682C85E07B000EE268 /* LifecycleObserverTests.swift */; }; 523287732C86195E000EE268 /* SessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287722C86195E000EE268 /* SessionManagerTests.swift */; }; @@ -339,6 +347,14 @@ 52159E2C2CC8FAA500486F54 /* TimeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeProviderTests.swift; sourceTree = ""; }; 5224ECDF2C88057A00D1B1F7 /* FatalErrorUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FatalErrorUtil.swift; sourceTree = ""; }; 5224ECE22C880FA300D1B1F7 /* XCTextCase+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTextCase+Extension.swift"; sourceTree = ""; }; + 5225D0272D088B7100FD240D /* HttpData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HttpData.swift; sourceTree = ""; }; + 5225D0282D088B7100FD240D /* HttpEventCollector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HttpEventCollector.swift; sourceTree = ""; }; + 5225D0292D088B7100FD240D /* HttpInterceptorCallbacks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HttpInterceptorCallbacks.swift; sourceTree = ""; }; + 5225D02A2D088B7100FD240D /* NetworkInterceptorProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkInterceptorProtocol.swift; sourceTree = ""; }; + 5225D02B2D088B7100FD240D /* URLSessionTaskInterceptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionTaskInterceptor.swift; sourceTree = ""; }; + 5225D02C2D088B7100FD240D /* URLSessionTaskSwizzler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionTaskSwizzler.swift; sourceTree = ""; }; + 5225D0342D0AEB1A00FD240D /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; + 5225D04F2D0FECFF00FD240D /* InputStream+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InputStream+Extension.swift"; sourceTree = ""; }; 5229D16D2CCB533C00EFFE44 /* RecentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSession.swift; sourceTree = ""; }; 523287682C85E07B000EE268 /* LifecycleObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleObserverTests.swift; sourceTree = ""; }; 523287722C86195E000EE268 /* SessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManagerTests.swift; sourceTree = ""; }; @@ -558,8 +574,10 @@ 5202BE442C89600200A3496E /* Extensions */ = { isa = PBXGroup; children = ( + 5225D04F2D0FECFF00FD240D /* InputStream+Extension.swift */, 52A1A9472CA31E8E00461103 /* NSManagedObjectContext+Extension.swift */, 52AE72072CABAEAB00F2830A /* NSObject+Extension.swift */, + 5225D0342D0AEB1A00FD240D /* String+Extension.swift */, 5202BE432C89600200A3496E /* UIDevice+Extension.swift */, 52D51A5B2CCE77A5008F30A6 /* UIViewController+Extension.swift */, 52AE72062CABAEAB00F2830A /* UIWindow+Extension.swift */, @@ -611,6 +629,19 @@ path = Helper; sourceTree = ""; }; + 5225D02D2D088B7100FD240D /* Http */ = { + isa = PBXGroup; + children = ( + 5225D0272D088B7100FD240D /* HttpData.swift */, + 5225D0282D088B7100FD240D /* HttpEventCollector.swift */, + 5225D0292D088B7100FD240D /* HttpInterceptorCallbacks.swift */, + 5225D02A2D088B7100FD240D /* NetworkInterceptorProtocol.swift */, + 5225D02B2D088B7100FD240D /* URLSessionTaskInterceptor.swift */, + 5225D02C2D088B7100FD240D /* URLSessionTaskSwizzler.swift */, + ); + path = Http; + sourceTree = ""; + }; 5232876A2C85E277000EE268 /* Utils */ = { isa = PBXGroup; children = ( @@ -710,6 +741,7 @@ 52A853382C994AD100B2A39F /* Exception */, 524576632CBFD18600B288E5 /* Exporter */, 52AE72002CABAE9000F2830A /* Gestures */, + 5225D02D2D088B7100FD240D /* Http */, 52816B682CCE390800B160A4 /* Lifecycle */, 52A3C0752CDB732F00C8F047 /* Performance */, 524CC5DB2C6A4B48001AB506 /* Utils */, @@ -1322,6 +1354,7 @@ 52A1A93A2CA0702600461103 /* SessionEntity.swift in Sources */, 52CC63C92C9DE71300F7CA0A /* SystemCrashReporter.swift in Sources */, 5202BE392C895FC800A3496E /* AppAttributeProcessor.swift in Sources */, + 5225D0352D0AEB1A00FD240D /* String+Extension.swift in Sources */, 524576772CC1366E00B288E5 /* EventExporter.swift in Sources */, 5202BE472C89600200A3496E /* UIDevice+Extension.swift in Sources */, 52AE72012CABAE9000F2830A /* GestureCollector.swift in Sources */, @@ -1339,6 +1372,7 @@ 5202BE7B2C8B117900A3496E /* Event.swift in Sources */, 5202BE7C2C8B117900A3496E /* EventProcessor.swift in Sources */, 5202BE482C89600200A3496E /* UserDefaultStorage.swift in Sources */, + 5225D0502D0FECFF00FD240D /* InputStream+Extension.swift in Sources */, 5202BE7A2C8B117900A3496E /* AttachmentType.swift in Sources */, 528EAB8D2C80824200CB1574 /* Logger.swift in Sources */, 528EAB8F2C81B4C700CB1574 /* TimeProvider.swift in Sources */, @@ -1350,10 +1384,12 @@ 524576752CC11FDA00B288E5 /* BatchEntity.swift in Sources */, 524576692CC0021B00B288E5 /* NetworkClient.swift in Sources */, 52A1A9482CA31E8E00461103 /* NSManagedObjectContext+Extension.swift in Sources */, + 5225D0302D088B7100FD240D /* HttpInterceptorCallbacks.swift in Sources */, 52AE72022CABAE9000F2830A /* GestureDetector.swift in Sources */, 52CC63C12C9C608E00F7CA0A /* CrashDataPersistence.swift in Sources */, 52EB380C2C8C7334002D63EC /* SignPost.swift in Sources */, 52A8533E2C994CC200B2A39F /* ExceptionDetail.swift in Sources */, + 5225D02F2D088B7100FD240D /* HttpEventCollector.swift in Sources */, 526E30F62CE77BCB00F484B4 /* AppLaunchEvents.swift in Sources */, 52A853402C994D7900B2A39F /* Exception.swift in Sources */, 52159E272CC802A800486F54 /* EventSerializer.swift in Sources */, @@ -1367,6 +1403,7 @@ 5245766D2CC0DF0200B288E5 /* Heartbeat.swift in Sources */, 5202BE3F2C895FC800A3496E /* NetworkStateAttributeProcessor.swift in Sources */, 5229D16E2CCB533C00EFFE44 /* RecentSession.swift in Sources */, + 5225D02E2D088B7100FD240D /* HttpData.swift in Sources */, 5245766B2CC0D55500B288E5 /* HttpModels.swift in Sources */, 52CC63C32C9C609F00F7CA0A /* CrashDataWriter.swift in Sources */, 52D51A9B2CCF5780008F30A6 /* MeasureViewController.swift in Sources */, @@ -1378,10 +1415,12 @@ 524576732CC116DD00B288E5 /* BatchStore.swift in Sources */, 52816B6A2CCE399B00B160A4 /* LifecycleCollector.swift in Sources */, 528EAB962C84553500CB1574 /* LifecycleObserver.swift in Sources */, + 5225D0322D088B7100FD240D /* URLSessionTaskInterceptor.swift in Sources */, 52AE72032CABAE9000F2830A /* GestureEvents.swift in Sources */, 52A3C0782CDB732F00C8F047 /* CpuUsageData.swift in Sources */, 5202BE7D2C8B117900A3496E /* EventType.swift in Sources */, 52A8533C2C994BCE00B2A39F /* StackFrame.swift in Sources */, + 5225D0332D088B7100FD240D /* URLSessionTaskSwizzler.swift in Sources */, 52CD911E2C7B397C000189BA /* BaseConfigProvider.swift in Sources */, 52FA6A802CE212320091F089 /* SysCtl.swift in Sources */, 52A853422C9983B900B2A39F /* CrashDataFormatter.swift in Sources */, @@ -1389,6 +1428,7 @@ 52A3C0902CDC73AB00C8F047 /* MemoryUsageCollector.swift in Sources */, 52A1A9462CA1786A00461103 /* MeasureQueue.swift in Sources */, 52AE72092CABAEAB00F2830A /* NSObject+Extension.swift in Sources */, + 5225D0312D088B7100FD240D /* NetworkInterceptorProtocol.swift in Sources */, 52CD91202C7B39AE000189BA /* Config.swift in Sources */, 52AE72082CABAEAB00F2830A /* UIWindow+Extension.swift in Sources */, 52D51A5E2CCE7BE8008F30A6 /* LifecycleManager.swift in Sources */, diff --git a/ios/MeasureSDK.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme b/ios/MeasureSDK.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme index 63828d6a6..ef446bff4 100644 --- a/ios/MeasureSDK.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme +++ b/ios/MeasureSDK.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme @@ -53,7 +53,7 @@ + isEnabled = "YES"> (_ event: Event) { // swiftlint:disable:this cyclomatic_complexity function_body_length self.id = event.id @@ -173,6 +174,17 @@ struct EventEntity { self.hotLaunch = nil } + if let http = event.data as? HttpData { + do { + let data = try JSONEncoder().encode(http) + self.http = data + } catch { + self.http = nil + } + } else { + self.http = nil + } + if let attributes = event.attributes { do { let data = try JSONEncoder().encode(attributes) @@ -213,7 +225,8 @@ struct EventEntity { memoryUsage: Data?, coldLaunch: Data?, warmLaunch: Data?, - hotLaunch: Data?) { + hotLaunch: Data?, + http: Data?) { self.id = id self.sessionId = sessionId self.timestamp = timestamp @@ -236,6 +249,7 @@ struct EventEntity { self.coldLaunch = coldLaunch self.warmLaunch = warmLaunch self.hotLaunch = hotLaunch + self.http = http } func getEvent() -> Event { // swiftlint:disable:this cyclomatic_complexity function_body_length @@ -338,6 +352,14 @@ struct EventEntity { decodedData = nil } } + case .http: + if let httpData = self.http { + do { + decodedData = try JSONDecoder().decode(T.self, from: httpData) + } catch { + decodedData = nil + } + } case nil: decodedData = nil } diff --git a/ios/MeasureSDK/CoreData/EventStore.swift b/ios/MeasureSDK/CoreData/EventStore.swift index d6bd4607e..bcec95aa6 100644 --- a/ios/MeasureSDK/CoreData/EventStore.swift +++ b/ios/MeasureSDK/CoreData/EventStore.swift @@ -51,6 +51,7 @@ final class BaseEventStore: EventStore { eventOb.coldLaunch = event.coldLaunch eventOb.warmLaunch = event.warmLaunch eventOb.hotLaunch = event.hotLaunch + eventOb.http = event.http do { try context.saveIfNeeded() @@ -93,7 +94,8 @@ final class BaseEventStore: EventStore { memoryUsage: eventOb.memoryUsage, coldLaunch: eventOb.coldLaunch, warmLaunch: eventOb.warmLaunch, - hotLaunch: eventOb.hotLaunch) + hotLaunch: eventOb.hotLaunch, + http: eventOb.http) } } catch { guard let self = self else { return } @@ -134,7 +136,8 @@ final class BaseEventStore: EventStore { memoryUsage: eventOb.memoryUsage, coldLaunch: eventOb.coldLaunch, warmLaunch: eventOb.warmLaunch, - hotLaunch: eventOb.hotLaunch) + hotLaunch: eventOb.hotLaunch, + http: eventOb.http) } } catch { guard let self = self else { return } @@ -194,7 +197,8 @@ final class BaseEventStore: EventStore { memoryUsage: eventOb.memoryUsage, coldLaunch: eventOb.coldLaunch, warmLaunch: eventOb.warmLaunch, - hotLaunch: eventOb.hotLaunch)) + hotLaunch: eventOb.hotLaunch, + http: eventOb.http)) } } catch { guard let self = self else { diff --git a/ios/MeasureSDK/Events/EventType.swift b/ios/MeasureSDK/Events/EventType.swift index 35972a3b6..f59063e12 100644 --- a/ios/MeasureSDK/Events/EventType.swift +++ b/ios/MeasureSDK/Events/EventType.swift @@ -20,4 +20,5 @@ enum EventType: String, Codable { case coldLaunch = "cold_launch" case warmLaunch = "warm_launch" case hotLaunch = "hot_launch" + case http } diff --git a/ios/MeasureSDK/Exporter/EventSerializer.swift b/ios/MeasureSDK/Exporter/EventSerializer.swift index 20f639be2..6199c4a06 100644 --- a/ios/MeasureSDK/Exporter/EventSerializer.swift +++ b/ios/MeasureSDK/Exporter/EventSerializer.swift @@ -7,7 +7,7 @@ import Foundation -struct EventSerializer { +struct EventSerializer { // swiftlint:disable:this type_body_length private func getSerialisedData(for event: EventEntity) -> String? { // swiftlint:disable:this cyclomatic_complexity function_body_length let eventType = EventType(rawValue: event.type) switch eventType { @@ -131,6 +131,16 @@ struct EventSerializer { } } return nil + case .http: + if let httpData = event.http { + do { + let decodedData = try JSONDecoder().decode(HttpData.self, from: httpData) + return serialiseHttpData(decodedData) + } catch { + return nil + } + } + return nil case nil: return nil } @@ -321,6 +331,55 @@ struct EventSerializer { return result } + private func serialiseHttpData(_ httpData: HttpData) -> String { + var result = "{" + result += "\"url\":\"\(httpData.url)\"," + result += "\"method\":\"\(httpData.method)\"," + + if let statusCode = httpData.statusCode { + result += "\"status_code\":\"\(statusCode)\"," + } + + if let startTime = httpData.startTime { + result += "\"start_time\":\"\(startTime)\"," + } + + if let endTime = httpData.endTime { + result += "\"end_time\":\"\(endTime)\"," + } + + if let failureReason = httpData.failureReason { + result += "\"failure_reason\":\"\(failureReason)\"," + } + + if let failureDescription = httpData.failureDescription { + result += "\"failure_description\":\"\(failureDescription)\"," + } + + if let requestHeaders = httpData.requestHeaders { + let headers = requestHeaders.map { "\"\($0.key)\":\"\($0.value)\"" }.joined(separator: ",") + result += "\"request_headers\":{\(headers)}," + } + + if let responseHeaders = httpData.responseHeaders { + let headers = responseHeaders.map { "\"\($0.key)\":\"\($0.value)\"" }.joined(separator: ",") + result += "\"response_headers\":{\(headers)}," + } + + if let requestBody = httpData.requestBody { + result += "\"request_body\":\"\(requestBody)\"," + } + + if let responseBody = httpData.responseBody { + result += "\"response_body\":\"\(responseBody)\"," + } + + result += "\"client\":\"\(httpData.client)\"" + result += "}" + + return result + } + private func getSerialisedAttributes(for event: EventEntity) -> String? { let decodedAttributes: Attributes? if let attributeData = event.attributes { diff --git a/ios/MeasureSDK/Exporter/HttpClient.swift b/ios/MeasureSDK/Exporter/HttpClient.swift index cf34d5ebe..2328d2c47 100644 --- a/ios/MeasureSDK/Exporter/HttpClient.swift +++ b/ios/MeasureSDK/Exporter/HttpClient.swift @@ -23,6 +23,7 @@ final class BaseHttpClient: HttpClient { self.configProvider = configProvider let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = configProvider.timeoutIntervalForRequest +// NetworkInterceptor.enable(on: configuration) self.session = URLSession(configuration: configuration) } diff --git a/ios/MeasureSDK/Exporter/PeriodicEventExporter.swift b/ios/MeasureSDK/Exporter/PeriodicEventExporter.swift index 870cd33b0..b79feaa83 100644 --- a/ios/MeasureSDK/Exporter/PeriodicEventExporter.swift +++ b/ios/MeasureSDK/Exporter/PeriodicEventExporter.swift @@ -10,6 +10,7 @@ import Foundation protocol PeriodicEventExporter { func applicationDidEnterBackground() func applicationWillEnterForeground() + func start() } final class BasePeriodicEventExporter: PeriodicEventExporter, HeartbeatListener { @@ -38,6 +39,10 @@ final class BasePeriodicEventExporter: PeriodicEventExporter, HeartbeatListener } func applicationWillEnterForeground() { + start() + } + + func start() { heartbeat.start(intervalMs: configProvider.eventsBatchingIntervalMs, initialDelayMs: 0) } diff --git a/ios/MeasureSDK/Http/HttpData.swift b/ios/MeasureSDK/Http/HttpData.swift new file mode 100644 index 000000000..94e9a3f3b --- /dev/null +++ b/ios/MeasureSDK/Http/HttpData.swift @@ -0,0 +1,61 @@ +// +// HttpData.swift +// MeasureSDK +// +// Created by Adwin Ross on 28/11/24. +// + +import Foundation + +struct HttpData: Codable { + /// The complete URL of the request. + let url: String + + /// HTTP method, like get, post, put, etc. In lowercase. + let method: String + + /// HTTP response code. Example: 200, 401, 500, etc. + let statusCode: Int? + + /// The uptime at which the HTTP call started, in milliseconds. + let startTime: Int64? + + /// The uptime at which the HTTP call ended, in milliseconds. + let endTime: Int64? + + /// The reason for the failure. Typically the error class name. + let failureReason: String? + + /// The description of the failure. Typically the error message. + let failureDescription: String? + + /// The request headers. + var requestHeaders: [String: String]? + + /// The response headers. + var responseHeaders: [String: String]? + + /// The request body. + var requestBody: String? + + /// The response body. + var responseBody: String? + + /// The name of the client that sent the request. + let client: String + + enum CodingKeys: String, CodingKey { + case url + case method + case statusCode = "status_code" + case startTime = "start_time" + case endTime = "end_time" + case failureReason = "failure_reason" + case failureDescription = "failure_description" + case requestHeaders = "request_headers" + case responseHeaders = "response_headers" + case requestBody = "request_body" + case responseBody = "response_body" + case client + } +} diff --git a/ios/MeasureSDK/Http/HttpEventCollector.swift b/ios/MeasureSDK/Http/HttpEventCollector.swift new file mode 100644 index 000000000..47ba0c9ee --- /dev/null +++ b/ios/MeasureSDK/Http/HttpEventCollector.swift @@ -0,0 +1,54 @@ +// +// HttpEventCollector.swift +// MeasureSDK +// +// Created by Adwin Ross on 28/11/24. +// + +import Foundation + +protocol HttpEventCollector { + func enable() +} + +final class BaseHttpEventCollector: HttpEventCollector { + private let logger: Logger + private let eventProcessor: EventProcessor + private let timeProvider: TimeProvider + private let urlSessionTaskSwizzler: URLSessionTaskSwizzler + private let httpInterceptorCallbacks: HttpInterceptorCallbacks + private let client: Client + + init(logger: Logger, + eventProcessor: EventProcessor, + timeProvider: TimeProvider, + urlSessionTaskSwizzler: URLSessionTaskSwizzler, + httpInterceptorCallbacks: HttpInterceptorCallbacks, + client: Client) { + self.logger = logger + self.eventProcessor = eventProcessor + self.timeProvider = timeProvider + self.urlSessionTaskSwizzler = urlSessionTaskSwizzler + self.httpInterceptorCallbacks = httpInterceptorCallbacks + self.client = client + self.httpInterceptorCallbacks.httpDataCallback = onHttpCompletion(data:) + } + + func enable() { + NetworkInterceptorProtocol.setTimeProvider(timeProvider) + NetworkInterceptorProtocol.setHttpInterceptorCallbacks(httpInterceptorCallbacks) + urlSessionTaskSwizzler.swizzleURLSessionTask() + URLSessionTaskInterceptor.shared.setHttpInterceptorCallbacks(httpInterceptorCallbacks) + URLSessionTaskInterceptor.shared.setTimeProvider(timeProvider) +// URLSessionTaskInterceptor.shared.setIgnoredDomains([client.apiUrl.absoluteString]) + } + + func onHttpCompletion(data: HttpData) { + eventProcessor.track(data: data, + timestamp: timeProvider.now(), + type: .http, + attributes: nil, + sessionId: nil, + attachments: nil) + } +} diff --git a/ios/MeasureSDK/Http/HttpInterceptorCallbacks.swift b/ios/MeasureSDK/Http/HttpInterceptorCallbacks.swift new file mode 100644 index 000000000..16157bda2 --- /dev/null +++ b/ios/MeasureSDK/Http/HttpInterceptorCallbacks.swift @@ -0,0 +1,17 @@ +// +// HttpInterceptorCallbacks.swift +// MeasureSDK +// +// Created by Adwin Ross on 28/11/24. +// + +import Foundation + +final class HttpInterceptorCallbacks { + var httpDataCallback: ((_ data: HttpData) -> Void)? + + func onHttpCompletion(data: HttpData) { + guard let httpDataCallback = httpDataCallback else { return } + httpDataCallback(data) + } +} diff --git a/ios/MeasureSDK/Http/NetworkInterceptorProtocol.swift b/ios/MeasureSDK/Http/NetworkInterceptorProtocol.swift new file mode 100644 index 000000000..8de7d81a4 --- /dev/null +++ b/ios/MeasureSDK/Http/NetworkInterceptorProtocol.swift @@ -0,0 +1,180 @@ +// +// NetworkInterceptorProtocol.swift +// MeasureSDK +// +// Created by Adwin Ross on 26/11/24. +// + +import Foundation + +// Custom URLProtocol for intercepting network requests and responses +class NetworkInterceptorProtocol: URLProtocol { + private var dataTask: URLSessionDataTask? + private var startTime: Int64? + private var responseBody: Data? + private var httpResponse: HTTPURLResponse? + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = nil // Prevent recursive interception + return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + }() + + static var httpInterceptorCallbacks: HttpInterceptorCallbacks? + static var timeProvider: TimeProvider? + static var allowedDomains: [String]? + static var ignoredDomains: [String]? + + static func setTimeProvider(_ timeProvider: TimeProvider) { + self.timeProvider = timeProvider + } + + static func setHttpInterceptorCallbacks(_ httpInterceptorCallbacks: HttpInterceptorCallbacks) { + self.httpInterceptorCallbacks = httpInterceptorCallbacks + } + + /// Sets the list of domains to explicitly allow for tracking HTTP requests. + /// If this list is non-empty, only requests to URLs containing one of the allowed domains + /// will be tracked, provided they are not in the ignored domains list. + /// If this list is empty, all HTTP requests are tracked + /// - Parameter allowedDomains: An array of domain strings to allow for tracking. + static func setAllowedDomains(_ allowedDomains: [String]) { + self.allowedDomains = allowedDomains + } + + /// Sets the list of domains to ignore when tracking HTTP requests. + /// Any request to URLs containing a domain from this list will be excluded from tracking, + /// regardless of whether the domain is also in the allowed domains list. + /// - Parameter ignoredDomains: An array of domain strings to ignore. + static func setIgnoredDomains(_ ignoredDomains: [String]) { + self.ignoredDomains = ignoredDomains + } + + override class func canInit(with request: URLRequest) -> Bool { + // Prevent infinite loop by checking a custom property + return URLProtocol.property(forKey: networkInterceptorHandledKey, in: request) == nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + if let timeProvider = NetworkInterceptorProtocol.timeProvider { + startTime = timeProvider.millisTime + } + + if let taggedRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest { + URLProtocol.setProperty(true, forKey: networkInterceptorHandledKey, in: taggedRequest) + + dataTask = session.dataTask(with: taggedRequest as URLRequest) + dataTask?.resume() + } + } + + override func stopLoading() { + dataTask?.cancel() + } + + private func extractHeaders(from response: URLResponse?) -> [String: String]? { + guard let httpResponse = response as? HTTPURLResponse else { return nil } + return httpResponse.allHeaderFields as? [String: String] + } +} + +extension NetworkInterceptorProtocol: URLSessionDataDelegate { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + if responseBody == nil { + responseBody = Data() + } + responseBody?.append(data) + client?.urlProtocol(self, didLoad: data) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if let httpResponse = response as? HTTPURLResponse { + self.httpResponse = httpResponse + } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + defer { + if let error = error { + client?.urlProtocol(self, didFailWithError: error) + } else { + client?.urlProtocolDidFinishLoading(self) + } + } + + if let timeProvider = NetworkInterceptorProtocol.timeProvider, + let httpInterceptorCallbacks = NetworkInterceptorProtocol.httpInterceptorCallbacks, + let url = request.url?.absoluteString { + if let ignoreDomains = NetworkInterceptorProtocol.ignoredDomains.flatMap({ $0 }), ignoreDomains.contains(where: { url.contains($0) }) { + return + } + if let allowedDomains = NetworkInterceptorProtocol.allowedDomains.flatMap({ $0 }), !allowedDomains.isEmpty && !allowedDomains.contains(where: { url.contains($0) }) { + return + } + let endTime = timeProvider.millisTime + + var requestBody: String? + if let requestBodyStream = request.httpBodyStream { + requestBody = requestBodyStream.readStream() + } else if let requestBodyData = request.httpBody, let requestBodyString = String(data: requestBodyData, encoding: .utf8) { + requestBody = requestBodyString + } + requestBody = nil + let responseString = responseBody.map { String(data: $0, encoding: .utf8) } ?? nil + + let httpData = HttpData( + url: url.removeHttpPrefix(), + method: request.httpMethod?.lowercased() ?? "", + statusCode: httpResponse?.statusCode, + startTime: startTime, + endTime: endTime, + failureReason: error.map { String(describing: type(of: $0)) }, + failureDescription: error?.localizedDescription, + requestHeaders: request.allHTTPHeaderFields, + responseHeaders: extractHeaders(from: httpResponse), + requestBody: requestBody?.sanitizeRequestBody(), + responseBody: responseString?.sanitizeRequestBody(), + client: "URLSession") + + httpInterceptorCallbacks.onHttpCompletion(data: httpData) + } + + if let error = error { + client?.urlProtocol(self, didFailWithError: error) + } else { + client?.urlProtocolDidFinishLoading(self) + } + } +} + +public struct NetworkInterceptor { + static var isEnabled = false + private static let lock = NSLock() + + /// Enables the `NetworkInterceptor` by modifying the provided `URLSessionConfiguration`. + /// + /// This method injects the `NetworkInterceptorProtocol` into the `protocolClasses` of the given + /// `URLSessionConfiguration`. If the interceptor is already enabled, subsequent calls to this + /// method will have no effect. + /// + /// - Parameter sessionConfiguration: The `URLSessionConfiguration` to modify. + /// + /// - Note: Ensure you call this method before creating a `URLSession` instance with the given configuration. + public static func enable(on sessionConfiguration: URLSessionConfiguration) { + lock.lock() + defer { lock.unlock() } + + guard !isEnabled else { return } + + sessionConfiguration.protocolClasses = [NetworkInterceptorProtocol.self] + (sessionConfiguration.protocolClasses ?? []) + + isEnabled = true + } +} diff --git a/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift b/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift new file mode 100644 index 000000000..fa64e26a1 --- /dev/null +++ b/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift @@ -0,0 +1,109 @@ +// +// URLSessionTaskIntercepter.swift +// MeasureSDK +// +// Created by Adwin Ross on 28/11/24. +// + +import Foundation + +final class URLSessionTaskInterceptor { + static let shared = URLSessionTaskInterceptor() + private var httpInterceptorCallbacks: HttpInterceptorCallbacks? + private var taskStartTimes: [URLSessionTask: Int64] = [:] + private var timeProvider: TimeProvider? + private var allowedDomains: [String]? + private var ignoredDomains: [String]? + var trackedHttpEvents: [URLSessionTask: HttpData] = [:] + + private init() {} + + func setHttpInterceptorCallbacks(_ httpInterceptorCallbacks: HttpInterceptorCallbacks) { + self.httpInterceptorCallbacks = httpInterceptorCallbacks + } + + func setTimeProvider(_ timeProvider: TimeProvider) { + self.timeProvider = timeProvider + } + + /// Sets the list of domains to explicitly allow for tracking HTTP requests. + /// If this list is non-empty, only requests to URLs containing one of the allowed domains + /// will be tracked, provided they are not in the ignored domains list. + /// If this list is empty, all HTTP requests are tracked + /// - Parameter allowedDomains: An array of domain strings to allow for tracking. + func setAllowedDomains(_ allowedDomains: [String]) { + self.allowedDomains = allowedDomains + } + + /// Sets the list of domains to ignore when tracking HTTP requests. + /// Any request to URLs containing a domain from this list will be excluded from tracking, + /// regardless of whether the domain is also in the allowed domains list. + /// - Parameter ignoredDomains: An array of domain strings to ignore. + func setIgnoredDomains(_ ignoredDomains: [String]) { + self.ignoredDomains = ignoredDomains + } + + func urlSessionTask(_ task: URLSessionTask, setState state: URLSessionTask.State) { + guard !NetworkInterceptor.isEnabled else { return } + + guard let httpInterceptorCallbacks = self.httpInterceptorCallbacks, + let timeProvider = self.timeProvider else { return } + + guard let url = task.currentRequest?.url?.absoluteString else { return } + + // Skip generating `HttpData` if the URL is in ignored domains + if let ignoreDomains = self.ignoredDomains.flatMap({ $0 }), ignoreDomains.contains(where: { url.contains($0) }) { return } + + // Skip if allowedDomains is non-empty and the URL doesn't match any domain in allowedDomains + if let allowedDomains = self.allowedDomains.flatMap({ $0 }), !allowedDomains.isEmpty && !allowedDomains.contains(where: { url.contains($0) }) { return } + + if state == .running, taskStartTimes[task] == nil { + taskStartTimes[task] = timeProvider.millisTime + } + + if state == .completed || state == .canceling { + let endTime: Int64? = timeProvider.millisTime + let method = task.currentRequest?.httpMethod?.lowercased() ?? "" + let requestHeaders = task.currentRequest?.allHTTPHeaderFields ?? [:] + + var requestBody: String? + if let requestBodyStream = task.currentRequest?.httpBodyStream { + requestBody = requestBodyStream.readStream() + } else if let requestBodyData = task.currentRequest?.httpBody, + let requestBodyString = String(data: requestBodyData, encoding: .utf8) { + requestBody = requestBodyString + } + + guard let response = task.response as? HTTPURLResponse else { return } + let statusCode = response.statusCode + let responseHeaders = response.allHeaderFields as? [String: String] ?? [:] + + let responseBody: String? = nil + + let failureReason: String? = task.error?.localizedDescription + let failureDescription: String? = (task.error as NSError?)?.domain + + let startTime = taskStartTimes[task] + + let client = "URLSession" + + let httpData = HttpData( + url: url.removeHttpPrefix(), + method: method, + statusCode: statusCode, + startTime: startTime, + endTime: endTime, + failureReason: failureReason, + failureDescription: failureDescription, + requestHeaders: requestHeaders, + responseHeaders: responseHeaders, + requestBody: requestBody?.sanitizeRequestBody(), + responseBody: responseBody?.sanitizeRequestBody(), + client: client + ) + + taskStartTimes.removeValue(forKey: task) + httpInterceptorCallbacks.onHttpCompletion(data: httpData) + } + } +} diff --git a/ios/MeasureSDK/Http/URLSessionTaskSwizzler.swift b/ios/MeasureSDK/Http/URLSessionTaskSwizzler.swift new file mode 100644 index 000000000..b991c0d13 --- /dev/null +++ b/ios/MeasureSDK/Http/URLSessionTaskSwizzler.swift @@ -0,0 +1,106 @@ +// +// URLSessionTaskSwizzler.swift +// MeasureSDK +// +// Created by Adwin Ross on 28/11/24. +// + +import Foundation + +final class URLSessionTaskSwizzler { + func swizzleURLSessionTask() { + let classesToSwizzle = URLSessionTaskSearch.urlSessionTaskClasses() + guard !classesToSwizzle.isEmpty else { return } + + let setStateSelector = NSSelectorFromString("setState:") + + for classToSwizzle in classesToSwizzle { + // Swizzle the setState: method + swizzle( + classToSwizzle, + originalSelector: setStateSelector, + swizzledSelector: #selector(URLSessionTask.setStateSwizzled(state:)) + ) + } + } + + private func swizzle(_ cls: AnyClass, originalSelector: Selector, swizzledSelector: Selector) { + guard let originalMethod = class_getInstanceMethod(cls, originalSelector), + let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else { + return + } + + let didAddMethod = class_addMethod( + cls, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + cls, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + } +} + +private class URLSessionTaskSearch { + /// To track network requests, we swizzle the `setState:` method of NSURLSessionTask task. + /// The below code helps us identify which subclass of NSURLSessionTask implements the setState: function so that it can be swizzled. + /// + /// This inspiration for this code was taken from https://github.com/AFNetworking/AFNetworking/blob/4eaec5b586ddd897ebeda896e332a62a9fdab818/AFNetworking/AFURLSessionManager.m#L382-L403 + /// - Returns: Classes that implement NSURLSessionTask's `setState:` function + fileprivate static func urlSessionTaskClasses() -> [AnyClass] { + let configuration = URLSessionConfiguration.ephemeral + let session = URLSession(configuration: configuration) + + let localDataTask = session.dataTask(with: URL(string: "msr")!) + + var currentClass: AnyClass = type(of: localDataTask) + var result: [AnyClass] = [] + + let setStateSelector = NSSelectorFromString("setState:") + + while (class_getInstanceMethod(currentClass, setStateSelector) != nil) { // swiftlint:disable:this control_statement + guard let superClass = currentClass.superclass() else { break } + + if class_getInstanceMethod(currentClass, setStateSelector) != nil && class_getInstanceMethod(superClass, setStateSelector) == nil { + result.append(currentClass) + currentClass = superClass + break + } + if class_getInstanceMethod(currentClass, setStateSelector) == nil && class_getInstanceMethod(superClass, setStateSelector) != nil { + result.append(currentClass) + currentClass = superClass + break + } + if let classSetState = class_getInstanceMethod(currentClass, setStateSelector), + let superclassSetState = class_getInstanceMethod(superClass, setStateSelector) { + let classIMP = method_getImplementation(classSetState) + let superclassIMP = method_getImplementation(superclassSetState) + if classIMP != superclassIMP { + result.append(currentClass) + } + } + currentClass = superClass + } + + localDataTask.cancel() + session.invalidateAndCancel() + + return result + } +} + +extension URLSessionTask { + @objc fileprivate func setStateSwizzled(state: URLSessionTask.State) { + URLSessionTaskInterceptor.shared.urlSessionTask(self, setState: state) + self.setStateSwizzled(state: state) + } +} diff --git a/ios/MeasureSDK/Measure.swift b/ios/MeasureSDK/Measure.swift index b705d6e18..af623eba8 100644 --- a/ios/MeasureSDK/Measure.swift +++ b/ios/MeasureSDK/Measure.swift @@ -35,6 +35,7 @@ import Foundation let instance = Measure() return instance }() + private var measureInitializerLock = NSLock() private var measureInternal: MeasureInternal? var meaureInitializerInternal: MeasureInitializer? @@ -67,18 +68,20 @@ import Foundation /// [[Measure shared] initializeWith:clientInfo config:config]; /// ``` @objc public func initialize(with client: ClientInfo, config: BaseMeasureConfig? = nil) { - MeasureQueue.userInitiated.sync { - // Ensure initialization is done only once - guard measureInternal == nil else { return } - SignPost.trace(label: "Measure Initialisation") { - if let meaureInitializer = self.meaureInitializerInternal { - measureInternal = MeasureInternal(meaureInitializer) - meaureInitializer.logger.log(level: .info, message: "SDK enabled in testing mode.", error: nil, data: nil) - } else { - let meaureInitializer = BaseMeasureInitializer(config: config ?? BaseMeasureConfig(), - client: client) - measureInternal = MeasureInternal(meaureInitializer) - } + measureInitializerLock.lock() + defer { measureInitializerLock.unlock() } + + // Ensure initialization is done only once + guard measureInternal == nil else { return } + + SignPost.trace(label: "Measure Initialisation") { + if let meaureInitializer = self.meaureInitializerInternal { + measureInternal = MeasureInternal(meaureInitializer) + meaureInitializer.logger.log(level: .info, message: "SDK enabled in testing mode.", error: nil, data: nil) + } else { + let meaureInitializer = BaseMeasureInitializer(config: config ?? BaseMeasureConfig(), + client: client) + measureInternal = MeasureInternal(meaureInitializer) } } } diff --git a/ios/MeasureSDK/MeasureInitializer.swift b/ios/MeasureSDK/MeasureInitializer.swift index 876ea4ba8..c82d9ce65 100644 --- a/ios/MeasureSDK/MeasureInitializer.swift +++ b/ios/MeasureSDK/MeasureInitializer.swift @@ -47,6 +47,7 @@ protocol MeasureInitializer { var memoryUsageCalculator: MemoryUsageCalculator { get } var sysCtl: SysCtl { get } var appLaunchCollector: AppLaunchCollector { get } + var httpEventCollector: HttpEventCollector { get } } /// `BaseMeasureInitializer` is responsible for setting up the internal configuration @@ -78,6 +79,7 @@ protocol MeasureInitializer { /// - `cpuUsageCollector`: `CpuUsageCollector` object which is responsible for detecting and saving CPU usage data. /// - `memoryUsageCollector`: `MemoryUsageCollector` object which is responsible for detecting and saving memory usage data. /// - `appLaunchCollector`: `AppLaunchCollector` object which is responsible for detecting and saving launch related events. +/// - `httpEventCollector`: `HttpEventCollector` object that collects HTTP request data. /// - `gestureTargetFinder`: `GestureTargetFinder` object that determines which view is handling the gesture. /// - `cpuUsageCalculator`: `CpuUsageCalculator` object that generates CPU usage data. /// - `memoryUsageCalculator`: `MemoryUsageCalculator` object that generates memory usage data. @@ -128,6 +130,7 @@ final class BaseMeasureInitializer: MeasureInitializer { let memoryUsageCalculator: MemoryUsageCalculator let sysCtl: SysCtl let appLaunchCollector: AppLaunchCollector + var httpEventCollector: HttpEventCollector init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -237,5 +240,11 @@ final class BaseMeasureInitializer: MeasureInitializer { userDefaultStorage: userDefaultStorage, currentAppVersion: appVersion) self.client = client + self.httpEventCollector = BaseHttpEventCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + urlSessionTaskSwizzler: URLSessionTaskSwizzler(), + httpInterceptorCallbacks: HttpInterceptorCallbacks(), + client: client) } } diff --git a/ios/MeasureSDK/MeasureInternal.swift b/ios/MeasureSDK/MeasureInternal.swift index df5886522..e6b9c8f47 100644 --- a/ios/MeasureSDK/MeasureInternal.swift +++ b/ios/MeasureSDK/MeasureInternal.swift @@ -103,6 +103,9 @@ final class MeasureInternal { private var appLaunchCollector: AppLaunchCollector { return measureInitializer.appLaunchCollector } + private var httpEventCollector: HttpEventCollector { + return measureInitializer.httpEventCollector + } private let lifecycleObserver: LifecycleObserver init(_ measureInitializer: MeasureInitializer) { @@ -123,6 +126,8 @@ final class MeasureInternal { self.lifecycleCollector.enable() self.cpuUsageCollector.enable() self.memoryUsageCollector.enable() + self.periodicEventExporter.start() + self.httpEventCollector.enable() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { if let window = UIApplication.shared.windows.first { self.gestureCollector.enable(for: window) diff --git a/ios/MeasureSDK/Performance/CpuUsageCalculator.swift b/ios/MeasureSDK/Performance/CpuUsageCalculator.swift index 873a5fffe..6c4695ec8 100644 --- a/ios/MeasureSDK/Performance/CpuUsageCalculator.swift +++ b/ios/MeasureSDK/Performance/CpuUsageCalculator.swift @@ -23,7 +23,7 @@ final class BaseCpuUsageCalculator: CpuUsageCalculator { return -1 } - var threadList: thread_act_array_t? = UnsafeMutablePointer(mutating: [thread_act_t]()) + var threadList: thread_act_array_t? var threadCount: mach_msg_type_number_t = 0 defer { if let threadList = threadList { diff --git a/ios/MeasureSDK/Utils/Constants.swift b/ios/MeasureSDK/Utils/Constants.swift index 9176c15e3..f8c3a70e9 100644 --- a/ios/MeasureSDK/Utils/Constants.swift +++ b/ios/MeasureSDK/Utils/Constants.swift @@ -35,6 +35,7 @@ let recentSessionCrashedKey = "recent_session_crashed_key" let recentSessionVersionCodeKey = "recent_session_version_code" let recentLaunchAppVersion = "recent_launch_app_version" let recentLaunchTimeSinceLastBoot = "recent_launch_time_since_last_boot" +let networkInterceptorHandledKey = "NetworkInterceptorHandled" struct AttributeConstants { static let deviceManufacturer = "Apple" diff --git a/ios/MeasureSDK/Utils/Extensions/InputStream+Extension.swift b/ios/MeasureSDK/Utils/Extensions/InputStream+Extension.swift new file mode 100644 index 000000000..717a39c13 --- /dev/null +++ b/ios/MeasureSDK/Utils/Extensions/InputStream+Extension.swift @@ -0,0 +1,29 @@ +// +// InputStream+Extension.swift +// MeasureSDK +// +// Created by Adwin Ross on 16/12/24. +// + +import Foundation + +extension InputStream { + /// This extension adds a `readStream` method to the `InputStream` class, allowing you to read the contents of an `InputStream` as a `String`. + /// - Returns: A String value for the inputStream + func readStream() -> String { + self.open() + defer { self.close() } + + var buffer = [UInt8](repeating: 0, count: 1024) + var output = "" + + while self.hasBytesAvailable { + let length = self.read(&buffer, maxLength: buffer.count) + if length > 0, let string = String(bytes: buffer[0.. String { + return self.replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + } + + func sanitizeRequestBody() -> String { + return self.replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\t", with: "") + .replacingOccurrences(of: " ", with: "") + } +} diff --git a/ios/MeasureSDK/Utils/MeasureQueue.swift b/ios/MeasureSDK/Utils/MeasureQueue.swift index 5186fe1b4..a64319290 100644 --- a/ios/MeasureSDK/Utils/MeasureQueue.swift +++ b/ios/MeasureSDK/Utils/MeasureQueue.swift @@ -12,9 +12,4 @@ struct MeasureQueue { let queue = DispatchQueue(label: periodicEventExporterLabel, qos: .background) return queue }() - - static let userInitiated: DispatchQueue = { - let queue = DispatchQueue(label: userInitiatedQueueLabel, qos: .userInitiated) - return queue - }() } diff --git a/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents b/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents index 354b416fe..c9dbdfefd 100644 --- a/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents +++ b/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents @@ -17,6 +17,7 @@ + diff --git a/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift b/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift index e30a6a8b2..7a0d1bca8 100644 --- a/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift +++ b/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift @@ -723,4 +723,76 @@ final class EventSerializerTests: XCTestCase { // swiftlint:disable:this type_bo } } -} + func testHttpDataSerialization() { + let httpData = HttpData( + url: "https://example.com/api/v1/resource", + method: "GET", + statusCode: 200, + startTime: 123456789, + endTime: 123456999, + failureReason: nil, + failureDescription: nil, + requestHeaders: ["Content-Type": "application/json", "Authorization": "Bearer token"], + responseHeaders: ["Server": "nginx", "Content-Length": "123"], + requestBody: "requestBody", + responseBody: "responseBody", + client: "TestClient" + ) + + let event = Event( + id: "httpEventId", + sessionId: "sessionId", + timestamp: "2024-12-14T10:00:00Z", + timestampInMillis: 123456789000, + type: .http, + data: httpData, + attachments: [], + attributes: TestDataGenerator.generateAttributes(), + userTriggered: false + ) + + let eventEntity = EventEntity(event) + + guard let jsonString = eventSerializer.getSerialisedEvent(for: eventEntity) else { + XCTFail("getSerialisedEvent cannot be nil") + return + } + + let jsonData = Data(jsonString.utf8) + do { + let jsonDict = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] + + if let httpDataDict = jsonDict?["http"] as? [String: Any] { + XCTAssertEqual(httpDataDict["url"] as? String, "https://example.com/api/v1/resource") + XCTAssertEqual(httpDataDict["method"] as? String, "GET") + XCTAssertEqual(httpDataDict["status_code"] as? String, "200") + XCTAssertEqual(httpDataDict["start_time"] as? String, "123456789") + XCTAssertEqual(httpDataDict["end_time"] as? String, "123456999") + XCTAssertNil(httpDataDict["failure_reason"]) + XCTAssertNil(httpDataDict["failure_description"]) + + if let requestHeaders = httpDataDict["request_headers"] as? [String: String] { + XCTAssertEqual(requestHeaders["Content-Type"], "application/json") + XCTAssertEqual(requestHeaders["Authorization"], "Bearer token") + } else { + XCTFail("Request headers are not serialized correctly.") + } + + if let responseHeaders = httpDataDict["response_headers"] as? [String: String] { + XCTAssertEqual(responseHeaders["Server"], "nginx") + XCTAssertEqual(responseHeaders["Content-Length"], "123") + } else { + XCTFail("Response headers are not serialized correctly.") + } + + XCTAssertEqual(httpDataDict["request_body"] as? String, "requestBody") + XCTAssertEqual(httpDataDict["response_body"] as? String, "responseBody") + XCTAssertEqual(httpDataDict["client"] as? String, "TestClient") + } else { + XCTFail("HTTP data is not present in the serialized event.") + } + } catch { + XCTFail("Invalid JSON object: \(error.localizedDescription)") + } + } +} // swiftlint:disable:this file_length diff --git a/ios/MeasureSDKTests/Helper/TestDataGenerator.swift b/ios/MeasureSDKTests/Helper/TestDataGenerator.swift index fdd1f3717..85ef5402e 100644 --- a/ios/MeasureSDKTests/Helper/TestDataGenerator.swift +++ b/ios/MeasureSDKTests/Helper/TestDataGenerator.swift @@ -87,7 +87,8 @@ struct TestDataGenerator { memoryUsage: Data? = nil, coldLaunch: Data? = nil, warmLaunch: Data? = nil, - hotLaunch: Data? = nil + hotLaunch: Data? = nil, + http: Data? = nil ) -> EventEntity { return EventEntity( id: id, @@ -111,7 +112,8 @@ struct TestDataGenerator { memoryUsage: memoryUsage, coldLaunch: coldLaunch, warmLaunch: warmLaunch, - hotLaunch: hotLaunch + hotLaunch: hotLaunch, + http: http ) } diff --git a/ios/TestApp/AppDelegate.swift b/ios/TestApp/AppDelegate.swift index 90bf3f558..bcec7a2b4 100644 --- a/ios/TestApp/AppDelegate.swift +++ b/ios/TestApp/AppDelegate.swift @@ -63,7 +63,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func addLogLabels() { if let logger = mockMeasureInitializer?.logger as? MockLogger { logger.onLog = { _, message, _, data in - if message.contains("gestureClick") || message.contains("gestureLongClick") || message.contains("gestureScroll") || message.contains("lifecycleViewController") || message.contains("coldLaunch") || message.contains("warmLaunch") || message.contains("hotLaunch") { + if message.contains("gestureClick") || + message.contains("gestureLongClick") || + message.contains("gestureScroll") || + message.contains("lifecycleViewController") || + message.contains("coldLaunch") || + message.contains("warmLaunch") || + message.contains("hotLaunch") { if let data = data { if let jsonData = try? JSONEncoder().encode(data) { self.labelData.text = String(data: jsonData, encoding: .utf8) diff --git a/ios/TestApp/MockMeasureInitializer.swift b/ios/TestApp/MockMeasureInitializer.swift index dd4314f5f..11b10206b 100644 --- a/ios/TestApp/MockMeasureInitializer.swift +++ b/ios/TestApp/MockMeasureInitializer.swift @@ -46,6 +46,7 @@ final class MockMeasureInitializer: MeasureInitializer { let memoryUsageCalculator: MemoryUsageCalculator let sysCtl: SysCtl let appLaunchCollector: AppLaunchCollector + let httpEventCollector: HttpEventCollector init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -155,5 +156,11 @@ final class MockMeasureInitializer: MeasureInitializer { userDefaultStorage: userDefaultStorage, currentAppVersion: appVersion) self.client = client + self.httpEventCollector = BaseHttpEventCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + urlSessionTaskSwizzler: URLSessionTaskSwizzler(), + httpInterceptorCallbacks: HttpInterceptorCallbacks(), + client: client) } }