diff --git a/Agent.xcodeproj/project.pbxproj b/Agent.xcodeproj/project.pbxproj index 58a98d99..6436714a 100644 --- a/Agent.xcodeproj/project.pbxproj +++ b/Agent.xcodeproj/project.pbxproj @@ -1576,6 +1576,8 @@ F8A455492AFBE31E0057B1E0 /* NRMAURLSessionHeaderTrackingTestsOldEventSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = F8A455472AFBE31E0057B1E0 /* NRMAURLSessionHeaderTrackingTestsOldEventSystem.m */; }; F8AC3E932938FD6C002B4AA8 /* NRMAFakeDataHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = F8AC3E922938FD6C002B4AA8 /* NRMAFakeDataHelper.m */; }; F8AC3E942938FD6C002B4AA8 /* NRMAFakeDataHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = F8AC3E922938FD6C002B4AA8 /* NRMAFakeDataHelper.m */; }; + F8CCE8752EBBA6560042C230 /* NRMAUIImageOverride.m in Sources */ = {isa = PBXBuildFile; fileRef = F8CCE8732EBBA6560042C230 /* NRMAUIImageOverride.m */; }; + F8CCE8762EBBA6560042C230 /* NRMAUIImageOverride.h in Headers */ = {isa = PBXBuildFile; fileRef = F8CCE8722EBBA6560042C230 /* NRMAUIImageOverride.h */; }; F8E17C542DB681820098C3CB /* NRLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E17C532DB681820098C3CB /* NRLogger.swift */; }; F8E17C552DB681820098C3CB /* NRLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E17C532DB681820098C3CB /* NRLogger.swift */; }; F8E17C562DB681820098C3CB /* NRLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E17C532DB681820098C3CB /* NRLogger.swift */; }; @@ -2613,6 +2615,8 @@ F8A455472AFBE31E0057B1E0 /* NRMAURLSessionHeaderTrackingTestsOldEventSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NRMAURLSessionHeaderTrackingTestsOldEventSystem.m; sourceTree = ""; }; F8AC3E922938FD6C002B4AA8 /* NRMAFakeDataHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NRMAFakeDataHelper.m; sourceTree = ""; }; F8AC3EA72938FDDB002B4AA8 /* NRMAFakeDataHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NRMAFakeDataHelper.h; sourceTree = ""; }; + F8CCE8722EBBA6560042C230 /* NRMAUIImageOverride.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NRMAUIImageOverride.h; sourceTree = ""; }; + F8CCE8732EBBA6560042C230 /* NRMAUIImageOverride.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NRMAUIImageOverride.m; sourceTree = ""; }; F8E17C532DB681820098C3CB /* NRLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NRLogger.swift; sourceTree = ""; }; F8E202DD2B07BA61008E0B7B /* NRMAOfflineStorage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NRMAOfflineStorage.m; sourceTree = ""; }; F8E202F32B07BA6E008E0B7B /* NRMAOfflineStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NRMAOfflineStorage.h; sourceTree = ""; }; @@ -3370,6 +3374,7 @@ 02FF486C24DC618F00115469 /* NRMAThread.h */, 02FF486E24DC618F00115469 /* NRMAThread.m */, 02FF486B24DC618F00115469 /* NRMAThreadTransition.h */, + F8CCE8742EBBA6560042C230 /* UIImage */, 02FF486A24DC618F00115469 /* NRMAThreadTransition.m */, ); path = Instrumentation; @@ -4152,6 +4157,15 @@ path = AttributeValidator; sourceTree = ""; }; + F8CCE8742EBBA6560042C230 /* UIImage */ = { + isa = PBXGroup; + children = ( + F8CCE8722EBBA6560042C230 /* NRMAUIImageOverride.h */, + F8CCE8732EBBA6560042C230 /* NRMAUIImageOverride.m */, + ); + path = UIImage; + sourceTree = ""; + }; F8FBFA3C2A71A32400CDC8C5 /* Events */ = { isa = PBXGroup; children = ( @@ -4348,6 +4362,7 @@ 02FF493924DC625A00115469 /* NRMAHexUploader.h in Headers */, 02FF48EA24DC622700115469 /* NRMATimestampContainer.h in Headers */, 02FF4A6D24DC64E600115469 /* NRMAActivityTraceMeasurementProducer.h in Headers */, + F8CCE8762EBBA6560042C230 /* NRMAUIImageOverride.h in Headers */, 02FF49F024DC645B00115469 /* NRMAAppUpgradeMetricGenerator.h in Headers */, 02FF484124DC614200115469 /* NRMATraceMachineAgentUserInterface.h in Headers */, 2B496B1C2C530A7800A0459E /* reflection_generated.h in Headers */, @@ -5681,6 +5696,7 @@ 2BAE5C782E85FA62001D2B88 /* SwiftUIDrawingThingy.swift in Sources */, 02FF49CB24DC62B800115469 /* NRMAHarvestableMetric.m in Sources */, 02FF489A24DC61D200115469 /* NRMAURLSessionTaskDelegate.m in Sources */, + F8CCE8752EBBA6560042C230 /* NRMAUIImageOverride.m in Sources */, 02FF482224DB5B8100115469 /* NRMATableViewIntrumentation.m in Sources */, 02FF482124DB5B8100115469 /* NRMAApplicationInstrumentation.m in Sources */, 2B81C3442E996304002F5593 /* MaskedContainerView.swift in Sources */, diff --git a/Agent/APrivateHeader.h b/Agent/APrivateHeader.h index e5b02458..503074c4 100644 --- a/Agent/APrivateHeader.h +++ b/Agent/APrivateHeader.h @@ -22,5 +22,6 @@ #import "NRMABool.h" #import "Constants.h" #import "NRMAExceptionMetaDataStore.h" +#import "NRMAUIImageOverride.h" #endif /* APrivateHeader_h */ diff --git a/Agent/Instrumentation/NSURLSession/NRMAURLSessionOverride.m b/Agent/Instrumentation/NSURLSession/NRMAURLSessionOverride.m index 4c0d2013..fe390c5f 100644 --- a/Agent/Instrumentation/NSURLSession/NRMAURLSessionOverride.m +++ b/Agent/Instrumentation/NSURLSession/NRMAURLSessionOverride.m @@ -20,6 +20,8 @@ #import "NRMAAssociate.h" #import "NRMAURLSessionTaskSearch.h" #import "NRMAFlags.h" +#import "NRMAUIImageOverride.h" +#import "NewRelicAgentInternal.h" #define NRMASwizzledMethodPrefix @"_NRMAOverride__" @@ -295,7 +297,12 @@ + (void)swizzleURLSessionTask } // NRLOG_AGENT_VERBOSE(@"NRMA__recordTask called from NRMAOverride__dataTaskWithRequest_completionHandler"); - +#if TARGET_OS_IOS + if ([[NewRelicAgentInternal sharedInstance] isSessionReplayEnabled] && [[NewRelicAgentInternal sharedInstance] isSessionReplaySampled]) { + [NRMAUIImageOverride registerURL:response.URL forData:data]; + } +#endif + NRMA__recordTask(task,data,response,error); completionHandler(data,response,error); diff --git a/Agent/Instrumentation/UIImage/NRMAUIImageOverride.h b/Agent/Instrumentation/UIImage/NRMAUIImageOverride.h new file mode 100644 index 00000000..b6a78ba4 --- /dev/null +++ b/Agent/Instrumentation/UIImage/NRMAUIImageOverride.h @@ -0,0 +1,18 @@ +// +// NRMAUIImageOverride.h +// Agent +// +// Created by Mike Bruin on 1/5/25. +// Copyright © 2025 New Relic. All rights reserved. +// + +#import +#import + +@interface NRMAUIImageOverride : NSObject + ++ (void)beginInstrumentation; ++ (void)deinstrument; ++ (void)registerURL:(NSURL*)url forData:(NSData*)data; + +@end diff --git a/Agent/Instrumentation/UIImage/NRMAUIImageOverride.m b/Agent/Instrumentation/UIImage/NRMAUIImageOverride.m new file mode 100644 index 00000000..a3f38a3c --- /dev/null +++ b/Agent/Instrumentation/UIImage/NRMAUIImageOverride.m @@ -0,0 +1,200 @@ +// +// NRMAUIImageOverride.m +// Agent +// +// Created by Mike Bruin on 1/5/25. +// Copyright © 2025 New Relic. All rights reserved. +// + +#import "NRMAUIImageOverride.h" +#import "NRMAMethodSwizzling.h" +#import "NRLogger.h" +#import +#import +#import + +static IMP NRMAOriginal__initWithData; +static IMP NRMAOriginal__initWithData_scale; + +// Global registry to map data hashes to URLs +static NSMapTable *dataHashToURLMap; +static NSLock *registryLock; + +// Flag to prevent double swizzling +static BOOL isSwizzled = NO; + +// Forward declarations +UIImage* NRMAOverride__initWithData(UIImage* self, SEL _cmd, NSData* data); +UIImage* NRMAOverride__initWithData_scale(UIImage* self, SEL _cmd, NSData* data, CGFloat scale); +NSString* NRMA_HashForData(NSData* data); + +@implementation NRMAUIImageOverride + ++ (void)beginInstrumentation { + // Prevent double swizzling + if (isSwizzled) { + NRLOG_AGENT_DEBUG(@"NRMAUIImageOverride - Already instrumented, skipping swizzling"); + return; + } + + Class clazz = [UIImage class]; + + // Initialize the global registry and lock + if (dataHashToURLMap == nil) { + dataHashToURLMap = [NSMapTable strongToStrongObjectsMapTable]; + registryLock = [[NSLock alloc] init]; + } + + if (clazz) { + // Swizzle initWithData: + NRMAOriginal__initWithData = NRMASwapImplementations(clazz, + @selector(initWithData:), + (IMP)NRMAOverride__initWithData); + + // Swizzle initWithData:scale: + NRMAOriginal__initWithData_scale = NRMASwapImplementations(clazz, + @selector(initWithData:scale:), + (IMP)NRMAOverride__initWithData_scale); + + // Mark as swizzled + isSwizzled = YES; + NRLOG_AGENT_DEBUG(@"NRMAUIImageOverride - Instrumentation completed successfully"); + } +} + +// Public method to register a URL for NSData ++ (void)registerURL:(NSURL*)url forData:(NSData*)data { + if (url == nil || data == nil) return; + + NSString *hash = NRMA_HashForData(data); + [registryLock lock]; + + [dataHashToURLMap setObject:url forKey:hash]; + + [registryLock unlock]; + + NRLOG_AGENT_DEBUG(@"NRMAUIImageOverride map size: %lu", (unsigned long)dataHashToURLMap.count); +} + ++ (void)deinstrument { + Class clazz = [UIImage class]; + + if (clazz) { + if (NRMAOriginal__initWithData != nil) { + NRMASwapImplementations(clazz, @selector(initWithData:), (IMP)NRMAOriginal__initWithData); + NRMAOriginal__initWithData = nil; + } + + if (NRMAOriginal__initWithData_scale != nil) { + NRMASwapImplementations(clazz, @selector(initWithData:scale:), (IMP)NRMAOriginal__initWithData_scale); + NRMAOriginal__initWithData_scale = nil; + } + } + + // Clean up registry + [registryLock lock]; + [dataHashToURLMap removeAllObjects]; + [registryLock unlock]; + + // Reset the swizzled flag + isSwizzled = NO; +} + +@end + +// Swizzled implementation for initWithData: +UIImage* NRMAOverride__initWithData(UIImage* self, SEL _cmd, NSData* data) { + if (NRMAOriginal__initWithData == nil || data == nil) { + return nil; + } + + NSString *hash = NRMA_HashForData(data); + [registryLock lock]; + NSURL* url = [dataHashToURLMap objectForKey:hash]; + + // Remove the entry after retrieving it (automatic cleanup after use) + if (url != nil) { + [dataHashToURLMap removeObjectForKey:hash]; + } + + [registryLock unlock]; + + // Call original implementation + UIImage* image = ((UIImage*(*)(id, SEL, NSData*))NRMAOriginal__initWithData)(self, _cmd, data); + + if (image != nil && url != nil) { + // Attach the URL to the newly created UIImage + image.NRSessionReplayImageURL = url; + NRLOG_AGENT_DEBUG(@"NRMAOverride__initWithData - Successfully attached URL to image: %@", url); + } + + return image; +} + +// Swizzled implementation for initWithData:scale: +UIImage* NRMAOverride__initWithData_scale(UIImage* self, SEL _cmd, NSData* data, CGFloat scale) { + if (NRMAOriginal__initWithData_scale == nil || data == nil) { + return nil; + } + + NSString *hash = NRMA_HashForData(data); + [registryLock lock]; + NSURL* url = [dataHashToURLMap objectForKey:hash]; + + // Remove the entry after retrieving it (automatic cleanup after use) + if (url != nil) { + [dataHashToURLMap removeObjectForKey:hash]; + } + + [registryLock unlock]; + + // Call original implementation + UIImage* image = ((UIImage*(*)(id, SEL, NSData*, CGFloat))NRMAOriginal__initWithData_scale)(self, _cmd, data, scale); + + if (image != nil && url != nil) { + // Attach the URL to the newly created UIImage + image.NRSessionReplayImageURL = url; + NRLOG_AGENT_DEBUG(@"NRMAOverride__initWithData:scale: - Successfully attached URL to image: %@", url); + } + + return image; +} + +// Helper function to generate a hash for NSData +NSString* NRMA_HashForData(NSData* data) { + if (data == nil || data.length == 0) { + return nil; + } + + // For performance, only hash the first 1KB and last 1KB along with the length + // This is sufficient to uniquely identify image data in most cases + NSUInteger length = data.length; + NSUInteger hashLength = MIN(1024, length); + + unsigned char hash[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_CTX ctx; + CC_SHA256_Init(&ctx); + + // Hash the length + CC_SHA256_Update(&ctx, &length, sizeof(length)); + + // Hash first chunk + CC_SHA256_Update(&ctx, data.bytes, (CC_LONG)hashLength); + + // If data is large enough, also hash last chunk + if (length > 2048) { + const void *lastChunk = data.bytes + (length - hashLength); + CC_SHA256_Update(&ctx, lastChunk, (CC_LONG)hashLength); + } + + CC_SHA256_Final(hash, &ctx); + + // Convert to hex string + NSMutableString *hashString = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { + [hashString appendFormat:@"%02x", hash[i]]; + } + + return hashString; +} + diff --git a/Agent/SessionReplay/NRMASessionReplay.swift b/Agent/SessionReplay/NRMASessionReplay.swift index 2d38e669..c1b56b81 100644 --- a/Agent/SessionReplay/NRMASessionReplay.swift +++ b/Agent/SessionReplay/NRMASessionReplay.swift @@ -59,6 +59,8 @@ public class NRMASessionReplay: NSObject { } self.sessionReplayTouchCapture = SessionReplayTouchCapture(window: window) swizzleSendEvent() + // Instrument UIImage to preserve URL from NSData + NRMAUIImageOverride.beginInstrumentation() } } } diff --git a/Agent/SessionReplay/ViewCaptors/UIImageViewThingy.swift b/Agent/SessionReplay/ViewCaptors/UIImageViewThingy.swift index 5be91d88..196289cc 100644 --- a/Agent/SessionReplay/ViewCaptors/UIImageViewThingy.swift +++ b/Agent/SessionReplay/ViewCaptors/UIImageViewThingy.swift @@ -16,6 +16,8 @@ class UIImageViewThingy: SessionReplayViewThingy { var viewDetails: ViewDetails let imagePlaceholderCSS = "background: rgb(2,0,36);background: linear-gradient(90deg, rgba(2,0,36,1) 0%, rgba(0,212,255,1) 100%);" var image: UIImage? + + var imageURL: URL? var contentMode: [String: String] var shouldRecordSubviews: Bool { @@ -37,7 +39,11 @@ class UIImageViewThingy: SessionReplayViewThingy { self.isMasked = NRMAHarvestController.configuration()?.session_replay_maskAllImages ?? true } if !self.isMasked { - self.image = view.image + if let url = view.image?.NRSessionReplayImageURL { + imageURL = url + } else { + self.image = view.image + } } self.contentMode = UIImageViewThingy.contentModeToCSS(contentMode: view.contentMode) @@ -57,8 +63,12 @@ class UIImageViewThingy: SessionReplayViewThingy { } if !self.isMasked { if let cgImage = cgImage { - let uiImage = UIImage(cgImage: cgImage, scale: swiftUIImage.scale, orientation: swiftUIImage.orientation.toUIImageOrientation()) + if let url = cgImage.NRSessionReplayImageURL { + imageURL = url + } else { + let uiImage = UIImage(cgImage: cgImage, scale: swiftUIImage.scale, orientation: swiftUIImage.orientation.toUIImageOrientation()) self.image = uiImage + } } } @@ -91,14 +101,23 @@ class UIImageViewThingy: SessionReplayViewThingy { } func generateRRWebAdditionNode(parentNodeId: Int) -> [RRWebMutationData.AddRecord] { + let elementNode = ElementNodeData(id: viewDetails.viewId, + tagName: .image, + attributes: ["id":viewDetails.cssSelector], + childNodes: []) + elementNode.attributes["style"] = inlineCSSDescription() // Create the img element let imgNode = ElementNodeData(id: viewDetails.viewId + 1000000, // Use offset to avoid ID conflicts tagName: .image, attributes: [:], childNodes: []) imgNode.attributes["style"] = imageInlineCSSDescription() - if let imageData = image?.optimizedPngData() { - imgNode.attributes["src"] = "data:image/png;base64,\(imageData.base64EncodedString())" + if !isMasked { + if let url = imageURL { + imgNode.attributes["src"] = url.absoluteString + } else if let imageData = image?.optimizedPngData() { + imgNode.attributes["src"] = "data:image/png;base64,\(imageData.base64EncodedString())" + } } // Create the container div element @@ -121,8 +140,12 @@ class UIImageViewThingy: SessionReplayViewThingy { attributes: [:], childNodes: []) imgNode.attributes["style"] = imageInlineCSSDescription() - if let imageData = image?.optimizedPngData() { - imgNode.attributes["src"] = "data:image/png;base64,\(imageData.base64EncodedString())" + if let url = imageURL { + imgNode.attributes["src"] = url.absoluteString + } else { + if let imageData = image?.optimizedPngData() { + imgNode.attributes["src"] = "data:image/png;base64,\(imageData.base64EncodedString())" + } } // Create and return the container div @@ -132,14 +155,22 @@ class UIImageViewThingy: SessionReplayViewThingy { childNodes: [.element(imgNode)]) } - static func imagesAreLikelyEqual(_ img1: UIImage?, _ img2: UIImage?) -> Bool { - guard let img1 = img1, let img2 = img2 else { return img1 == nil && img2 == nil } - - // Compare optimized image data - let data1 = img1.optimizedPngData() - let data2 = img2.optimizedPngData() - - return data1 == data2 + // Helper to produce stable src representation + private func currentSrcRepresentation() -> String? { + guard !isMasked else { return nil } + if let url = imageURL { return url.absoluteString } + if let data = image?.optimizedPngData() { return "data:image/png;base64,\(data.base64EncodedString())" } + return nil + } + + static func imagesOrURLsAreLikelyEqual(lhs: UIImageViewThingy, rhs: UIImageViewThingy) -> Bool { + if lhs.isMasked && rhs.isMasked { return true } + if lhs.isMasked != rhs.isMasked { return false } + if let lURL = lhs.imageURL, let rURL = rhs.imageURL { return lURL == rURL } + // Fallback: compare optimized PNG data representations + let ld = lhs.image?.optimizedPngData() + let rd = rhs.image?.optimizedPngData() + return ld == rd } func generateDifference(from other: T) -> [MutationRecord] { @@ -162,10 +193,10 @@ class UIImageViewThingy: SessionReplayViewThingy { imgAttributes["style"] = typedOther.imageInlineCSSDescription() if !typedOther.isMasked { - if !UIImageViewThingy.imagesAreLikelyEqual(self.image, typedOther.image) { - if let imageData = typedOther.image?.optimizedPngData() { - imgAttributes["src"] = "data:image/png;base64,\(imageData.base64EncodedString())" - } + let oldSrc = self.currentSrcRepresentation() + let newSrc = typedOther.currentSrcRepresentation() + if oldSrc != newSrc, let newSrc = newSrc { + imgAttributes["src"] = newSrc } } @@ -180,14 +211,18 @@ class UIImageViewThingy: SessionReplayViewThingy { extension UIImageViewThingy: Equatable { static func == (lhs: UIImageViewThingy, rhs: UIImageViewThingy) -> Bool { - return lhs.viewDetails == rhs.viewDetails && UIImageViewThingy.imagesAreLikelyEqual(lhs.image, rhs.image) + return lhs.viewDetails == rhs.viewDetails && UIImageViewThingy.imagesOrURLsAreLikelyEqual(lhs: lhs, rhs: rhs) } } extension UIImageViewThingy: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(viewDetails) - hasher.combine(image?.hashValue ?? 0) + if let url = imageURL { + hasher.combine(url.absoluteString) + } else { + hasher.combine(image?.hashValue ?? 0) + } } } @@ -252,6 +287,46 @@ extension UIImageViewThingy { } } + +fileprivate var associatedSessionReplayImageURLKey: String = "NRSessionReplayImageURL" + +extension UIImage { + /// Public hook allowing host apps to supply the original remote image URL so Session Replay can reference + /// the URL instead of embedding base64 image data when possible. + @objc public var NRSessionReplayImageURL: URL? { + set { + withUnsafePointer(to: &associatedSessionReplayImageURLKey) { + objc_setAssociatedObject(self, $0, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + self.cgImage?.NRSessionReplayImageURL = newValue + } + + get { + withUnsafePointer(to: &associatedSessionReplayImageURLKey) { + objc_getAssociatedObject(self, $0) as? URL + } + } + } +} + +extension CGImage { + + var NRSessionReplayImageURL: URL? { + set { + withUnsafePointer(to: &associatedSessionReplayImageURLKey) { + objc_setAssociatedObject(self, $0, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + get { + withUnsafePointer(to: &associatedSessionReplayImageURLKey) { + objc_getAssociatedObject(self, $0) as? URL + } + } + } +} + + fileprivate var associatedOptimizedImageDataKey: String = "SessionReplayOptimizedImageData" internal extension UIImage { @@ -305,5 +380,3 @@ internal extension UIImage { return resizedImage.pngData() } } - - diff --git a/Test Harness/NRTestApp/NRTestApp/ViewControllers/InfiniteImageCollectionViewController.swift b/Test Harness/NRTestApp/NRTestApp/ViewControllers/InfiniteImageCollectionViewController.swift index 005f3bfd..2a42b6ce 100644 --- a/Test Harness/NRTestApp/NRTestApp/ViewControllers/InfiniteImageCollectionViewController.swift +++ b/Test Harness/NRTestApp/NRTestApp/ViewControllers/InfiniteImageCollectionViewController.swift @@ -163,7 +163,7 @@ class ImageCollectionViewCell: UICollectionViewCell { self?.activityIndicator.stopAnimating() guard let data = data, let image = UIImage(data: data) else { return } - + //image.NRSessionReplayImageURL = url cache.setObject(image, forKey: cacheKey) self?.imageView.image = image }