diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt index ff3fb29f34..9b308b0420 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/MainActivity.kt @@ -115,11 +115,7 @@ private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) { .verticalScroll(rememberScrollState()) .padding(16.dp) ) { - Text( - text = "Masking", - style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(bottom = 8.dp) - ) + SessionReplayHeader() HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) MaskingButtons() @@ -131,8 +127,6 @@ private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) { ) HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) - SessionReplayToggle() - IdentifyButtons(viewModel = viewModel) InstrumentationButtons(viewModel = viewModel) @@ -146,19 +140,19 @@ private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) { } @Composable -private fun SessionReplayToggle() { +private fun SessionReplayHeader() { var isSessionReplayEnabled by rememberSaveable { mutableStateOf(true) } Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), + .padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "Session Replay", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold) ) Switch( checked = isSessionReplayEnabled, @@ -295,7 +289,7 @@ private fun MaskingButtons() { ) MaskingRow( - name = "Check", + name = "Dialogs", ctx = context, activity1 = XMLMaskingActivity::class.java, activity2 = ComposeMaskingActivity::class.java diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt index efd1f702c2..26948a1b8b 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt @@ -96,7 +96,7 @@ class BenchmarkExecutor { } val exportDiffManager = ExportDiffManager(compression = method, scale = 1f) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300) + val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "benchmark") val json = Json var bytes = 0 var isFirst = true diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt index 837fac2379..536fc95bdf 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt @@ -88,7 +88,13 @@ open class BaseApplication : Application() { .anonymous(true) .build() - LDClient.init(this@BaseApplication, ldConfig, context) + LDClient.init(this@BaseApplication, ldConfig, context, 1) + val anonContext = LDContext.builder(ContextKind.DEFAULT, "anonymous-userkey") + .anonymous(true) + .build() + + //LDClient.get().identify(anonContext) + telemetryInspector = observabilityPlugin.getTelemetryInspector() if (testUrl == null) { diff --git a/sdk/@launchdarkly/mobile-dotnet/README.md b/sdk/@launchdarkly/mobile-dotnet/README.md index 4bbad587c5..3afc4f2691 100644 --- a/sdk/@launchdarkly/mobile-dotnet/README.md +++ b/sdk/@launchdarkly/mobile-dotnet/README.md @@ -1,25 +1,177 @@ -# LaunchDarkly Session Replay for .NET MAUI +# LaunchDarkly Observability SDK for .NET MAUI -The LaunchDarkly Session Replay SDK for .NET MAUI allows you to capture user interactions and screen recordings to understand how users interact with your application. +The LaunchDarkly Observability SDK for .NET MAUI provides automatic and manual instrumentation for your mobile application, including metrics, logs, error reporting, and session replay. + +## Early Access Preview + +**NB: APIs are subject to change until a 1.x version is released.** + +## Features + +### Automatic Instrumentation + +The .NET MAUI observability plugin automatically instruments: +- **HTTP Requests**: Outgoing HTTP requests +- **Crash Reporting**: Automatic crash reporting and stack traces +- **Feature Flag Evaluations**: Evaluation events added to your spans +- **Session Management**: User session tracking and background timeout handling ## Prerequisites * **.NET 9.0** or higher is required. * MAUI support for **iOS** and **Android**. -## Getting Started +## Example Application -To enable Session Replay, you need to configure both the `ObservabilityPlugin` and `SessionReplayPlugin` when initializing the LaunchDarkly client. +A complete example application is available in the [sample](./sample) directory. -### Configure Session Replay +## Usage -In your `MauiProgram.cs` (or wherever you initialize your application), register the plugins via `LdClient`: +### Basic Setup + +In your `MauiProgram.cs` (or wherever you initialize your application), register the `ObservabilityPlugin` via `LdClient`: ```csharp -using LaunchDarkly.SessionReplay; +using LaunchDarkly.Observability; +using LaunchDarkly.Sdk; using LaunchDarkly.Sdk.Client; using LaunchDarkly.Sdk.Client.Integrations; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + + // ... other configuration ... + + var mobileKey = "your-mobile-key"; + + var ldConfig = Configuration.Builder(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Enabled) + .Plugins(new PluginConfigurationBuilder() + .Add(new ObservabilityPlugin(new ObservabilityOptions( + isEnabled: true, + serviceName: "maui-sample-app" + ))) + ).Build(); + + var context = Context.New("maui-user-key"); + var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(10)); + + return builder.Build(); + } +} +``` + +### Recording Observability Data + +After initialization of the LaunchDarkly client, use `LDObserve` to record metrics, logs, and errors: + +```csharp +using LaunchDarkly.Observability; + +// Record metrics +LDObserve.RecordMetric("user_actions", 1.0); +LDObserve.RecordCount("api_calls", 1.0); +LDObserve.RecordIncr("page_views", 1.0); +LDObserve.RecordHistogram("response_time", 150.0); +LDObserve.RecordUpDownCounter("active_connections", 1.0); + +// Record logs with severity and optional attributes +LDObserve.RecordLog( + "User performed action", + LDObserve.Severity.Info, + new Dictionary + { + { "user_id", "12345" }, + { "action", "button_click" } + } +); + +// Record errors with an optional cause +LDObserve.RecordError("Something went wrong", "The underlying cause of the error."); +``` + +#### Metrics + +| Method | Description | +|---|---| +| `RecordMetric(name, value)` | Record a gauge metric | +| `RecordCount(name, value)` | Record a count metric | +| `RecordIncr(name, value)` | Record an incremental counter metric | +| `RecordHistogram(name, value)` | Record a histogram metric | +| `RecordUpDownCounter(name, value)` | Record an up-down counter metric | + +#### Logs + +Use `RecordLog` to emit structured log records with a severity level and optional attributes: + +```csharp +LDObserve.RecordLog( + "Checkout completed", + LDObserve.Severity.Info, + new Dictionary + { + { "order_id", "ORD-9876" }, + { "total", 42.99 } + } +); +``` + +Supported severity levels: `Trace`, `Debug`, `Info`, `Warn`, `Error`, `Fatal`. + +#### Errors + +Use `RecordError` to capture error events. The optional second parameter provides the underlying cause: + +```csharp +LDObserve.RecordError("Payment failed", "Timeout connecting to payment gateway."); +``` + +### Identifying Users + +Use the LaunchDarkly client to identify or switch user contexts. This ties observability data to the correct user: + +```csharp +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Client; + +// Single context +var userContext = Context.Builder("user-key") + .Name("Bob Bobberson") + .Build(); +await LdClient.Instance.IdentifyAsync(userContext); + +// Multi-context +var userContext = Context.Builder("user-key") + .Name("Bob Bobberson") + .Build(); +var deviceContext = Context.Builder(ContextKind.Of("device"), "iphone") + .Name("iphone") + .Build(); +var multiContext = Context.MultiBuilder() + .Add(userContext) + .Add(deviceContext) + .Build(); +LdClient.Instance.Identify(multiContext, TimeSpan.FromSeconds(5)); + +// Anonymous context +var anonContext = Context.Builder("anonymous-key") + .Anonymous(true) + .Build(); +LdClient.Instance.Identify(anonContext, TimeSpan.FromSeconds(5)); +``` + +## Session Replay + +Session Replay captures user interactions and screen recordings to help you understand how users interact with your application. To enable Session Replay, add the `SessionReplayPlugin` alongside the `ObservabilityPlugin`: + +```csharp +using LaunchDarkly.SessionReplay; using LaunchDarkly.Observability; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Client; +using LaunchDarkly.Sdk.Client.Integrations; public static class MauiProgram { @@ -47,7 +199,7 @@ public static class MauiProgram ))) ).Build(); - var context = LaunchDarkly.Sdk.Context.New("maui-user-key"); + var context = Context.New("maui-user-key"); var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(10)); return builder.Build(); @@ -55,7 +207,7 @@ public static class MauiProgram } ``` -## Privacy Options +### Privacy Options You can control what information is captured during a session using `PrivacyOptions`: @@ -64,7 +216,7 @@ You can control what information is captured during a session using `PrivacyOpti * `MaskLabels`: (Default: `false`) Masks all text labels. * `MaskImages`: (Default: `false`) Masks all images. -## Manual Masking +### Manual Masking You can manually mask or unmask specific UI components using the provided extension methods on any MAUI `View`. @@ -77,3 +229,21 @@ mySensitiveView.LDMask(); // Unmask a specific view myPublicView.LDUnmask(); ``` + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## About LaunchDarkly + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the 'gold' plan get access to more features than users in the 'silver' plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [launchdarkly.com/blog](https://launchdarkly.com/blog/ "LaunchDarkly Blog Documentation") for the latest product updates diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt index 09783ab0e7..c250eecd79 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt @@ -5,10 +5,11 @@ import com.example.LDObserve.BridgeLogger import com.example.LDObserve.SystemOutBridgeLogger import com.launchdarkly.observability.BuildConfig import com.launchdarkly.observability.client.TelemetryInspector +import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.plugin.Observability +import com.launchdarkly.observability.sdk.AttributeConverter import com.launchdarkly.observability.sdk.LDObserve -import com.launchdarkly.observability.replay.PrivacyProfile -import com.launchdarkly.observability.replay.ReplayOptions +import com.launchdarkly.observability.sdk.LDReplay import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.sdk.ContextKind import com.launchdarkly.sdk.LDContext @@ -16,10 +17,6 @@ import com.launchdarkly.sdk.android.Components import com.launchdarkly.sdk.android.LDAndroidLogging import com.launchdarkly.sdk.android.LDClient import com.launchdarkly.sdk.android.LDConfig -import com.launchdarkly.sdk.android.integrations.Plugin -import io.opentelemetry.api.common.AttributeKey -import io.opentelemetry.api.common.Attributes -import java.util.Collections public class LDObservabilityOptions { @JvmField var isEnabled: Boolean = true @@ -93,64 +90,52 @@ public class LDSessionReplayOptions { } } -internal fun buildResourceAttributes(source: HashMap?): Attributes { - if (source.isNullOrEmpty()) return Attributes.empty() - val builder = Attributes.builder() - source.forEach { (key, value) -> - when (value) { - is String -> builder.put(AttributeKey.stringKey(key), value) - is Boolean -> builder.put(AttributeKey.booleanKey(key), value) - is Long -> builder.put(AttributeKey.longKey(key), value) - is Int -> builder.put(AttributeKey.longKey(key), value.toLong()) - is Double -> builder.put(AttributeKey.doubleKey(key), value) - is Float -> builder.put(AttributeKey.doubleKey(key), value.toDouble()) - null -> {} - else -> builder.put(AttributeKey.stringKey(key), value.toString()) - } - } - return builder.build() -} public class ObservabilityBridge( private val logger: BridgeLogger = SystemOutBridgeLogger() ) { var isDebug: Boolean = true - public fun getHookProxy(): RealObservabilityHookProxy? { + public fun getObservabilityHookProxy(): RealObservabilityHookProxy? { val real = LDObserve.hookProxy ?: return null return RealObservabilityHookProxy(real) } + public fun getSessionReplayHookProxy(): RealSessionReplayHookProxy? { + val real = LDReplay.hookProxy ?: return null + return RealSessionReplayHookProxy(real) + } + public fun version(): String { return BuildConfig.OBSERVABILITY_SDK_VERSION } - public fun recordLog(message: String, severity: Int) { - // TODO: bridge to LDObserve.recordLog + public fun recordLog(message: String, severity: Int, attributes: HashMap? = null) { + LDObserve.recordLog(message, severity, attributes) } public fun recordError(message: String, cause: String?) { - // TODO: bridge to LDObserve.recordError + LDObserve.recordError(message, cause) } public fun recordMetric(name: String, value: Double) { - // TODO: bridge to LDObserve.recordMetric + LDObserve.recordMetric(Metric(name = name, value = value)) } public fun recordCount(name: String, value: Double) { - // TODO: bridge to LDObserve.recordCount + LDObserve.recordCount(Metric(name = name, value = value)) } public fun recordIncr(name: String, value: Double) { - // TODO: bridge to LDObserve.recordIncr + LDObserve.recordIncr(Metric(name = name, value = value)) } public fun recordHistogram(name: String, value: Double) { - // TODO: bridge to LDObserve.recordHistogram + LDObserve.recordHistogram(Metric(name = name, value = value)) } public fun recordUpDownCounter(name: String, value: Double) { - // TODO: bridge to LDObserve.recordUpDownCounter + LDObserve.recordUpDownCounter(Metric(name = name, value = value)) } public fun start( @@ -160,17 +145,15 @@ public class ObservabilityBridge( replay: LDSessionReplayOptions, observabilityVersion: String ) { - // logger.debug("LD:ObservabilityBridge start called 7") + logger.info("LD:ObservabilityBridge start called, ver" + observabilityVersion) val resourceAttributes = try { - buildResourceAttributes(observability.attributes) + AttributeConverter.convert(observability.attributes) } catch (t: Throwable) { printException("LD:resourceAttributes failed to build resourceAttributes", t) throw t } - //logger.debug("LD:ObservabilityBridge resourceAttributes called") - val nativeObservabilityOptions = try { com.launchdarkly.observability.api.ObservabilityOptions( enabled = observability.isEnabled, @@ -181,7 +164,7 @@ public class ObservabilityBridge( otlpEndpoint = observability.otlpEndpoint, backendUrl = observability.backendUrl, tracesApi = com.launchdarkly.observability.api.ObservabilityOptions.TracesApi(includeErrors = true, includeSpans = true), - metricsApi = com.launchdarkly.observability.api.ObservabilityOptions.MetricsApi.disabled(), + metricsApi = com.launchdarkly.observability.api.ObservabilityOptions.MetricsApi.enabled(), instrumentations = com.launchdarkly.observability.api.ObservabilityOptions.Instrumentations( crashReporting = false, launchTime = true, activityLifecycle = true ), @@ -260,7 +243,6 @@ public class ObservabilityBridge( try { LDClient.init(app, ldConfig, context) - //logger.info("LD:ObservabilityBridge LDClient.init completed") } catch (t: Throwable) { printException("LD:ObservabilityBridge LDClient.init failed", t) throw t diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealSessionReplayHookProxy.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealSessionReplayHookProxy.kt new file mode 100644 index 0000000000..9b13610401 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealSessionReplayHookProxy.kt @@ -0,0 +1,17 @@ +package com.launchdarkly.LDNative + +import com.launchdarkly.observability.replay.plugin.SessionReplayHookProxy as PluginSessionReplayHookProxy + +/** + * Bindable wrapper around the real session replay hook proxy. + * + * Keeping this class in the LDNative package ensures Xamarin binding generation + * emits a C# type without needing manual JNI glue code. + */ +class RealSessionReplayHookProxy internal constructor( + private val delegate: PluginSessionReplayHookProxy +) { + fun afterIdentify(contextKeys: Map, canonicalKey: String, completed: Boolean) { + delegate.afterIdentify(contextKeys, canonicalKey, completed) + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs b/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs index 6396c9369f..ea18a45212 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs +++ b/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs @@ -58,9 +58,13 @@ interface ObservabilityBridge [Export("startWithMobileKey:observability:replay:")] void Start(string mobileKey, ObjcObservabilityOptions observability, ObjcSessionReplayOptions replay); - [Export("getHookProxy")] + [Export("getObservabilityHookProxy")] [NullAllowed] - ObservabilityHookProxy GetHookProxy(); + ObservabilityHookProxy GetObservabilityHookProxy(); + + [Export("getSessionReplayHookProxy")] + [NullAllowed] + SessionReplayHookProxy GetSessionReplayHookProxy(); } [BaseType(typeof(NSObject))] @@ -68,6 +72,24 @@ interface LDObserveBridge { [Static, Export("recordLogWithMessage:severity:attributes:")] void RecordLog(string message, nint severity, NSDictionary attributes); + + [Static, Export("recordErrorWithMessage:cause:")] + void RecordError(string message, [NullAllowed] string cause); + + [Static, Export("recordMetricWithName:value:")] + void RecordMetric(string name, double value); + + [Static, Export("recordCountWithName:value:")] + void RecordCount(string name, double value); + + [Static, Export("recordIncrWithName:value:")] + void RecordIncr(string name, double value); + + [Static, Export("recordHistogramWithName:value:")] + void RecordHistogram(string name, double value); + + [Static, Export("recordUpDownCounterWithName:value:")] + void RecordUpDownCounter(string name, double value); } [BaseType(typeof(NSObject))] @@ -83,4 +105,11 @@ void AfterEvaluation(string evaluationId, string flagKey, string contextKey, [Export("afterIdentifyWithContextKeys:canonicalKey:completed:")] void AfterIdentify(NSDictionary contextKeys, string canonicalKey, bool completed); } + + [BaseType(typeof(NSObject))] + interface SessionReplayHookProxy + { + [Export("afterIdentifyWithContextKeys:canonicalKey:completed:")] + void AfterIdentify(NSDictionary contextKeys, string canonicalKey, bool completed); + } } \ No newline at end of file diff --git a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ae1621f442..6c19af08ce 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/LDObserve.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/launchdarkly/ios-client-sdk.git", "state" : { - "revision" : "34d1a543471753c3a51339af79c12389ca0e6b46", - "version" : "11.1.0" + "revision" : "8b56cf8a7f74618a5d8e9ef0e4dad543e2f3fed7", + "version" : "11.1.1" } }, { diff --git a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift index 0e31ef1dc3..b00ef91e85 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift +++ b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift @@ -73,10 +73,14 @@ public final class ObservabilityBridge: NSObject { return sdkVersion } - @objc public func getHookProxy() -> ObservabilityHookProxy? { + @objc public func getObservabilityHookProxy() -> ObservabilityHookProxy? { return LDObserve.shared.hookProxy } + @objc public func getSessionReplayHookProxy() -> SessionReplayHookProxy? { + return LDReplay.shared.hookProxy + } + @objc public func start(mobileKey: String, observability: ObjcObservabilityOptions, replay: ObjcSessionReplayOptions) { @@ -96,7 +100,7 @@ public final class ObservabilityBridge: NSObject { resourceAttributes: buildResourceAttributes(observability.attributes), crashReporting: .init(source: .none), instrumentation: .init( - urlSession: .disabled, + urlSession: .enabled, userTaps: .enabled, memory: .disabled, memoryWarnings: .disabled, diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props index c4e16c6092..c101a8ab6f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props +++ b/sdk/@launchdarkly/mobile-dotnet/observability/Directory.Build.props @@ -1,6 +1,8 @@ - 0.4.0 + LaunchDarkly.SessionReplay + 0.4.1 + false LaunchDarkly LaunchDarkly LaunchDarkly diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj index 9d3a4de1d4..b4ae7f26f8 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.Fat.csproj @@ -2,8 +2,6 @@ net9.0-android;net9.0-ios - true - LaunchDarkly.SessionReplay LD Session Replay package for .NET MAUI true false @@ -21,7 +19,7 @@ - + @@ -35,7 +33,9 @@ - + + + + Pack="true" PackagePath="buildTransitive\$(PackageId).targets" /> diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj index 3ecdf29313..9f0b524c1f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj +++ b/sdk/@launchdarkly/mobile-dotnet/observability/LDObservability.csproj @@ -3,7 +3,6 @@ net9.0-android;net9.0-ios true - true true enable enable @@ -15,7 +14,6 @@ true - LaunchDarkly.SessionReplay LD Observability bindings aggregator for .NET (Android/iOS). false true @@ -57,7 +55,7 @@ - + diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/DictionaryTypeConverters.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/DictionaryTypeConverters.cs index 5b96471838..2f12b8cd7f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/DictionaryTypeConverters.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/DictionaryTypeConverters.cs @@ -5,7 +5,7 @@ using Foundation; #endif -namespace LaunchDarkly.SessionReplay; +namespace LaunchDarkly.Observability; internal static class DictionaryTypeConverters { @@ -48,7 +48,7 @@ internal static NSObject ToNSObject(object? value) IEnumerable arr => NSArray.FromNSObjects(arr.Select(f => (NSObject)NSNumber.FromFloat(f)).ToArray()), IEnumerable arr => NSArray.FromNSObjects(arr.Select(m => (NSObject)NSNumber.FromDouble((double)m)).ToArray()), - IDictionary dict => ToNSDictionary(dict) ?? new NSDictionary(), + IDictionary dict => ToNSDictionary(dict) ?? new NSDictionary(), NSDictionary nsDict => nsDict, NSArray nsArray => nsArray, @@ -59,15 +59,15 @@ internal static NSObject ToNSObject(object? value) } #elif ANDROID - internal static Java.Util.HashMap? ToJavaHashMap(IDictionary? src) + internal static IDictionary? ToJavaDictionary(IDictionary? src) { if (src is null) return null; - var map = new Java.Util.HashMap(); + var map = new Dictionary(src.Count); foreach (var (k, v) in src) { var jobj = ToJavaObject(v); - if (jobj != null) map.Put(k, jobj); + if (jobj != null) map[k] = jobj; } return map; } @@ -85,8 +85,37 @@ internal static NSObject ToNSObject(object? value) double d => new Java.Lang.Double(d), float f => new Java.Lang.Float(f), decimal m => new Java.Lang.Double((double)m), + + IDictionary dict => ToJavaHashMap(dict), + + IEnumerable arr => ToJavaList(arr.Select(s => (Java.Lang.Object)new Java.Lang.String(s))), + IEnumerable arr => ToJavaList(arr.Select(b => (Java.Lang.Object)new Java.Lang.Boolean(b))), + IEnumerable arr => ToJavaList(arr.Select(i => (Java.Lang.Object)new Java.Lang.Integer(i))), + IEnumerable arr => ToJavaList(arr.Select(l => (Java.Lang.Object)new Java.Lang.Long(l))), + IEnumerable arr => ToJavaList(arr.Select(d => (Java.Lang.Object)new Java.Lang.Double(d))), + IEnumerable arr => ToJavaList(arr.Select(f => (Java.Lang.Object)new Java.Lang.Float(f))), + _ => new Java.Lang.String(value.ToString() ?? string.Empty) }; } + + private static Java.Util.HashMap ToJavaHashMap(IDictionary dict) + { + var map = new Java.Util.HashMap(); + foreach (var (k, v) in dict) + { + var jVal = ToJavaObject(v); + if (jVal != null) map.Put(new Java.Lang.String(k), jVal); + } + return map; + } + + private static Java.Util.ArrayList ToJavaList(IEnumerable items) + { + var list = new Java.Util.ArrayList(); + foreach (var item in items) + list.Add(item); + return list; + } #endif } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.Android.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.Android.cs index e4a599b617..ca76707c01 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.Android.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.Android.cs @@ -1,5 +1,7 @@ +using LaunchDarkly.SessionReplay; + #if ANDROID -namespace LaunchDarkly.SessionReplay; +namespace LaunchDarkly.Observability; internal static class LDNativeAndroidMapping { @@ -12,7 +14,7 @@ internal static class LDNativeAndroidMapping options.OtlpEndpoint, options.BackendUrl, options.ContextFriendlyName, - DictionaryTypeConverters.ToJavaHashMap(options.Attributes) + DictionaryTypeConverters.ToJavaDictionary(options.Attributes) ); } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs index 11325d2157..89a1cf7672 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using LaunchDarkly.Observability; #if ANDROID using Android.App; diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeHookProxy.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeObservabilityHookExporter.cs similarity index 94% rename from sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeHookProxy.cs rename to sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeObservabilityHookExporter.cs index c6fccbdccf..bfa75b9d41 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeHookProxy.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeObservabilityHookExporter.cs @@ -16,18 +16,18 @@ namespace LaunchDarkly.Observability /// C# passes only primitives and Foundation types — no SDK-specific types /// (EvaluationSeriesContext, LDEvaluationDetail) need to be constructed natively. /// - internal sealed class NativeHookProxy : Hook + internal sealed class NativeObservabilityHookExporter : Hook { private const string EvalIdKey = "__nativeEvalId"; private readonly ObservabilityHookProxy _proxy; - internal NativeHookProxy(ObservabilityHookProxy proxy) : base("Observability") + internal NativeObservabilityHookExporter(ObservabilityHookProxy proxy) : base("Observability") { _proxy = proxy; } public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) - { + { var evalId = Guid.NewGuid().ToString(); _proxy.BeforeEvaluation(evalId, context.FlagKey, context.Context.FullyQualifiedKey); return new SeriesDataBuilder(data).Set(EvalIdKey, evalId).Build(); @@ -90,12 +90,12 @@ namespace LaunchDarkly.Observability { using SeriesData = ImmutableDictionary; - internal sealed class NativeHookProxy : Hook + internal sealed class NativeObservabilityHookExporter : Hook { private const string EvalIdKey = "__nativeEvalId"; private readonly RealObservabilityHookProxy _proxy; - internal NativeHookProxy(RealObservabilityHookProxy proxy) : base("Observability") + internal NativeObservabilityHookExporter(RealObservabilityHookProxy proxy) : base("Observability") { _proxy = proxy; } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeSessionReplayHookExporter.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeSessionReplayHookExporter.cs new file mode 100644 index 0000000000..c17d7245bd --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeSessionReplayHookExporter.cs @@ -0,0 +1,89 @@ +#if IOS +using System.Collections.Immutable; +using Foundation; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Client.Hooks; +using LDObserveMaciOS; + +namespace LaunchDarkly.Observability +{ + using SeriesData = ImmutableDictionary; + + internal sealed class NativeSessionReplayHookExporter + { + private readonly SessionReplayHookProxy _proxy; + + internal NativeSessionReplayHookExporter(SessionReplayHookProxy proxy) + { + _proxy = proxy; + } + + internal SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + var contextKeys = new NSMutableDictionary(); + if (context.Context.Multiple) + { + foreach (var individual in context.Context.MultiKindContexts) + { + contextKeys.Add(new NSString(individual.Kind.Value), new NSString(individual.Key)); + } + } + else + { + contextKeys.Add(new NSString(context.Context.Kind.Value), new NSString(context.Context.Key)); + } + _proxy.AfterIdentify( + contextKeys, + context.Context.FullyQualifiedKey, + result.Status == IdentifySeriesResult.IdentifySeriesStatus.Completed); + + return data; + } + } +} +#elif ANDROID +using System.Collections.Immutable; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Client.Hooks; +using LDObserveAndroid; + +namespace LaunchDarkly.Observability +{ + using SeriesData = ImmutableDictionary; + + internal sealed class NativeSessionReplayHookExporter + { + private readonly RealSessionReplayHookProxy _proxy; + + internal NativeSessionReplayHookExporter(RealSessionReplayHookProxy proxy) + { + _proxy = proxy; + } + + internal SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + var contextKeys = new System.Collections.Generic.Dictionary(); + if (context.Context.Multiple) + { + foreach (var individual in context.Context.MultiKindContexts) + { + contextKeys[individual.Kind.Value] = individual.Key; + } + } + else + { + contextKeys[context.Context.Kind.Value] = context.Context.Key; + } + + _proxy.AfterIdentify( + contextKeys, + context.Context.FullyQualifiedKey, + result.Status == IdentifySeriesResult.IdentifySeriesStatus.Completed + ); + return data; + } + } +} +#endif diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/SRClient.iOS.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/SRClient.iOS.cs index 2a0cdbbd55..259528d73f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/SRClient.iOS.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/SRClient.iOS.cs @@ -1,10 +1,10 @@ #if IOS using Foundation; using LDObserveMaciOS; +using LaunchDarkly.SessionReplay; +namespace LaunchDarkly.Observability; -namespace LaunchDarkly.SessionReplay; - -public class ObservabilityBridgeClient +class ObservabilityBridgeClient { private readonly LDObserveMaciOS.ObservabilityBridge _native; diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/infra/INativePlugin.cs b/sdk/@launchdarkly/mobile-dotnet/observability/infra/INativePlugin.cs index 17f6b7fb53..1a63bf0f98 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/infra/INativePlugin.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/infra/INativePlugin.cs @@ -2,6 +2,5 @@ namespace LaunchDarkly.Observability { internal interface INativePlugin { - void Initialize(); } } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/infra/NativePluginConnector.cs b/sdk/@launchdarkly/mobile-dotnet/observability/infra/NativePluginConnector.cs deleted file mode 100644 index 0d19f95cf4..0000000000 --- a/sdk/@launchdarkly/mobile-dotnet/observability/infra/NativePluginConnector.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Collections.Generic; -using LaunchDarkly.Sdk.Client.Hooks; -using LaunchDarkly.Sdk.Client.Interfaces; -using LaunchDarkly.Sdk.Integrations.Plugins; -using LaunchDarkly.SessionReplay; - -namespace LaunchDarkly.Observability -{ - internal sealed class NativePluginConnector - { - private static readonly Lazy _instance = - new Lazy(() => new NativePluginConnector()); - - internal static NativePluginConnector Instance => _instance.Value; - - private int _createdCount; - private int _registeredCount; - - internal NativeObserve? Observe { get; private set; } - internal NativeSessionReplay? SessionReplay { get; private set; } - - private NativePluginConnector() { } - - private void TryInitializeAll() - { - // checks last register - if (_registeredCount < _createdCount) return; - - var metadata = Observe?.Metadata ?? SessionReplay?.Metadata; - if (metadata == null) return; - - var mobileKey = metadata.Credential; - - var observabilityOptions = Observe?.Options - ?? new ObservabilityOptions(isEnabled: false); - - var replayOptions = SessionReplay?.Options - ?? new SessionReplayOptions(isEnabled: false); - - LDNative.Start(mobileKey, observabilityOptions, replayOptions); - } - - internal void CreateObserve(ObservabilityOptions options) - { - if (options == null) throw new ArgumentNullException(nameof(options)); - Observe = new NativeObserve(options); - _createdCount++; - } - - internal void RegisterObserve(ILdClient client, EnvironmentMetadata metadata) - { - if (Observe == null) return; - Observe.Client = client; - Observe.Metadata = metadata; - _registeredCount++; - TryInitializeAll(); - } - - internal IList GetHooksObserve(EnvironmentMetadata metadata) - { - if (Observe == null) return new List(); - - Observe.Metadata = metadata; - return new List { new ObservabilityHook(Observe) }; - } - - internal void CreateSessionReplay(SessionReplayOptions options) - { - if (options == null) throw new ArgumentNullException(nameof(options)); - SessionReplay = new NativeSessionReplay(options); - _createdCount++; - } - - internal void RegisterSessionReplay(ILdClient client, EnvironmentMetadata metadata) - { - if (SessionReplay == null) return; - SessionReplay.Client = client; - SessionReplay.Metadata = metadata; - _registeredCount++; - TryInitializeAll(); - } - - internal IList GetHooksSessionReplay(EnvironmentMetadata metadata) - { - if (SessionReplay != null) - { - SessionReplay.Metadata = metadata; - return new List { new SessionReplayHook(SessionReplay) }; - } - return new List(); - } - } -} diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/infra/PluginOrchestrator.cs b/sdk/@launchdarkly/mobile-dotnet/observability/infra/PluginOrchestrator.cs new file mode 100644 index 0000000000..734fde8ee5 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/infra/PluginOrchestrator.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Integrations.Plugins; +using LaunchDarkly.SessionReplay; + +namespace LaunchDarkly.Observability +{ + internal sealed class PluginOrchestrator + { + private static readonly Lazy _instance = + new Lazy(() => new PluginOrchestrator()); + + internal static PluginOrchestrator Instance => _instance.Value; + + private int _createdCount; + private int _registeredCount; + + internal NativeObserve? Observe { get; private set; } + internal NativeSessionReplay? SessionReplay { get; private set; } + + private PluginOrchestrator() { } + + private void TryInitializeAll() + { + // checks last register + if (_registeredCount < _createdCount) return; + + var metadata = Observe?.Metadata ?? SessionReplay?.Metadata; + if (metadata == null) return; + + var mobileKey = metadata.Credential; + + var observabilityOptions = Observe?.Options + ?? new ObservabilityOptions(isEnabled: false); + + var replayOptions = SessionReplay?.Options + ?? new SessionReplayOptions(isEnabled: false); + + LDNative.Start(mobileKey, observabilityOptions, replayOptions); + } + + internal void AddObserve(NativeObserve observe) + { + Observe = observe; + _createdCount++; + } + + internal void Register() + { + _registeredCount++; + TryInitializeAll(); + } + + internal void AddSessionReplay(NativeSessionReplay sessionReplay) + { + SessionReplay = sessionReplay; + _createdCount++; + } + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs index bf70c959ac..2d49bc7d09 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs @@ -3,12 +3,11 @@ using LaunchDarkly.Sdk; #if IOS -using UIKit; using Foundation; using LDObserveMaciOS; #endif -namespace LaunchDarkly.SessionReplay; +namespace LaunchDarkly.Observability; /// /// Static facade over the native observability bridge. @@ -97,28 +96,20 @@ public enum Severity { Fatal4 = Fatal3 + 1, } - // -------- Flag Evaluation Tracking -------- - - /// - /// Tracks a flag evaluation result for observability tracing. - /// - public static void TrackEvaluation(string flagKey, LdValue value, int? variationIndex, EvaluationReason? reason) - { -#if IOS - // TODO: forward to iOS observability bridge -#endif - } - // -------- Public API -------- /// /// Record a log with integer severity. /// - public static void RecordLog(string message, int severity, IDictionary? attributes = null) + private static void RecordLog(string message, int severity, IDictionary? attributes = null) { #if IOS var dict = DictionaryTypeConverters.ToNSDictionary(attributes) ?? new NSDictionary(); LDObserveBridge.RecordLog(message, severity, dict); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + var map = DictionaryTypeConverters.ToJavaDictionary(attributes); + bridge.RecordLog(message, severity, map); #endif } @@ -133,6 +124,12 @@ public static void RecordLog(string message, Severity severity, IDictionary public static void RecordError(string message, string? cause = null) { +#if IOS + LDObserveBridge.RecordError(message, cause); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + bridge.RecordError(message, cause); +#endif } /// @@ -140,6 +137,12 @@ public static void RecordError(string message, string? cause = null) /// public static void RecordMetric(string name, double value) { +#if IOS + LDObserveBridge.RecordMetric(name, value); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + bridge.RecordMetric(name, value); +#endif } /// @@ -147,6 +150,12 @@ public static void RecordMetric(string name, double value) /// public static void RecordCount(string name, double value) { +#if IOS + LDObserveBridge.RecordCount(name, value); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + bridge.RecordCount(name, value); +#endif } /// @@ -154,6 +163,12 @@ public static void RecordCount(string name, double value) /// public static void RecordIncr(string name, double value) { +#if IOS + LDObserveBridge.RecordIncr(name, value); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + bridge.RecordIncr(name, value); +#endif } /// @@ -161,6 +176,12 @@ public static void RecordIncr(string name, double value) /// public static void RecordHistogram(string name, double value) { +#if IOS + LDObserveBridge.RecordHistogram(name, value); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + bridge.RecordHistogram(name, value); +#endif } /// @@ -168,6 +189,12 @@ public static void RecordHistogram(string name, double value) /// public static void RecordUpDownCounter(string name, double value) { +#if IOS + LDObserveBridge.RecordUpDownCounter(name, value); +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + bridge.RecordUpDownCounter(name, value); +#endif } } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/NativeObserve.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/NativeObserve.cs index 1ab97af3d8..e12433fcca 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/NativeObserve.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/NativeObserve.cs @@ -1,8 +1,5 @@ -using System.Collections.Generic; -using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Integrations.Plugins; -using LaunchDarkly.SessionReplay; #if IOS using LDObserveMaciOS; @@ -28,25 +25,24 @@ public void Initialize() // TODO: initialize native observability with Options, Client, and Metadata } - internal List GetNativeHooks() + internal NativeObservabilityHookExporter? GetNativeHookExporter() { - var hooks = new List(); #if IOS var bridge = new LDObserveMaciOS.ObservabilityBridge(); - var proxy = bridge.GetHookProxy(); + var proxy = bridge.GetObservabilityHookProxy(); if (proxy != null) { - hooks.Add(new NativeHookProxy(proxy)); + return new NativeObservabilityHookExporter(proxy); } #elif ANDROID var bridge = new LDObserveAndroid.ObservabilityBridge(); - var proxy = bridge.HookProxy; + var proxy = bridge.ObservabilityHookProxy; if (proxy != null) { - hooks.Add(new NativeHookProxy(proxy)); + return new NativeObservabilityHookExporter(proxy); } #endif - return hooks; + return null; } } } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityHook.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityHook.cs index ff0cb0b81f..92f5311898 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityHook.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityHook.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Collections.Immutable; using LaunchDarkly.Sdk; using LaunchDarkly.Sdk.Client.Hooks; @@ -10,13 +9,14 @@ namespace LaunchDarkly.Observability /// /// Hook that delegates evaluation and identify calls to the native - /// ObservabilityHookImplementation (via NativeHookProxy) on iOS, + /// ObservabilityHookImplementation (via NativeHookExporter) on iOS, /// and is a no-op on other platforms. /// internal sealed class ObservabilityHook : Hook { private readonly NativeObserve _nativeObserve; - private IList? _nativeHooks; + private NativeObservabilityHookExporter? _nativeHookExporter; + private bool _nativeHookExporterResolved; internal ObservabilityHook(NativeObserve nativeObserve) : base("LaunchDarkly.Observability") @@ -24,13 +24,24 @@ internal ObservabilityHook(NativeObserve nativeObserve) _nativeObserve = nativeObserve; } - private IList NativeHooks => _nativeHooks ??= _nativeObserve.GetNativeHooks(); + private NativeObservabilityHookExporter? NativeHookExporter + { + get + { + if (!_nativeHookExporterResolved) + { + _nativeHookExporter = _nativeObserve.GetNativeHookExporter(); + _nativeHookExporterResolved = true; + } + return _nativeHookExporter; + } + } public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) { - foreach (var hook in NativeHooks) + if (NativeHookExporter != null) { - data = hook.BeforeEvaluation(context, data); + data = NativeHookExporter.BeforeEvaluation(context, data); } return data; } @@ -38,18 +49,18 @@ public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, Ser public override SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, EvaluationDetail detail) { - foreach (var hook in NativeHooks) + if (NativeHookExporter != null) { - data = hook.AfterEvaluation(context, data, detail); + data = NativeHookExporter.AfterEvaluation(context, data, detail); } return data; } public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) { - foreach (var hook in NativeHooks) + if (NativeHookExporter != null) { - data = hook.BeforeIdentify(context, data); + data = NativeHookExporter.BeforeIdentify(context, data); } return data; } @@ -57,9 +68,9 @@ public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesD public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, IdentifySeriesResult result) { - foreach (var hook in NativeHooks) + if (NativeHookExporter != null) { - data = hook.AfterIdentify(context, data, result); + data = NativeHookExporter.AfterIdentify(context, data, result); } return data; } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityOptions.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityOptions.cs index f3059dbbc8..70b0652623 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityOptions.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityOptions.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace LaunchDarkly.SessionReplay; +namespace LaunchDarkly.Observability; public class ObservabilityOptions { @@ -16,7 +16,7 @@ public class ObservabilityOptions public string OtlpEndpoint { get; set; } = DefaultOtlpEndpoint; public string BackendUrl { get; set; } = DefaultBackendUrl; public string? ContextFriendlyName { get; set; } - public IDictionary? Attributes { get; set; } + public IDictionary? Attributes { get; set; } public ObservabilityOptions() { } @@ -27,7 +27,7 @@ public ObservabilityOptions( string? otlpEndpoint = null, string? backendUrl = null, string? contextFriendlyName = null, - IDictionary? attributes = null) + IDictionary? attributes = null) { IsEnabled = isEnabled; ServiceName = serviceName; diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityPlugin.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityPlugin.cs index 2ee699ad56..a46ba14940 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityPlugin.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityPlugin.cs @@ -4,38 +4,32 @@ using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Plugins; using LaunchDarkly.Sdk.Integrations.Plugins; -using LaunchDarkly.SessionReplay; namespace LaunchDarkly.Observability { public class ObservabilityPlugin : Plugin { - private readonly ObservabilityOptions? _options; - - public static ObservabilityPlugin ForExistingServices() => new ObservabilityPlugin(); + internal NativeObserve Observe { get; private set; } public ObservabilityPlugin(ObservabilityOptions options) : base("LaunchDarkly.Observability") { - _options = options ?? throw new ArgumentNullException(nameof(options)); - NativePluginConnector.Instance.CreateObserve(options); - } - - internal ObservabilityPlugin() : base("LaunchDarkly.Observability") - { - _options = null; + Observe = new NativeObserve(options); + PluginOrchestrator.Instance.AddObserve(Observe); } /// public override void Register(ILdClient client, EnvironmentMetadata metadata) { - if (_options == null) return; - NativePluginConnector.Instance.RegisterObserve(client, metadata); + Observe.Client = client; + Observe.Metadata = metadata; + PluginOrchestrator.Instance.Register(); } /// public override IList GetHooks(EnvironmentMetadata metadata) { - return NativePluginConnector.Instance.GetHooksObserve(metadata); + Observe.Metadata = metadata; + return new List { new ObservabilityHook(Observe) }; } } } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDViewExtensions.cs b/sdk/@launchdarkly/mobile-dotnet/observability/replay/api/LDViewExtensions.cs similarity index 100% rename from sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDViewExtensions.cs rename to sdk/@launchdarkly/mobile-dotnet/observability/replay/api/LDViewExtensions.cs diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/NativeSessionReplay.cs b/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/NativeSessionReplay.cs index 78f0040d52..6904bc28f3 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/NativeSessionReplay.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/NativeSessionReplay.cs @@ -2,6 +2,12 @@ using LaunchDarkly.Sdk.Integrations.Plugins; using LaunchDarkly.SessionReplay; +#if IOS +using LDObserveMaciOS; +#elif ANDROID +using LDObserveAndroid; +#endif + namespace LaunchDarkly.Observability { internal class NativeSessionReplay : INativePlugin @@ -15,9 +21,24 @@ internal NativeSessionReplay(SessionReplayOptions options) Options = options; } - public void Initialize() + internal NativeSessionReplayHookExporter? GetNativeSessionReplayHookExporter() { - // TODO: initialize native session replay with Options, Client, and Metadata +#if IOS + var bridge = new LDObserveMaciOS.ObservabilityBridge(); + var proxy = bridge.GetSessionReplayHookProxy(); + if (proxy != null) + { + return new NativeSessionReplayHookExporter(proxy); + } +#elif ANDROID + var bridge = new LDObserveAndroid.ObservabilityBridge(); + var proxy = bridge.SessionReplayHookProxy; + if (proxy != null) + { + return new NativeSessionReplayHookExporter(proxy); + } +#endif + return null; } } } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayHook.cs b/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayHook.cs index 49c2f58bd0..92195d8882 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayHook.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayHook.cs @@ -8,12 +8,15 @@ namespace LaunchDarkly.Observability using SeriesData = ImmutableDictionary; /// - /// Hook that forwards flag evaluation data to the session replay native bridge, - /// allowing evaluations to be associated with recorded sessions. + /// Hook that delegates identify calls to the native + /// SessionReplayHookProxy (via NativeSessionReplayHookExporter), + /// and forwards flag evaluation data to LDReplay. /// internal sealed class SessionReplayHook : Hook { private readonly NativeSessionReplay _nativeSessionReplay; + private NativeSessionReplayHookExporter? _nativeHookExporter; + private bool _nativeHookExporterResolved; internal SessionReplayHook(NativeSessionReplay nativeSessionReplay) : base("LaunchDarkly.SessionReplay") @@ -21,6 +24,19 @@ internal SessionReplayHook(NativeSessionReplay nativeSessionReplay) _nativeSessionReplay = nativeSessionReplay; } + private NativeSessionReplayHookExporter? NativeHookExporter + { + get + { + if (!_nativeHookExporterResolved) + { + _nativeHookExporter = _nativeSessionReplay.GetNativeSessionReplayHookExporter(); + _nativeHookExporterResolved = true; + } + return _nativeHookExporter; + } + } + public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) { return data; @@ -29,13 +45,6 @@ public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, Ser public override SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, EvaluationDetail detail) { - LDReplay.TrackEvaluation( - context.FlagKey, - detail.Value, - detail.VariationIndex, - detail.Reason - ); - return data; } @@ -47,6 +56,10 @@ public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesD public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, IdentifySeriesResult result) { + if (NativeHookExporter != null) + { + data = NativeHookExporter.AfterIdentify(context, data, result); + } return data; } } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayPlugin.cs b/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayPlugin.cs index bee27bb327..c9162eba26 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayPlugin.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/replay/plugin/SessionReplayPlugin.cs @@ -10,32 +10,28 @@ namespace LaunchDarkly.Observability { public class SessionReplayPlugin : Plugin { - private readonly SessionReplayOptions? _options; - - public static SessionReplayPlugin ForExistingServices() => new SessionReplayPlugin(); + internal NativeSessionReplay SessionReplay { get; private set; } public SessionReplayPlugin(SessionReplayOptions options) : base("LaunchDarkly.SessionReplay") { - _options = options ?? throw new ArgumentNullException(nameof(options)); - NativePluginConnector.Instance.CreateSessionReplay(options); - } + SessionReplay = new NativeSessionReplay(options); - internal SessionReplayPlugin() : base("LaunchDarkly.SessionReplay") - { - _options = null; + PluginOrchestrator.Instance.AddSessionReplay(SessionReplay); } /// public override void Register(ILdClient client, EnvironmentMetadata metadata) { - if (_options == null) return; - NativePluginConnector.Instance.RegisterSessionReplay(client, metadata); + SessionReplay.Client = client; + SessionReplay.Metadata = metadata; + PluginOrchestrator.Instance.Register(); } - + /// public override IList GetHooks(EnvironmentMetadata metadata) { - return NativePluginConnector.Instance.GetHooksSessionReplay(metadata); + SessionReplay.Metadata = metadata; + return new List { new SessionReplayHook(SessionReplay) }; } } } diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/AppShell.xaml.cs b/sdk/@launchdarkly/mobile-dotnet/sample/AppShell.xaml.cs index c6eb037aac..04b167415b 100644 --- a/sdk/@launchdarkly/mobile-dotnet/sample/AppShell.xaml.cs +++ b/sdk/@launchdarkly/mobile-dotnet/sample/AppShell.xaml.cs @@ -8,5 +8,6 @@ public AppShell() Routing.RegisterRoute(nameof(CreditCardPage), typeof(CreditCardPage)); Routing.RegisterRoute(nameof(NumberPadPage), typeof(NumberPadPage)); + Routing.RegisterRoute(nameof(DialogsPage), typeof(DialogsPage)); } } diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/DialogsPage.xaml b/sdk/@launchdarkly/mobile-dotnet/sample/DialogsPage.xaml new file mode 100644 index 0000000000..e2a988de50 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/sample/DialogsPage.xaml @@ -0,0 +1,183 @@ + + + + + + + + + + + + +