diff --git a/Sources/EmbraceCommonInternal/Swizzling/EmbraceSwizzler.swift b/Sources/EmbraceCommonInternal/Swizzling/EmbraceSwizzler.swift new file mode 100644 index 00000000..26a21312 --- /dev/null +++ b/Sources/EmbraceCommonInternal/Swizzling/EmbraceSwizzler.swift @@ -0,0 +1,80 @@ +// +// Copyright © 2024 Embrace Mobile, Inc. All rights reserved. +// +import ObjectiveC.runtime + +public class EmbraceSwizzler { + public init() { + } + + /// Swizzles a specific instance method of a given class. + /// + /// This method allows you to replace the implementation of an instance method in the specified class (`type`) + /// with a custom implementation provided as a block. Only the implementation of the specified class is swizzled, + /// and parent class methods with the same selector are not affected. + /// + /// - Parameters: + /// - type: The class in which the method resides. The method to be swizzled **must belong to this class specifically**. + /// - selector: The selector for the instance method to be swizzled. + /// - implementationType: The expected function signature of the original method (use `@convention(c)`) + /// - blockImplementationType: The expected function signature of the new implementation block (use `@convention(block)`) + /// - block: A closure that accepts the original implementation as input (`implementationType.Type`) and provides the + /// new implementation (`blockImplementationType.Type`) + /// + /// - Important: This method only operates on methods explicitly declared in the specified class. If the method is inherited from a parent class, **it wont be swizzled**. + public func swizzleDeclaredInstanceMethod( + in type: AnyClass, + selector: Selector, + implementationType: T.Type, + blockImplementationType: F.Type, + _ block: @escaping (T) -> F + ) throws { + + // Find the method in the specified class + var methodToSwizzle: Method? + var methodCount: UInt32 = 0 + + // We use `class_copyMethodList` and search for the method instead of using `class_getInstanceMethod` + // because we don't want to modify the `superclass` implementation. + let methods = class_copyMethodList(type, &methodCount) + if let methods = methods { + for index in 0.. Span? { - guard let id = vc.emb_identifier else { + guard let id = vc.emb_instrumentation_state?.identifier else { return nil } @@ -70,6 +71,7 @@ class UIViewControllerHandler { self.parentSpans.removeAll() self.viewDidLoadSpans.removeAll() self.viewWillAppearSpans.removeAll() + self.viewIsAppearingSpans.removeAll() self.viewDidAppearSpans.removeAll() self.visibilitySpans.removeAll() self.uiReadySpans.removeAll() @@ -77,7 +79,7 @@ class UIViewControllerHandler { } } - func onViewDidLoadStart(_ vc: UIViewController) { + func onViewDidLoadStart(_ vc: UIViewController, now: Date = Date()) { guard dataSource?.state == .active, dataSource?.instrumentFirstRender == true, @@ -89,7 +91,10 @@ class UIViewControllerHandler { // There could be a race condition and it's possible that the controller was released or is in the process of deallocation, // which could cause a crash (as this feature relies on objc_setAssociatedObject). let id = UUID().uuidString - vc.emb_identifier = id + let state = ViewInstrumentationState() + state.viewDidLoadSpanCreated = true + state.identifier = id + vc.emb_instrumentation_state = state queue.async { // generate parent span @@ -105,7 +110,8 @@ class UIViewControllerHandler { let parentSpan = self.createSpan( with: otel, vc: vc, - name: spanName + name: spanName, + startTime: now ) // generate view did load span @@ -113,6 +119,7 @@ class UIViewControllerHandler { with: otel, vc: vc, name: SpanSemantics.View.viewDidLoadName, + startTime: now, parent: parentSpan ) @@ -121,21 +128,22 @@ class UIViewControllerHandler { } } - func onViewDidLoadEnd(_ vc: UIViewController) { + func onViewDidLoadEnd(_ vc: UIViewController, now: Date = Date()) { queue.async { - guard let id = vc.emb_identifier, + guard let id = vc.emb_instrumentation_state?.identifier, let span = self.viewDidLoadSpans.removeValue(forKey: id) else { return } - span.end() + span.end(time: now) } } - func onViewWillAppearStart(_ vc: UIViewController) { + func onViewWillAppearStart(_ vc: UIViewController, now: Date = Date()) { + vc.emb_instrumentation_state?.viewWillAppearSpanCreated = true queue.async { guard let otel = self.dataSource?.otel, - let id = vc.emb_identifier, + let id = vc.emb_instrumentation_state?.identifier, let parentSpan = self.parentSpans[id] else { return } @@ -145,6 +153,7 @@ class UIViewControllerHandler { with: otel, vc: vc, name: SpanSemantics.View.viewWillAppearName, + startTime: now, parent: parentSpan ) @@ -152,21 +161,55 @@ class UIViewControllerHandler { } } - func onViewWillAppearEnd(_ vc: UIViewController) { + func onViewWillAppearEnd(_ vc: UIViewController, now: Date = Date()) { queue.async { - guard let id = vc.emb_identifier, + guard let id = vc.emb_instrumentation_state?.identifier, let span = self.viewWillAppearSpans.removeValue(forKey: id) else { return } - span.end() + span.end(time: now) } } - func onViewDidAppearStart(_ vc: UIViewController) { + func onViewIsAppearingStart(_ vc: UIViewController, now: Date = Date()) { + vc.emb_instrumentation_state?.viewIsAppearingSpanCreated = true queue.async { guard let otel = self.dataSource?.otel, - let id = vc.emb_identifier, + let id = vc.emb_instrumentation_state?.identifier, + let parentSpan = self.parentSpans[id] else { + return + } + + // generate view is appearing span + let span = self.createSpan( + with: otel, + vc: vc, + name: SpanSemantics.View.viewIsAppearingName, + startTime: now, + parent: parentSpan + ) + + self.viewIsAppearingSpans[id] = span + } + } + + func onViewIsAppearingEnd(_ vc: UIViewController, now: Date = Date()) { + queue.async { + guard let id = vc.emb_instrumentation_state?.identifier, + let span = self.viewIsAppearingSpans.removeValue(forKey: id) else { + return + } + + span.end(time: now) + } + } + + func onViewDidAppearStart(_ vc: UIViewController, now: Date = Date()) { + vc.emb_instrumentation_state?.viewDidAppearSpanCreated = true + queue.async { + guard let otel = self.dataSource?.otel, + let id = vc.emb_instrumentation_state?.identifier, let parentSpan = self.parentSpans[id] else { return } @@ -176,6 +219,7 @@ class UIViewControllerHandler { with: otel, vc: vc, name: SpanSemantics.View.viewDidAppearName, + startTime: now, parent: parentSpan ) @@ -183,19 +227,19 @@ class UIViewControllerHandler { } } - func onViewDidAppearEnd(_ vc: UIViewController) { + func onViewDidAppearEnd(_ vc: UIViewController, now: Date = Date()) { if self.dataSource?.instrumentVisibility == true { // Create id only if necessary. This could happen when `instrumentFirstRender` is `false` // in those cases, the `emb_identifier` will be `nil` and we need it to instrument visibility // (and in those cases that's enabled, also instrumenting the rendering process). // The reason why we're doing this outside of the utility `queue` can be found on `onViewDidLoadStart`. - if vc.emb_identifier == nil { - vc.emb_identifier = UUID().uuidString + if vc.emb_instrumentation_state == nil { + vc.emb_instrumentation_state = ViewInstrumentationState(identifier: UUID().uuidString) } } queue.async { - guard let otel = self.dataSource?.otel, let id = vc.emb_identifier else { + guard let otel = self.dataSource?.otel, let id = vc.emb_instrumentation_state?.identifier else { return } @@ -205,14 +249,12 @@ class UIViewControllerHandler { with: otel, vc: vc, name: SpanSemantics.View.screenName, - type: .view + type: .view, + startTime: now ) self.visibilitySpans[id] = span } - // end view did appear span - let now = Date() - if let span = self.viewDidAppearSpans.removeValue(forKey: id) { span.end(time: now) } @@ -232,6 +274,7 @@ class UIViewControllerHandler { with: otel, vc: vc, name: SpanSemantics.View.uiReadyName, + startTime: now, parent: parentSpan ) @@ -253,7 +296,7 @@ class UIViewControllerHandler { func onViewDidDisappear(_ vc: UIViewController) { queue.async { - guard let id = vc.emb_identifier else { + guard let id = vc.emb_instrumentation_state?.identifier else { return } @@ -272,7 +315,7 @@ class UIViewControllerHandler { func onViewBecameInteractive(_ vc: UIViewController) { queue.async { - guard let id = vc.emb_identifier, + guard let id = vc.emb_instrumentation_state?.identifier, let parentSpan = self.parentSpans[id], parentSpan.isTimeToInteractive else { return @@ -305,6 +348,10 @@ class UIViewControllerHandler { viewWillAppearSpan.end(errorCode: .userAbandon, time: time) } + if let viewIsAppearingSpan = self.viewIsAppearingSpans[id] { + viewIsAppearingSpan.end(errorCode: .userAbandon, time: time) + } + if let viewDidAppearSpan = self.viewDidAppearSpans[id] { viewDidAppearSpan.end(errorCode: .userAbandon, time: time) } @@ -325,6 +372,7 @@ class UIViewControllerHandler { vc: UIViewController, name: String, type: SpanType = .viewLoad, + startTime: Date, parent: Span? = nil ) -> Span { let builder = otel.buildSpan( @@ -341,6 +389,8 @@ class UIViewControllerHandler { builder.setParent(parent) } + builder.setStartTime(time: startTime) + return builder.startSpan() } @@ -348,11 +398,11 @@ class UIViewControllerHandler { self.parentSpans[id] = nil self.viewDidLoadSpans[id] = nil self.viewWillAppearSpans[id] = nil + self.viewIsAppearingSpans[id] = nil self.viewDidAppearSpans[id] = nil self.uiReadySpans[id] = nil self.alreadyFinishedUiReadyIds.remove(id) - - vc?.emb_identifier = nil + vc?.emb_instrumentation_state = nil } } diff --git a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift index 78d0ccd3..7d55bebb 100644 --- a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift +++ b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService+Options.swift @@ -2,9 +2,23 @@ // Copyright © 2024 Embrace Mobile, Inc. All rights reserved. // -import Foundation +#if canImport(UIKit) && !os(watchOS) +import UIKit extension ViewCaptureService { + enum InstrumentFirstRenderMode { + case automatic + case manual(viewControllers: [UIViewController.Type]) + case off + + func isOn() -> Bool { + if case .off = self { + return false + } + return true + } + } + /// Class used to setup a `ViewCaptureService`. @objc(EMBViewCaptureServiceOptions) public final class Options: NSObject { @@ -24,11 +38,22 @@ extension ViewCaptureService { /// The implementers will need to call `setInteractionReady()` on the `UIViewController` to mark the end time. /// If the `UIViewController` disappears before the interaction is set as ready, the span status will be set to `error` /// with the `userAbandon` error code. - @objc public let instrumentFirstRender: Bool + @objc public var instrumentFirstRender: Bool { + instrumentFirstRenderMode.isOn() + } + + let instrumentFirstRenderMode: InstrumentFirstRenderMode + + @objc public convenience init(instrumentVisibility: Bool, instrumentFirstRender: Bool) { + self.init( + instrumentVisibility: instrumentVisibility, + firstRenderInstrumentationMode: instrumentFirstRender ? .automatic : .off + ) + } - @objc public init(instrumentVisibility: Bool, instrumentFirstRender: Bool) { + private init(instrumentVisibility: Bool, firstRenderInstrumentationMode: InstrumentFirstRenderMode) { self.instrumentVisibility = instrumentVisibility - self.instrumentFirstRender = instrumentFirstRender + self.instrumentFirstRenderMode = firstRenderInstrumentationMode } @objc public convenience override init() { @@ -36,3 +61,4 @@ extension ViewCaptureService { } } } +#endif diff --git a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService.swift b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService.swift index 1f9faf8f..4a28b4d2 100644 --- a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService.swift +++ b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureService.swift @@ -8,15 +8,16 @@ import EmbraceCaptureService import EmbraceCommonInternal import EmbraceOTelInternal import OpenTelemetryApi +import Foundation -/// Service that generates OpenTelemetry spans for `UIViewControllers`. @objc(EMBViewCaptureService) public final class ViewCaptureService: CaptureService, UIViewControllerHandlerDataSource { - public let options: ViewCaptureService.Options - private var lock: NSLocking - private var swizzlers: [any Swizzlable] = [] - private var handler: UIViewControllerHandler + private let handler: UIViewControllerHandler + private let swizzler: EmbraceSwizzler + private let swizzlerCache: ViewCaptureServiceSwizzlerCache + private let bundlePath: String + private let lock: NSLocking var instrumentVisibility: Bool { return options.instrumentVisibility @@ -26,6 +27,10 @@ public final class ViewCaptureService: CaptureService, UIViewControllerHandlerDa return options.instrumentFirstRender } + var instrumentationFirstRenderMode: ViewCaptureService.InstrumentFirstRenderMode { + return options.instrumentFirstRenderMode + } + @objc public convenience init(options: ViewCaptureService.Options) { self.init(options: options, lock: NSLock()) } @@ -36,12 +41,18 @@ public final class ViewCaptureService: CaptureService, UIViewControllerHandlerDa init( options: ViewCaptureService.Options = ViewCaptureService.Options(), - lock: NSLocking, - handler: UIViewControllerHandler = UIViewControllerHandler() + handler: UIViewControllerHandler = UIViewControllerHandler(), + swizzler: EmbraceSwizzler = .init(), + swizzlerCache: ViewCaptureServiceSwizzlerCache = .withDefaults(), + bundle: Bundle = .main, + lock: NSLocking ) { self.options = options - self.lock = lock self.handler = handler + self.swizzler = EmbraceSwizzler() + self.swizzlerCache = swizzlerCache + self.bundlePath = bundle.bundlePath + self.lock = lock } func onViewBecameInteractive(_ vc: UIViewController) { @@ -67,114 +78,228 @@ public final class ViewCaptureService: CaptureService, UIViewControllerHandlerDa } handler.dataSource = self - initializeSwizzlers() - swizzlers.forEach { - do { - try $0.install() - } catch let exception { - Embrace.logger.error("Capture service couldn't be installed: \(exception.localizedDescription)") + if instrumentFirstRender { + switch instrumentationFirstRenderMode { + case .automatic: + instrumentInitWithCoder() + instrumentInitWithNibAndBundle() + case .manual(let viewControllers): + for viewController in viewControllers { + instrumentRender(of: viewController) + } + default: break } } - } - private func initializeSwizzlers() { - swizzlers.append(UIViewControllerViewDidLoadSwizzler(handler: handler)) - swizzlers.append(UIViewControllerViewWillAppearSwizzler(handler: handler)) - swizzlers.append(UIViewControllerViewDidAppearSwizzler(handler: handler)) - swizzlers.append(UIViewControllerViewDidDisappearSwizzler(handler: handler)) + if instrumentVisibility || instrumentFirstRender { + instrumentRender(of: UIViewController.self) + } + + if instrumentVisibility { + instrumentViewDidDisappear(of: UIViewController.self) + } } } -class UIViewControllerViewDidLoadSwizzler: Swizzlable { - typealias ImplementationType = @convention(c) (UIViewController, Selector) -> Void - typealias BlockImplementationType = @convention(block) (UIViewController) -> Void - static var selector: Selector = #selector(UIViewController.viewDidLoad) - var baseClass: AnyClass = UIViewController.self - - private let handler: UIViewControllerHandler +private extension ViewCaptureService { - init(handler: UIViewControllerHandler) { - self.handler = handler + func instrumentRender(of viewControllerType: UIViewController.Type) { + instrumentViewDidLoad(of: viewControllerType) + instrumentViewWillAppear(of: viewControllerType) + instrumentViewDidAppear(of: viewControllerType) } - func install() throws { - try swizzleInstanceMethod { originalImplementation in - return { [weak self] viewController -> Void in - self?.handler.onViewDidLoadStart(viewController) - originalImplementation(viewController, Self.selector) - self?.handler.onViewDidLoadEnd(viewController) + func instrumentViewDidLoad(of viewControllerType: UIViewController.Type) { + let selector = #selector(UIViewController.viewDidLoad) + do { + try swizzler.swizzleDeclaredInstanceMethod( + in: viewControllerType, + selector: selector, + implementationType: (@convention(c) (UIViewController, Selector) -> Void).self, + blockImplementationType: (@convention(block) (UIViewController) -> Void).self + ) { originalImplementation in + { viewController in + // If the state was already fulfilled, then call the original implementation. + if let state = viewController.emb_instrumentation_state, state.viewDidLoadSpanCreated { + originalImplementation(viewController, selector) + return + } + + // Start and end a `viewDidLoad` span + self.handler.onViewDidLoadStart(viewController) + originalImplementation(viewController, selector) + self.handler.onViewDidLoadEnd(viewController) + } } + } catch let exception { + Embrace.logger.error("Error swizzling viewDidLoad: \(exception.localizedDescription)") } } -} -class UIViewControllerViewWillAppearSwizzler: Swizzlable { - typealias ImplementationType = @convention(c) (UIViewController, Selector, Bool) -> Void - typealias BlockImplementationType = @convention(block) (UIViewController, Bool) -> Void - static var selector: Selector = #selector(UIViewController.viewWillAppear(_:)) - var baseClass: AnyClass = UIViewController.self - - private let handler: UIViewControllerHandler - - init(handler: UIViewControllerHandler) { - self.handler = handler - } - - func install() throws { - try swizzleInstanceMethod { originalImplementation in - return { [weak self] viewController, animated -> Void in - self?.handler.onViewWillAppearStart(viewController) - originalImplementation(viewController, Self.selector, animated) - self?.handler.onViewWillAppearEnd(viewController) + func instrumentViewWillAppear(of viewControllerType: UIViewController.Type) { + let selector = #selector(UIViewController.viewWillAppear(_:)) + do { + try swizzler.swizzleDeclaredInstanceMethod( + in: viewControllerType, + selector: selector, + implementationType: (@convention(c) (UIViewController, Selector, Bool) -> Void).self, + blockImplementationType: (@convention(block) (UIViewController, Bool) -> Void).self + ) { originalImplementation in + { viewController, animated in + // If by this time (`viewWillAppear` being called) there's no `emb_instrumentation_state` associated + // to the viewController, then we don't swizzle as the "instrument render" feature might be disabled. + if let state = viewController.emb_instrumentation_state { + // If the state was already fulfilled, then call the original implementation. + if state.viewWillAppearSpanCreated { + originalImplementation(viewController, selector, animated) + return + } + + // Start and end a `viewWillAppear` span + self.handler.onViewWillAppearStart(viewController) + originalImplementation(viewController, selector, animated) + self.handler.onViewWillAppearEnd(viewController) + + // Start a `viewIsAppearing` span to measure the animation times. + // Note: we're not swizzling `viewIsAppearing` as: + // 1. Most people doesn't override it. + // 2. It's only available for iOS 15 and up. + self.handler.onViewIsAppearingStart(viewController) + } else { + // Fall back to the original implementation + originalImplementation(viewController, selector, animated) + } + } } + } catch let exception { + Embrace.logger.error("Error swizzling viewWillAppear: \(exception.localizedDescription)") } } -} -class UIViewControllerViewDidAppearSwizzler: Swizzlable { - typealias ImplementationType = @convention(c) (UIViewController, Selector, Bool) -> Void - typealias BlockImplementationType = @convention(block) (UIViewController, Bool) -> Void - static var selector: Selector = #selector(UIViewController.viewDidAppear(_:)) - var baseClass: AnyClass = UIViewController.self - - private let handler: UIViewControllerHandler - - init(handler: UIViewControllerHandler) { - self.handler = handler + func instrumentViewDidAppear(of viewControllerType: UIViewController.Type) { + let selector = #selector(UIViewController.viewDidAppear(_:)) + do { + try swizzler.swizzleDeclaredInstanceMethod( + in: viewControllerType, + selector: selector, + implementationType: (@convention(c) (UIViewController, Selector, Bool) -> Void).self, + blockImplementationType: (@convention(block) (UIViewController, Bool) -> Void).self + ) { originalImplementation in + { viewController, animated in + // If the state was already fulfilled, then call the original implementation. + if let state = viewController.emb_instrumentation_state, state.viewDidAppearSpanCreated { + originalImplementation(viewController, selector, animated) + return + } + + // If we started a `viewIsAppearing` span, we ensure we end it. + // This ensures that spans measuring animation times are properly closed, + if let state = viewController.emb_instrumentation_state, state.viewIsAppearingSpanCreated { + self.handler.onViewIsAppearingEnd(viewController) + } + + // Start and end a `viewDidAppear` span + self.handler.onViewDidAppearStart(viewController) + originalImplementation(viewController, selector, animated) + self.handler.onViewDidAppearEnd(viewController) + } + } + } catch let exception { + Embrace.logger.error("Error swizzling viewDidAppear: \(exception.localizedDescription)") + } } - func install() throws { - try swizzleInstanceMethod { originalImplementation in - return { [weak self] viewController, animated -> Void in - self?.handler.onViewDidAppearStart(viewController) - originalImplementation(viewController, Self.selector, animated) - self?.handler.onViewDidAppearEnd(viewController) + func instrumentViewDidDisappear(of viewControllerType: UIViewController.Type) { + let selector = #selector(UIViewController.viewDidDisappear(_:)) + do { + try swizzler.swizzleDeclaredInstanceMethod( + in: viewControllerType, + selector: selector, + implementationType: (@convention(c) (UIViewController, Selector, Bool) -> Void).self, + blockImplementationType: (@convention(block) (UIViewController, Bool) -> Void).self + ) { originalImplementation in + { viewController, animated in + self.handler.onViewDidDisappear(viewController) + originalImplementation(viewController, selector, animated) + } } + } catch let exception { + Embrace.logger.error("Error swizzling viewDidDisappear: \(exception.localizedDescription)") } } -} - -class UIViewControllerViewDidDisappearSwizzler: Swizzlable { - typealias ImplementationType = @convention(c) (UIViewController, Selector, Bool) -> Void - typealias BlockImplementationType = @convention(block) (UIViewController, Bool) -> Void - static var selector: Selector = #selector(UIViewController.viewDidDisappear(_:)) - var baseClass: AnyClass = UIViewController.self - - private let handler: UIViewControllerHandler - init(handler: UIViewControllerHandler) { - self.handler = handler + func instrumentInitWithCoder() { + let selector = #selector(UIViewController.init(coder:)) + do { + try swizzler.swizzleDeclaredInstanceMethod( + in: UIViewController.self, + selector: selector, + implementationType: ( + @convention(c) (UIViewController, Selector, NSCoder) -> UIViewController? + ).self, + blockImplementationType: ( + @convention(block) (UIViewController, NSCoder) -> UIViewController? + ).self + ) { originalImplementation in + { viewController, coder in + // Get the class and bundle path of the view controller being initialized and check + // if the view controller belongs to the main bundle (this excludes, for eaxmple, UIKit classes) + let viewControllerClass = type(of: viewController) + let viewControllerBundlePath = Bundle(for: viewControllerClass).bundlePath + guard viewControllerBundlePath.contains(self.bundlePath) else { + return originalImplementation(viewController, selector, coder) + } + + // If the view controller hasn't been swizzled yet, instrument its lifecycle + if !self.swizzlerCache.wasViewControllerSwizzled(withType: viewControllerClass) { + self.instrumentRender(of: viewControllerClass) + self.swizzlerCache.addNewSwizzled(viewControllerType: viewControllerClass) + } + + // return the result of the original implementation + return originalImplementation(viewController, selector, coder) + } + } + } catch let exception { + Embrace.logger.error("Error swizzling init(coder:): \(exception.localizedDescription)") + } } - func install() throws { - try swizzleInstanceMethod { originalImplementation in - return { [weak self] viewController, animated -> Void in - self?.handler.onViewDidDisappear(viewController) - originalImplementation(viewController, Self.selector, animated) + func instrumentInitWithNibAndBundle() { + let selector = #selector(UIViewController.init(nibName:bundle:)) + do { + try swizzler.swizzleDeclaredInstanceMethod( + in: UIViewController.self, + selector: selector, + implementationType: ( + @convention(c) (UIViewController, Selector, String?, Bundle?) -> UIViewController + ).self, + blockImplementationType: ( + @convention(block) (UIViewController, String?, Bundle?) -> UIViewController + ).self + ) { originalImplementation in + { viewController, nibName, bundle in + // Get the class and bundle path of the view controller being initialized and check + // if the view controller belongs to the main bundle (this excludes, for eaxmple, UIKit classes) + let viewControllerClass = type(of: viewController) + let viewControllerBundlePath = Bundle(for: viewControllerClass).bundlePath + guard viewControllerBundlePath.contains(self.bundlePath) else { + return originalImplementation(viewController, selector, nibName, bundle) + } + + // If the view controller hasn't been swizzled yet, instrument its lifecycle + if !self.swizzlerCache.wasViewControllerSwizzled(withType: viewControllerClass) { + self.instrumentRender(of: viewControllerClass) + self.swizzlerCache.addNewSwizzled(viewControllerType: viewControllerClass) + } + + // return the result of the original implementation + return originalImplementation(viewController, selector, nibName, bundle) } } + } catch let exception { + Embrace.logger.error("Error swizzling init(nibName:bundle:): \(exception.localizedDescription)") } } } - #endif diff --git a/Sources/EmbraceCore/Capture/UX/View/ViewCaptureServiceSwizzleCache.swift b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureServiceSwizzleCache.swift new file mode 100644 index 00000000..126c8c23 --- /dev/null +++ b/Sources/EmbraceCore/Capture/UX/View/ViewCaptureServiceSwizzleCache.swift @@ -0,0 +1,31 @@ +// +// Copyright © 2024 Embrace Mobile, Inc. All rights reserved. +// +#if canImport(UIKit) && !os(watchOS) +import UIKit +import EmbraceCommonInternal + +class ViewCaptureServiceSwizzlerCache { + @ThreadSafe + private var swizzledViewControllers: [UIViewController.Type] + + init(swizzledViewControllers: [UIViewController.Type] = []) { + self.swizzledViewControllers = swizzledViewControllers + } + + func wasViewControllerSwizzled(withType viewControllerType: UIViewController.Type) -> Bool { + swizzledViewControllers.contains(where: { viewControllerType == $0 }) + } + + func addNewSwizzled(viewControllerType: UIViewController.Type) { + swizzledViewControllers.append(viewControllerType) + } +} + +extension ViewCaptureServiceSwizzlerCache { + static func withDefaults() -> ViewCaptureServiceSwizzlerCache { + .init(swizzledViewControllers: [UIViewController.self]) + } +} + +#endif diff --git a/Sources/EmbraceCore/Capture/UX/View/ViewInstrumentationState.swift b/Sources/EmbraceCore/Capture/UX/View/ViewInstrumentationState.swift new file mode 100644 index 00000000..ea39bd83 --- /dev/null +++ b/Sources/EmbraceCore/Capture/UX/View/ViewInstrumentationState.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2024 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation + +class ViewInstrumentationState: NSObject { + var identifier: String? + var viewDidLoadSpanCreated = false + var viewWillAppearSpanCreated = false + var viewIsAppearingSpanCreated = false + var viewDidAppearSpanCreated = false + + init(identifier: String? = nil) { + self.identifier = identifier + } +} diff --git a/Sources/EmbraceSemantics/Span/SpanSemantics+View.swift b/Sources/EmbraceSemantics/Span/SpanSemantics+View.swift index d288effb..1834470a 100644 --- a/Sources/EmbraceSemantics/Span/SpanSemantics+View.swift +++ b/Sources/EmbraceSemantics/Span/SpanSemantics+View.swift @@ -16,6 +16,7 @@ public extension SpanSemantics { public static let timeToInteractiveName = "emb-NAME-time-to-interactive" public static let viewDidLoadName = "emb-view-did-load" public static let viewWillAppearName = "emb-view-will-appear" + public static let viewIsAppearingName = "emb-view-is-appearing" public static let viewDidAppearName = "emb-view-did-appear" public static let uiReadyName = "ui-ready" public static let keyViewTitle = "view.title" diff --git a/Tests/EmbraceCoreTests/Capture/UX/View/CaptureServicesUIViewControllerTests.swift b/Tests/EmbraceCoreTests/Capture/UX/View/CaptureServicesUIViewControllerTests.swift index bddedd98..9a376f2d 100644 --- a/Tests/EmbraceCoreTests/Capture/UX/View/CaptureServicesUIViewControllerTests.swift +++ b/Tests/EmbraceCoreTests/Capture/UX/View/CaptureServicesUIViewControllerTests.swift @@ -50,7 +50,7 @@ class CaptureServicesUIViewControllerTests: XCTestCase { func test_onInteractionReady() throws { // given capture services with a ViewCaptureService let handler = MockUIViewControllerHandler() - let service = ViewCaptureService(options: enabledOptions, lock: NSLock(), handler: handler) + let service = ViewCaptureService(options: enabledOptions, handler: handler, lock: NSLock()) let captureServices = CaptureServices(services: [service], context: context) let vc = MockViewController() @@ -102,7 +102,7 @@ class CaptureServicesUIViewControllerTests: XCTestCase { func test_buildChildSpan() throws { // given capture services with a ViewCaptureService with an active span let handler = MockUIViewControllerHandler() - let service = ViewCaptureService(options: enabledOptions, lock: NSLock(), handler: handler) + let service = ViewCaptureService(options: enabledOptions, handler: handler, lock: NSLock()) let captureServices = CaptureServices(services: [service], context: context) let vc = MockViewController() @@ -163,7 +163,7 @@ class CaptureServicesUIViewControllerTests: XCTestCase { func recordCompletedChildSpan() throws { // given capture services with a ViewCaptureService with an active span let handler = MockUIViewControllerHandler() - let service = ViewCaptureService(options: enabledOptions, lock: NSLock(), handler: handler) + let service = ViewCaptureService(options: enabledOptions, handler: handler, lock: NSLock()) let captureServices = CaptureServices(services: [service], context: context) let vc = MockViewController() diff --git a/Tests/EmbraceCoreTests/Capture/UX/View/MockUIViewControllerHandler.swift b/Tests/EmbraceCoreTests/Capture/UX/View/MockUIViewControllerHandler.swift index 209afadd..8bd8f6cd 100644 --- a/Tests/EmbraceCoreTests/Capture/UX/View/MockUIViewControllerHandler.swift +++ b/Tests/EmbraceCoreTests/Capture/UX/View/MockUIViewControllerHandler.swift @@ -19,32 +19,42 @@ class MockUIViewControllerHandler: UIViewControllerHandler { } var onViewDidLoadStartCalled = false - override func onViewDidLoadStart(_ vc: UIViewController) { + override func onViewDidLoadStart(_ vc: UIViewController, now: Date = Date()) { onViewDidLoadStartCalled = true } var onViewDidLoadEndCalled = false - override func onViewDidLoadEnd(_ vc: UIViewController) { + override func onViewDidLoadEnd(_ vc: UIViewController, now: Date = Date()) { onViewDidLoadEndCalled = true } var onViewWillAppearStartCalled = false - override func onViewWillAppearStart(_ vc: UIViewController) { + override func onViewWillAppearStart(_ vc: UIViewController, now: Date = Date()) { onViewWillAppearStartCalled = true } var onViewWillAppearEndCalled = false - override func onViewWillAppearEnd(_ vc: UIViewController) { + override func onViewWillAppearEnd(_ vc: UIViewController, now: Date = Date()) { onViewWillAppearEndCalled = true } + var onViewIsAppearingStartCalled = false + override func onViewIsAppearingStart(_ vc: UIViewController, now: Date = Date()) { + onViewIsAppearingStartCalled = true + } + + var onViewIsAppearingEndCalled = false + override func onViewIsAppearingEnd(_ vc: UIViewController, now: Date = Date()) { + onViewIsAppearingEndCalled = true + } + var onViewDidAppearStartCalled = false - override func onViewDidAppearStart(_ vc: UIViewController) { + override func onViewDidAppearStart(_ vc: UIViewController, now: Date = Date()) { onViewDidAppearStartCalled = true } var onViewDidAppearEndCalled = false - override func onViewDidAppearEnd(_ vc: UIViewController) { + override func onViewDidAppearEnd(_ vc: UIViewController, now: Date = Date()) { onViewDidAppearEndCalled = true } diff --git a/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift b/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift index df98d04f..4c02b627 100644 --- a/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift +++ b/Tests/EmbraceCoreTests/Capture/UX/View/UIViewControllerHandlerTests.swift @@ -34,7 +34,7 @@ class UIViewControllerHandlerTests: XCTestCase { // then it is succesfully fetched let vc = MockViewController() - vc.emb_identifier = id + vc.emb_instrumentation_state = .init(identifier: id) let parent = handler.parentSpan(for: vc) XCTAssertEqual(span.name, parent!.name) @@ -47,7 +47,7 @@ class UIViewControllerHandlerTests: XCTestCase { // when fetching the parent span for a view controller // that doesn't have one let vc = MockViewController() - vc.emb_identifier = "test" + vc.emb_instrumentation_state = .init(identifier: "test") // then the result is nil let parent = handler.parentSpan(for: vc) @@ -88,6 +88,9 @@ class UIViewControllerHandlerTests: XCTestCase { let viewWillAppearSpan = createViewWillAppearSpan() handler.viewWillAppearSpans[id] = viewWillAppearSpan + let viewIsAppearing = createViewWillAppearSpan() + handler.viewIsAppearingSpans[id] = viewIsAppearing + let viewDidAppearSpan = createViewDidAppearSpan() handler.viewDidAppearSpans[id] = viewDidAppearSpan @@ -102,7 +105,7 @@ class UIViewControllerHandlerTests: XCTestCase { // then all spans are ended wait { - return self.otel.spanProcessor.endedSpans.count == 6 + return self.otel.spanProcessor.endedSpans.count == 7 } } @@ -169,6 +172,7 @@ class UIViewControllerHandlerTests: XCTestCase { let parentName = "time-to-first-render" validateViewDidLoadSpans(vc: vc, parentName: parentName) validateViewWillAppearSpans(vc: vc, parentName: parentName) + validateViewIsAppearingSpans(vc: vc, parentName: parentName) validateViewDidAppearSpans(vc: vc, parentName: parentName) // then all the spans are created and ended at the right times @@ -223,6 +227,7 @@ class UIViewControllerHandlerTests: XCTestCase { let parentName = "time-to-interactive" validateViewDidLoadSpans(vc: vc, parentName: parentName) validateViewWillAppearSpans(vc: vc, parentName: parentName) + validateViewIsAppearingSpans(vc: vc, parentName: parentName) validateViewDidAppearSpans(vc: vc, parentName: parentName) // when view did appear ends @@ -257,6 +262,7 @@ class UIViewControllerHandlerTests: XCTestCase { validateViewDidLoadSpans(vc: vc, parentName: parentName) handler.onViewBecameInteractive(vc) validateViewWillAppearSpans(vc: vc, parentName: parentName) + validateViewIsAppearingSpans(vc: vc, parentName: parentName) validateViewDidAppearSpans(vc: vc, parentName: parentName) // then the spans are ended @@ -349,6 +355,29 @@ class UIViewControllerHandlerTests: XCTestCase { } } + func validateViewIsAppearingSpans(vc: UIViewController, parentName: String) { + // when view is appearing starts + handler.onViewIsAppearingStart(vc) + + // then a child span is created + wait(timeout: .longTimeout) { + let parent = self.otel.spanProcessor.startedSpans.first(where: { $0.name.contains(parentName) }) + let child = self.otel.spanProcessor.startedSpans.first(where: { $0.name == "emb-view-is-appearing"}) + + return parent != nil && child!.parentSpanId == parent!.spanId && child!.embType == .viewLoad + } + + // when view is appearing ends + handler.onViewIsAppearingEnd(vc) + + // then the view will appear span is ended + wait(timeout: .longTimeout) { + let span = self.otel.spanProcessor.endedSpans.first(where: { $0.name == "emb-view-is-appearing"}) + return span != nil && self.handler.viewIsAppearingSpans.isEmpty + } + } + + func validateViewDidAppearSpans(vc: UIViewController, parentName: String) { // when view did appear starts handler.onViewDidAppearStart(vc) @@ -377,12 +406,13 @@ class UIViewControllerHandlerTests: XCTestCase { func cacheIsEmpty(_ checkVisibilitySpans: Bool = false) -> Bool { return handler.parentSpans.count == 0 && - handler.viewDidLoadSpans.count == 0 && - handler.viewWillAppearSpans.count == 0 && - handler.viewDidAppearSpans.count == 0 && - (!checkVisibilitySpans || handler.visibilitySpans.count == 0) && - handler.uiReadySpans.count == 0 && - handler.alreadyFinishedUiReadyIds.count == 0 + handler.viewDidLoadSpans.count == 0 && + handler.viewWillAppearSpans.count == 0 && + handler.viewIsAppearingSpans.count == 0 && + handler.viewDidAppearSpans.count == 0 && + (!checkVisibilitySpans || handler.visibilitySpans.count == 0) && + handler.uiReadySpans.count == 0 && + handler.alreadyFinishedUiReadyIds.count == 0 } } @@ -413,6 +443,10 @@ extension UIViewControllerHandlerTests { return createSpan(name: "view-will-appear") } + func createViewIsAppearingSpan() -> Span { + return createSpan(name: "view-is-appearing") + } + func createViewDidAppearSpan() -> Span { return createSpan(name: "view-did-appear") } diff --git a/Tests/EmbraceCoreTests/Capture/UX/View/ViewCaptureServiceTests.swift b/Tests/EmbraceCoreTests/Capture/UX/View/ViewCaptureServiceTests.swift index 41b3521f..a4907cca 100644 --- a/Tests/EmbraceCoreTests/Capture/UX/View/ViewCaptureServiceTests.swift +++ b/Tests/EmbraceCoreTests/Capture/UX/View/ViewCaptureServiceTests.swift @@ -17,7 +17,7 @@ class ViewCaptureServiceTests: XCTestCase { override func setUpWithError() throws { handler = MockUIViewControllerHandler() - service = ViewCaptureService(options: ViewCaptureService.Options(), lock: NSLock(), handler: handler) + service = ViewCaptureService(options: ViewCaptureService.Options(), handler: handler, lock: NSLock()) service.install(otel: nil) service.start() } @@ -33,15 +33,27 @@ class ViewCaptureServiceTests: XCTestCase { XCTAssert(handler.onViewDidLoadEndCalled) } - func test_viewWillAppear() { + func test_viewWillAppear_wontCallMethodsIfStateIsNotSet() { + let vc = MockViewController() + vc.emb_instrumentation_state = nil + vc.viewWillAppear(false) + + XCTAssertFalse(handler.onViewWillAppearStartCalled) + XCTAssertFalse(handler.onViewWillAppearEndCalled) + XCTAssertFalse(handler.onViewIsAppearingStartCalled) + } + + func test_viewWillAppear_wontCallsMethodsIfStateIsSet() { // given a ViewCaptureService // when viewWillAppear is called on a UIViewController let vc = MockViewController() + vc.emb_instrumentation_state = .init() vc.viewWillAppear(false) // then the appropiate methods are called on the handler - XCTAssert(handler.onViewWillAppearStartCalled) - XCTAssert(handler.onViewWillAppearEndCalled) + XCTAssertTrue(handler.onViewWillAppearStartCalled) + XCTAssertTrue(handler.onViewWillAppearEndCalled) + XCTAssertTrue(handler.onViewIsAppearingStartCalled) } func test_viewDidAppear() {