diff --git a/.prettierignore b/.prettierignore index ab373c8d41..e097893ef3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -25,3 +25,4 @@ sdk/highlightinc-highlight-datasource/grafana/data sdk/highlight-php sdk/@launchdarkly/observability-dotnet sdk/@launchdarkly/launchdarkly_flutter_observability +sdk/@launchdarkly/mobile-dotnet 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 536fc95bdf..dde23d36bb 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 @@ -89,12 +89,6 @@ open class BaseApplication : Application() { .build() 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/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt b/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt index f6c116e888..7dffb6ae2b 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt @@ -55,7 +55,15 @@ class ViewModel(application: Application) : AndroidViewModel(application) { LDObserve.recordLog( "Test Log", Severity.INFO, - Attributes.of(AttributeKey.stringKey("FakeAttribute"), "FakeVal") + Attributes.builder() + .put(AttributeKey.stringKey("test-string"), "maui") + .put(AttributeKey.booleanKey("test-true"), true) + .put(AttributeKey.booleanKey("test-false"), false) + .put(AttributeKey.longKey("test-integer"), 42L) + .put(AttributeKey.doubleKey("test-double"), 3.14) + .put(AttributeKey.doubleArrayKey("test-array"), listOf(3.14)) + .put(AttributeKey.longArrayKey("test-nested.array"), listOf(1L)) + .build() ) } @@ -89,11 +97,11 @@ class ViewModel(application: Application) : AndroidViewModel(application) { fun triggerNestedSpans() { viewModelScope.launch(Dispatchers.IO) { - val newSpan0 = LDObserve.startSpan("FakeSpan", Attributes.empty()) + val newSpan0 = LDObserve.startSpan("NestedSpan", Attributes.empty()) newSpan0.makeCurrent().use { - val newSpan1 = LDObserve.startSpan("FakeSpan1", Attributes.empty()) + val newSpan1 = LDObserve.startSpan("NestedSpan1", Attributes.empty()) newSpan1.makeCurrent().use { - val newSpan2 = LDObserve.startSpan("FakeSpan2", Attributes.empty()) + val newSpan2 = LDObserve.startSpan("NestedSpan2", Attributes.empty()) newSpan2.makeCurrent().use { sendOkHttpRequest() sendURLRequest() diff --git a/sdk/@launchdarkly/mobile-dotnet/README.md b/sdk/@launchdarkly/mobile-dotnet/README.md index 4bbad587c5..c1228f76c6 100644 --- a/sdk/@launchdarkly/mobile-dotnet/README.md +++ b/sdk/@launchdarkly/mobile-dotnet/README.md @@ -1,25 +1,206 @@ -# 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 + +A complete example application is available in the [sample](./sample) directory. -To enable Session Replay, you need to configure both the `ObservabilityPlugin` and `SessionReplayPlugin` when initializing the LaunchDarkly client. +## Usage -### Configure Session Replay +### Basic Setup -In your `MauiProgram.cs` (or wherever you initialize your application), register the plugins via `LdClient`: +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."); +``` + +#### Traces + +Use `LDObserve` to create spans for tracing operations in your application. Spans are backed by [OpenTelemetry](https://opentelemetry.io/) and should be disposed when the operation completes. + +Create spans to trace operations. The returned `TelemetrySpan` is disposable — wrap it in a `using` statement so it ends automatically: + +```csharp +using var span = LDObserve.StartActiveSpan("api_request"); +span.SetAttribute("endpoint", "/api/users"); +span.SetAttribute("method", "GET"); +``` + +##### Nested Spans + +`StartActiveSpan` automatically creates parent-child relationships. Each new active span becomes a child of the currently active span: + +```csharp +using var parent = LDObserve.StartActiveSpan("ProcessOrder"); +using var child = LDObserve.StartActiveSpan("ValidatePayment"); +using var grandchild = LDObserve.StartActiveSpan("ChargeCard"); + +await httpClient.PostAsync("https://api.example.com/charge", content); +``` + +| Method | Description | +|---|---| +| `LDObserve.StartActiveSpan(name)` | Start a new active span that automatically nests under the current parent. Returns a disposable `TelemetrySpan`. | +| `span.SetAttribute(key, value)` | Set a key-value attribute on a span. | + +### 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 +228,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 +236,7 @@ public static class MauiProgram } ``` -## Privacy Options +### Privacy Options You can control what information is captured during a session using `PrivacyOptions`: @@ -64,7 +245,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 +258,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/LDObserveBridgeAdapter.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/LDObserveBridgeAdapter.kt new file mode 100644 index 0000000000..bc11073e39 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/LDObserveBridgeAdapter.kt @@ -0,0 +1,19 @@ +package com.launchdarkly.LDNative + +import com.launchdarkly.observability.bridge.LDObserveBridge as SdkLDObserveBridge + +class LDObserveBridgeAdapter { + companion object { + @JvmStatic + fun getObservabilityHookProxy(): RealObservabilityHookProxy? { + val proxy = SdkLDObserveBridge.getObservabilityHookProxy() ?: return null + return RealObservabilityHookProxy(proxy) + } + + @JvmStatic + fun getTracer(): RealTracer? { + val tracer = SdkLDObserveBridge.getKotlinTracer() ?: return null + return RealTracer(tracer) + } + } +} 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..e277f8b864 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.bridge.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,47 @@ 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? { - 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 +140,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 +159,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 +238,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/RealObservabilityHookProxy.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealObservabilityHookProxy.kt index da1d2a242d..188f5aa6f4 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealObservabilityHookProxy.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealObservabilityHookProxy.kt @@ -1,6 +1,6 @@ package com.launchdarkly.LDNative -import com.launchdarkly.observability.plugin.ObservabilityHookProxy as PluginObservabilityHookProxy +import com.launchdarkly.observability.bridge.ObservabilityHookProxy as PluginObservabilityHookProxy /** * Bindable wrapper around the real observability hook proxy. 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/android/native/LDObserve/src/main/java/com/example/LDObserve/RealSpanBuilder.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealSpanBuilder.kt new file mode 100644 index 0000000000..15e8c6f131 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealSpanBuilder.kt @@ -0,0 +1,45 @@ +package com.launchdarkly.LDNative + +import com.launchdarkly.observability.bridge.KotlinSpanBuilder + +/** + * Bindable wrapper around [KotlinSpanBuilder] for C# via Xamarin binding. + */ +class RealSpanBuilder internal constructor( + private val delegate: KotlinSpanBuilder +) { + val traceId: String get() = delegate.traceId + val spanId: String get() = delegate.spanId + + fun setAttribute(key: String, value: Any?) { + delegate.setAttribute(key, value) + } + + fun setAttributes(attributes: HashMap) { + delegate.setAttributes(attributes) + } + + fun addEvent(name: String) { + delegate.addEvent(name) + } + + fun addEventWithAttributes(name: String, attributes: HashMap) { + delegate.addEvent(name, attributes) + } + + fun recordException(message: String, type: String) { + delegate.recordException(message, type) + } + + fun recordExceptionWithAttributes(message: String, type: String, attributes: HashMap) { + delegate.recordException(message, type, attributes) + } + + fun setStatus(code: Int) { + delegate.setStatus(code) + } + + fun end(epochSeconds: Double) { + delegate.end(epochSeconds) + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealTracer.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealTracer.kt new file mode 100644 index 0000000000..6d90d968b9 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/RealTracer.kt @@ -0,0 +1,16 @@ +package com.launchdarkly.LDNative + +import com.launchdarkly.observability.bridge.KotlinTracer + +/** + * Bindable wrapper around [KotlinTracer] for C# via Xamarin binding. + */ +class RealTracer internal constructor( + private val delegate: KotlinTracer +) { + fun spanBuilder(name: String, startTimeEpochSeconds: Double, + traceId: String, spanId: String, parentSpanId: String): RealSpanBuilder { + val builder = delegate.spanBuilder(name, startTimeEpochSeconds, traceId, spanId, parentSpanId) + return RealSpanBuilder(builder) + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/global.json b/sdk/@launchdarkly/mobile-dotnet/global.json new file mode 100644 index 0000000000..ce8c0c4668 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.312", + "rollForward": "latestPatch" + } +} 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..fc751ea678 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs +++ b/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs @@ -1,4 +1,6 @@ +using System; using Foundation; +using ObjCRuntime; using UIKit; namespace LDObserveMaciOS @@ -58,9 +60,9 @@ interface ObservabilityBridge [Export("startWithMobileKey:observability:replay:")] void Start(string mobileKey, ObjcObservabilityOptions observability, ObjcSessionReplayOptions replay); - [Export("getHookProxy")] + [Export("getSessionReplayHookProxy")] [NullAllowed] - ObservabilityHookProxy GetHookProxy(); + SessionReplayHookProxy GetSessionReplayHookProxy(); } [BaseType(typeof(NSObject))] @@ -68,6 +70,76 @@ 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); + + [Static, Export("getObservabilityHookProxy")] + [return: NullAllowed] + ObservabilityHookProxy GetObservabilityHookProxy(); + + [Static, Export("getObjcTracer")] + [return: NullAllowed] + ObjcTracer GetObjcTracer(); + } + + [BaseType(typeof(NSObject))] + interface ObjcTracer + { + [Export("spanBuilderWithName:startTime:traceId:spanId:parentSpanId:")] + ObjcSpanBuilder SpanBuilder(string name, double startTime, string traceId, string spanId, string parentSpanId); + } + + [BaseType(typeof(NSObject))] + interface ObjcSpanBuilder + { + [Export("traceId")] + string TraceId { get; } + + [Export("spanId")] + string SpanId { get; } + + [Export("spanKind")] + nint SpanKind { get; } + + [Export("setAttributeWithKey:value:")] + void SetAttribute(string key, NSObject value); + + [Export("setAttributes:")] + void SetAttributes(NSDictionary attributes); + + [Export("addEventWithName:")] + void AddEvent(string name); + + [Export("addEventWithName:attributes:")] + void AddEvent(string name, NSDictionary attributes); + + [Export("recordExceptionWithMessage:type:")] + void RecordException(string message, string type); + + [Export("recordExceptionWithMessage:type:attributes:")] + void RecordException(string message, string type, NSDictionary attributes); + + [Export("setStatusCode:")] + void SetStatus(nint code); + + [Export("endWithTime:")] + void End(double time); } [BaseType(typeof(NSObject))] @@ -83,4 +155,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..dbf82b0947 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift +++ b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift @@ -73,8 +73,8 @@ public final class ObservabilityBridge: NSObject { return sdkVersion } - @objc public func getHookProxy() -> ObservabilityHookProxy? { - return LDObserve.shared.hookProxy + @objc public func getSessionReplayHookProxy() -> SessionReplayHookProxy? { + return LDReplay.shared.hookProxy } @objc public func start(mobileKey: String, @@ -96,7 +96,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..073edaec1c 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.5.0 + 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..efb601b03b 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 @@ -17,11 +15,12 @@ + - + @@ -35,7 +34,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..428bafb56f 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 @@ -47,6 +45,10 @@ true + + + + - + 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/bridge/TraceBuilderAdapter.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/TraceBuilderAdapter.cs new file mode 100644 index 0000000000..aee6f33a90 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/TraceBuilderAdapter.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; + +#if IOS +using Foundation; +using LDObserveMaciOS; +#elif ANDROID +using LDObserveAndroid; +#endif + +namespace LaunchDarkly.Observability; + +/// +/// Adapts .NET objects into calls on the native +/// tracer builder API, which creates real OpenTelemetry spans on the +/// native side and feeds them through the native pipeline. +/// +internal sealed class TraceBuilderAdapter +{ +#if IOS + private readonly ObjcTracer _tracer; + + internal TraceBuilderAdapter(ObjcTracer tracer) + { + _tracer = tracer; + } + + internal void Export(Activity activity) + { + var startTime = new DateTimeOffset(activity.StartTimeUtc, TimeSpan.Zero) + .ToUnixTimeMilliseconds() / 1000.0; + var endTime = startTime + activity.Duration.TotalSeconds; + + var builder = _tracer.SpanBuilder( + activity.DisplayName, + startTime, + activity.TraceId.ToString(), + activity.SpanId.ToString(), + activity.ParentSpanId.ToString() + ); + + foreach (var tag in activity.TagObjects) + { + builder.SetAttribute(tag.Key, DictionaryTypeConverters.ToNSObject(tag.Value)); + } + + foreach (var evt in activity.Events) + { + if (evt.Tags.Any()) + { + var eventAttrs = new NSMutableDictionary(); + foreach (var tag in evt.Tags) + { + eventAttrs[new NSString(tag.Key)] = DictionaryTypeConverters.ToNSObject(tag.Value); + } + builder.AddEvent(evt.Name, eventAttrs); + } + else + { + builder.AddEvent(evt.Name); + } + } + + var statusCode = activity.Status switch + { + ActivityStatusCode.Ok => 1, + ActivityStatusCode.Error => 2, + _ => 0, + }; + if (statusCode != 0) + { + builder.SetStatus(statusCode); + } + + builder.End(endTime); + } + +#elif ANDROID + private readonly RealTracer _tracer; + + internal TraceBuilderAdapter(RealTracer tracer) + { + _tracer = tracer; + } + + internal void Export(Activity activity) + { + var startTime = new DateTimeOffset(activity.StartTimeUtc, TimeSpan.Zero) + .ToUnixTimeMilliseconds() / 1000.0; + var endTime = startTime + activity.Duration.TotalSeconds; + + var builder = _tracer.SpanBuilder( + activity.DisplayName, + startTime, + activity.TraceId.ToString(), + activity.SpanId.ToString(), + activity.ParentSpanId.ToString() + ); + + foreach (var tag in activity.TagObjects) + { + var jVal = DictionaryTypeConverters.ToJavaObject(tag.Value); + if (jVal != null) + { + builder.SetAttribute(tag.Key, jVal); + } + } + + foreach (var evt in activity.Events) + { + if (evt.Tags.Any()) + { + var eventAttrs = new Dictionary(); + foreach (var tag in evt.Tags) + { + var jVal = DictionaryTypeConverters.ToJavaObject(tag.Value); + if (jVal != null) + { + eventAttrs[tag.Key] = jVal; + } + } + builder.AddEventWithAttributes(evt.Name, eventAttrs); + } + else + { + builder.AddEvent(evt.Name); + } + } + + var statusCode = activity.Status switch + { + ActivityStatusCode.Ok => 1, + ActivityStatusCode.Error => 2, + _ => 0, + }; + if (statusCode != 0) + { + builder.SetStatus(statusCode); + } + + builder.End(endTime); + } +#endif +} 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..b5f08e346f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs @@ -1,124 +1,38 @@ using System.Collections.Generic; using System.Linq; using LaunchDarkly.Sdk; +using OpenTelemetry.Trace; #if IOS -using UIKit; using Foundation; using LDObserveMaciOS; #endif -namespace LaunchDarkly.SessionReplay; +namespace LaunchDarkly.Observability; /// /// Static facade over the native observability bridge. /// On platforms without a native implementation, methods are no-ops. /// -public static class LDObserve +public static partial class LDObserve { - /// - /// Optional C# mirror of the Swift Severity raw values. - /// Make sure the numeric values match your Swift enum. - /// - public enum Severity { - /// Unspecified severity (0). - Unspecified = 0, - - /// Trace severity (1). - Trace = 1, - - /// Trace1 severity (2). - Trace2 = Trace + 1, - - /// Trace3 severity (3). - Trace3 = Trace2 + 1, - - /// Trace4 severity (4). - Trace4 = Trace3 + 1, - - /// Debug severity (5). - Debug = 5, - - /// Debug2 severity (6). - Debug2 = Debug + 1, - - /// Debug3 severity (7). - Debug3 = Debug2 + 1, - - /// Debug4 severity (8). - Debug4 = Debug3 + 1, - - /// Info severity (9). - Info = 9, - - /// Info2 severity (10). - Info2 = Info + 1, - - /// Info3 severity (12). - Info3 = Info2 + 1, - - /// Info4 severity (12). - Info4 = Info3 + 1, - - /// Warn severity (13). - Warn = 13, - - /// Warn2 severity (14). - Warn2 = Warn + 1, - - /// Warn3 severity (15). - Warn3 = Warn2 + 1, - - /// Warn4 severity (16). - Warn4 = Warn3 + 1, - - /// Error severity (17). - Error = 17, - - /// Error2 severity (18). - Error2 = Error + 1, - - /// Error3 severity (19). - Error3 = Error2 + 1, - - /// Error4 severity (20). - Error4 = Error3 + 1, - - /// Fatal severity (21). - Fatal = 21, - - /// Fatal2 severity (22). - Fatal2 = Fatal + 1, - - /// Fatal3 severity (23). - Fatal3 = Fatal2 + 1, - - /// Fatal4 severity (24). - 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 +#if ANDROID + private static readonly LDObserveAndroid.ObservabilityBridge _androidBridge = new(); #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 map = DictionaryTypeConverters.ToJavaDictionary(attributes); + _androidBridge.RecordLog(message, severity, map); #endif } @@ -133,6 +47,11 @@ 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 + _androidBridge.RecordError(message, cause); +#endif } /// @@ -140,6 +59,11 @@ 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 + _androidBridge.RecordMetric(name, value); +#endif } /// @@ -147,6 +71,11 @@ public static void RecordMetric(string name, double value) /// public static void RecordCount(string name, double value) { +#if IOS + LDObserveBridge.RecordCount(name, value); +#elif ANDROID + _androidBridge.RecordCount(name, value); +#endif } /// @@ -154,6 +83,11 @@ public static void RecordCount(string name, double value) /// public static void RecordIncr(string name, double value) { +#if IOS + LDObserveBridge.RecordIncr(name, value); +#elif ANDROID + _androidBridge.RecordIncr(name, value); +#endif } /// @@ -161,6 +95,11 @@ public static void RecordIncr(string name, double value) /// public static void RecordHistogram(string name, double value) { +#if IOS + LDObserveBridge.RecordHistogram(name, value); +#elif ANDROID + _androidBridge.RecordHistogram(name, value); +#endif } /// @@ -168,6 +107,21 @@ public static void RecordHistogram(string name, double value) /// public static void RecordUpDownCounter(string name, double value) { +#if IOS + LDObserveBridge.RecordUpDownCounter(name, value); +#elif ANDROID + _androidBridge.RecordUpDownCounter(name, value); +#endif } + /// + /// Returns the OpenTelemetry from the singleton. + /// + public static Tracer GetTracer() => LDTracer.Instance.Tracer; + + /// + /// Starts a new active span with the given name using the singleton tracer. + /// The returned should be disposed when the operation completes. + /// + public static TelemetrySpan StartActiveSpan(string name) => GetTracer().StartActiveSpan(name); } diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDTraceExporter.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDTraceExporter.cs new file mode 100644 index 0000000000..e6c17b5819 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDTraceExporter.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; +using OpenTelemetry; + +#if IOS +using LDObserveMaciOS; +#elif ANDROID +using LDObserveAndroid; +#endif + +namespace LaunchDarkly.Observability; + +/// +/// OpenTelemetry trace exporter that forwards completed spans to the +/// native SDK tracer via so they flow +/// through the native sampling and event pipeline. +/// +public sealed class LDTraceExporter : BaseExporter +{ + private readonly TraceBuilderAdapter? _adapter; + + public LDTraceExporter() + { +#if IOS + var nativeTracer = LDObserveBridge.GetObjcTracer(); + _adapter = nativeTracer != null ? new TraceBuilderAdapter(nativeTracer) : null; +#elif ANDROID + var nativeTracer = LDObserveBridgeAdapter.Tracer; + _adapter = nativeTracer != null ? new TraceBuilderAdapter(nativeTracer) : null; +#endif + } + + public override ExportResult Export(in Batch batch) + { + if (_adapter == null) + return ExportResult.Success; + + foreach (var activity in batch) + { + _adapter.Export(activity); + } + + return ExportResult.Success; + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDTracer.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDTracer.cs new file mode 100644 index 0000000000..4ab9a56462 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDTracer.cs @@ -0,0 +1,49 @@ +using System; +using OpenTelemetry; +using OpenTelemetry.Trace; +using OpenTelemetry.Resources; + +using OTelSdk = OpenTelemetry.Sdk; + +namespace LaunchDarkly.Observability; + +/// +/// Thread-safe singleton that owns the OpenTelemetry TracerProvider +/// configured with . +/// +public sealed class LDTracer : IDisposable +{ + private static readonly Lazy LazyInstance = new(() => new LDTracer()); + + private const string ServiceName = "ld-observability"; + + private readonly TracerProvider _tracerProvider; + + private LDTracer() + { + _tracerProvider = OTelSdk.CreateTracerProviderBuilder() + .AddSource(ServiceName) + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService(serviceName: ServiceName)) + .AddProcessor(new SimpleActivityExportProcessor(new LDTraceExporter())) + .Build()!; + + Tracer = _tracerProvider.GetTracer(ServiceName); + } + + /// + /// The global singleton instance. + /// + public static LDTracer Instance => LazyInstance.Value; + + /// + /// The OpenTelemetry backed by . + /// + public Tracer Tracer { get; } + + public void Dispose() + { + _tracerProvider.Dispose(); + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/Severity.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/Severity.cs new file mode 100644 index 0000000000..5a23aa1a16 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/Severity.cs @@ -0,0 +1,84 @@ +namespace LaunchDarkly.Observability; + + +/// +/// Optional C# mirror of the Swift Severity raw values. +/// Make sure the numeric values match your Swift enum. +/// +public enum Severity +{ + /// Unspecified severity (0). + Unspecified = 0, + + /// Trace severity (1). + Trace = 1, + + /// Trace1 severity (2). + Trace2 = Trace + 1, + + /// Trace3 severity (3). + Trace3 = Trace2 + 1, + + /// Trace4 severity (4). + Trace4 = Trace3 + 1, + + /// Debug severity (5). + Debug = 5, + + /// Debug2 severity (6). + Debug2 = Debug + 1, + + /// Debug3 severity (7). + Debug3 = Debug2 + 1, + + /// Debug4 severity (8). + Debug4 = Debug3 + 1, + + /// Info severity (9). + Info = 9, + + /// Info2 severity (10). + Info2 = Info + 1, + + /// Info3 severity (12). + Info3 = Info2 + 1, + + /// Info4 severity (12). + Info4 = Info3 + 1, + + /// Warn severity (13). + Warn = 13, + + /// Warn2 severity (14). + Warn2 = Warn + 1, + + /// Warn3 severity (15). + Warn3 = Warn2 + 1, + + /// Warn4 severity (16). + Warn4 = Warn3 + 1, + + /// Error severity (17). + Error = 17, + + /// Error2 severity (18). + Error2 = Error + 1, + + /// Error3 severity (19). + Error3 = Error2 + 1, + + /// Error4 severity (20). + Error4 = Error3 + 1, + + /// Fatal severity (21). + Fatal = 21, + + /// Fatal2 severity (22). + Fatal2 = Fatal + 1, + + /// Fatal3 severity (23). + Fatal3 = Fatal2 + 1, + + /// Fatal4 severity (24). + Fatal4 = Fatal3 + 1, +} diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/NativeObserve.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/NativeObserve.cs index 1ab97af3d8..8e12dfdac5 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,22 @@ 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 = LDObserveMaciOS.LDObserveBridge.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 = LDObserveAndroid.LDObserveBridgeAdapter.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/observe/api/LDViewExtensions.cs b/sdk/@launchdarkly/mobile-dotnet/observability/replay/api/LDViewExtensions.cs similarity index 100% rename from sdk/@launchdarkly/mobile-dotnet/observability/observe/api/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 @@ + + + + + + + + + + + + +