Skip to content
4 changes: 3 additions & 1 deletion Sources/LaunchDarklySessionReplay/API/LDReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ public final class LDReplay {
public static var shared = LDReplay()

/// Hook proxy for the C# / MAUI bridge. Set by the SessionReplay plugin during getHooks().
public var hookProxy: SessionReplayHookProxy?
public var hookProxy: SessionReplayHookProxy? {
client.map { SessionReplayHookProxy(sessionReplayService: $0) }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Computed hookProxy creates new instance on every access

Medium Severity

The hookProxy property changed from a stored property to a computed property, meaning every access allocates a new SessionReplayHookProxy (an NSObject subclass) instead of returning a cached instance. This is particularly wasteful for the C#/MAUI bridge, where each call like LDReplay.shared.hookProxy?.afterIdentify(...) creates a throwaway object. The sibling LDObserve.shared.hookProxy remains a stored property set once in getHooks(), making this inconsistent across the two plugins.

Fix in Cursor Fix in Web


var client: SessionReplayServicing?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ extension SessionReplayAPIService {
""",
variables: InitializeSessionVariables(
sessionSecureId: sessionSecureId,
organizationVerboseId: context.sdkKey,
organizationVerboseId: context.sdkKey,
enableStrictPrivacy: false,
privacySetting: "none",
enableRecordingNetworkContents: false,
Expand Down
9 changes: 6 additions & 3 deletions Sources/LaunchDarklySessionReplay/SessionReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import LaunchDarkly
import Foundation
import LaunchDarklyObservability
import OSLog
#if !LD_COCOAPODS
import Common
#endif

public final class SessionReplay: Plugin {
let sessionReplayHook = SessionReplayHook()
let options: SessionReplayOptions
var sessionReplayService: SessionReplayService?
var observabilityContext: ObservabilityContext?
Expand Down Expand Up @@ -34,7 +38,7 @@ public final class SessionReplay: Plugin {
metadata: metadata)
LDReplay.shared.client = sessionReplayService
self.sessionReplayService = sessionReplayService

sessionReplayHook.delegate = sessionReplayService
if options.isEnabled {
start()
}
Expand All @@ -44,8 +48,7 @@ public final class SessionReplay: Plugin {
}

public func getHooks(metadata: EnvironmentMetadata) -> [any Hook] {
LDReplay.shared.hookProxy = SessionReplayHookProxy(plugin: self)
return [SessionReplayHook(plugin: self)]
return [sessionReplayHook]
}

public func start() {
Expand Down
34 changes: 17 additions & 17 deletions Sources/LaunchDarklySessionReplay/SessionReplayHook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,31 @@ import LaunchDarklyObservability
import Common
#endif

/// Hook protocol adapter for native Swift SDK usage.
/// Extracts data from SDK types and delegates to SessionReplayHookExporter.
final class SessionReplayHook: Hook {
private let plugin: SessionReplay

init(plugin: SessionReplay) {
self.plugin = plugin
weak var delegate: SessionReplayServicing?

init() {
}

public func metadata() -> Metadata {
return Metadata(name: "SessionReplay")
}

public func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData {
guard case .complete = result else {
return seriesData
}

guard let options = plugin.observabilityContext?.options else {
guard case .complete = result, let delegate else {
return seriesData
}
let sessionAttributes = plugin.observabilityContext?.sessionAttributes
Task {
let identifyPayload = await IdentifyItemPayload(options: options, sessionAttributes: sessionAttributes, ldContext: seriesContext.context, timestamp: Date().timeIntervalSince1970)
await plugin.sessionReplayService?.scheduleIdentifySession(identifyPayload: identifyPayload)
}


var keys = [String: String]()
for (k, v) in seriesContext.context.contextKeys() { keys[k] = v }

delegate.afterIdentify(
contextKeys: keys,
canonicalKey: seriesContext.context.fullyQualifiedKey(),
completed: true
)
return seriesData
}
}
32 changes: 11 additions & 21 deletions Sources/LaunchDarklySessionReplay/SessionReplayHookProxy.swift
Original file line number Diff line number Diff line change
@@ -1,39 +1,29 @@
import Foundation
import LaunchDarklyObservability
#if LD_COCOAPODS
import LaunchDarklyObservability
#else
import Common
#endif

/// @objc adapter for the C# / MAUI bridge.
/// Converts Foundation types (NSObject, NSDictionary) to Swift types
/// and delegates to SessionReplay so the replay identify logic is accessible
/// from the Xamarin.iOS binding.
/// Converts Foundation types to Swift types
/// and delegates to SessionReplayHookExporter.
@objc(SessionReplayHookProxy)
public final class SessionReplayHookProxy: NSObject {
private let plugin: SessionReplay
private let sessionReplayService: SessionReplayServicing

init(plugin: SessionReplay) {
self.plugin = plugin
init(sessionReplayService: SessionReplayServicing) {
self.sessionReplayService = sessionReplayService
super.init()
}

@objc(afterIdentifyWithContextKeys:canonicalKey:completed:)
public func afterIdentify(contextKeys: NSDictionary, canonicalKey: String, completed: Bool) {
guard completed else { return }
guard let options = plugin.observabilityContext?.options else { return }

var keys = [String: String]()
for (k, v) in contextKeys {
if let key = k as? String, let val = v as? String { keys[key] = val }
}

let sessionAttributes = plugin.observabilityContext?.sessionAttributes
Task {
let identifyPayload = IdentifyItemPayload(
options: options,
sessionAttributes: sessionAttributes,
contextKeys: keys,
canonicalKey: canonicalKey,
timestamp: Date().timeIntervalSince1970
)
await plugin.sessionReplayService?.scheduleIdentifySession(identifyPayload: identifyPayload)
}
sessionReplayService.afterIdentify(contextKeys: keys, canonicalKey: canonicalKey, completed: completed)
}
}
20 changes: 19 additions & 1 deletion Sources/LaunchDarklySessionReplay/SessionReplayService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import LaunchDarklyObservability
import Common
#endif

protocol SessionReplayServicing {
protocol SessionReplayServicing: AnyObject {
@MainActor
func start()

Expand All @@ -17,6 +17,8 @@ protocol SessionReplayServicing {

@MainActor
var isEnabled: Bool { get set }

func afterIdentify(contextKeys: [String: String], canonicalKey: String, completed: Bool)
}

struct SessionReplayContext {
Expand Down Expand Up @@ -48,6 +50,7 @@ final class SessionReplayService: SessionReplayServicing {
var sessionReplayExporter: SessionReplayExporter
let userInteractionManager: UserInteractionManager
let log: OSLog
var observabilityContext: ObservabilityContext

@MainActor
var isEnabled = false {
Expand All @@ -69,6 +72,7 @@ final class SessionReplayService: SessionReplayServicing {
guard let url = URL(string: observabilityContext.options.backendUrl) else {
throw InstrumentationError.invalidGraphQLUrl
}
self.observabilityContext = observabilityContext
self.log = observabilityContext.options.log
let graphQLClient = GraphQLClient(endpoint: url)
let captureService = ImageCaptureService(options: sessonReplayOptions)
Expand Down Expand Up @@ -101,6 +105,20 @@ final class SessionReplayService: SessionReplayServicing {
os_log("LaunchDarkly Session Replay started, version: %{public}@", log: log, type: .info, sdkVersion)
}

func afterIdentify(contextKeys: [String: String], canonicalKey: String, completed: Bool) {
guard completed else { return }
Task {
let identifyPayload = IdentifyItemPayload(
options: observabilityContext.options,
sessionAttributes: observabilityContext.sessionAttributes,
contextKeys: contextKeys,
canonicalKey: canonicalKey,
timestamp: Date().timeIntervalSince1970
)
await scheduleIdentifySession(identifyPayload: identifyPayload)
}
}

func scheduleIdentifySession(identifyPayload: IdentifyItemPayload) async {
do {
try await sessionReplayExporter.identifySession(identifyPayload: identifyPayload)
Expand Down
2 changes: 2 additions & 0 deletions TestApp/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
)
config.plugins = [
Observability(options: .init(
isEnabled: true,
serviceName: "observability-ios-test-app",
otlpEndpoint: otlpEndpoint,
backendUrl: backendUrl,
resourceAttributes: ["test-options-attribute": .string("ios-test-app")],
sessionBackgroundTimeout: 3,
crashReporting: .enabled
)),
SessionReplay(options: .init(
isEnabled: true,
Expand Down
Loading