Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
09b0c1e
feat(maui): upgrade to OTel Java 1.66 / OTel Android 1.7 and shade bu…
abelonogov-ld Oct 2, 2026
8d4f125
feat(maui): add .NET 10 sample with Firebase compatibility check
abelonogov-ld Oct 2, 2026
c7e5350
fix(maui): restore AndroidX Lifecycle 2.9.2.1 floors to fix NU1605 in…
abelonogov-ld Oct 3, 2026
b73187d
feat(maui): catch up public API with Flutter observability SDK
abelonogov-ld Oct 6, 2026
16ecdb4
docs(maui): align README with current API and supported platforms
abelonogov-ld Oct 6, 2026
28f9397
Merge branch 'feat/maui-otel-upgrade-net10' into feat/maui-api-catch-up
abelonogov-ld Oct 6, 2026
4752099
docs(maui): document new observability, capture and privacy options
abelonogov-ld Oct 6, 2026
301f6e7
revert(maui): hold OTel at 1.51 / 0.11.0-alpha
abelonogov-ld Oct 6, 2026
2567a11
Merge branch 'feat/maui-otel-upgrade-net10' into feat/maui-api-catch-up
abelonogov-ld Oct 6, 2026
4098e02
chore(maui): bump LaunchDarkly.SessionReplay to 0.11.3
abelonogov-ld Oct 6, 2026
ddbed0c
Merge branch 'feat/maui-otel-upgrade-net10' into feat/maui-api-catch-up
abelonogov-ld Oct 6, 2026
43a4eeb
HttpActivityPropagationSupport
abelonogov-ld Oct 6, 2026
b959107
Merge branch 'feat/maui-otel-upgrade-net10' into feat/maui-api-catch-up
abelonogov-ld Oct 6, 2026
c92f0c9
Merge branch 'main' into feat/maui-api-catch-up
abelonogov-ld Oct 6, 2026
62df410
fix(maui): keep iOS custom headers and apply type rules to later views
abelonogov-ld Oct 6, 2026
0ebab59
refactor(maui): move type-based privacy rules out of the API catch-up
abelonogov-ld Oct 6, 2026
bada0a0
bump
abelonogov-ld Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion sdk/@launchdarkly/mobile-dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,34 @@ public static class MauiProgram
}
```

### Observability Options

`ObservabilityOptions` accepts the following settings, all optional:

| Option | Default | Description |
|---|---|---|
| `IsEnabled` | `true` | Whether observability telemetry is recorded. |
| `ServiceName` | `"observability-maui"` | The `service.name` resource attribute. Set it to identify your app. |
| `ServiceVersion` | Host app version | The `service.version` resource attribute. When not set, the app's version (`AppInfo.Current.VersionString`) is reported. |
| `OtlpEndpoint` | LaunchDarkly collector | OTLP/HTTP endpoint telemetry is exported to. Override it to send data through a proxy. |
| `BackendUrl` | LaunchDarkly backend | Backend used for session and replay metadata. |
| `ContextFriendlyName` | `null` | A human-readable name for the current context, shown instead of its key. |
| `Attributes` | `null` | Extra resource attributes attached to every exported signal, such as `deployment.environment`. |
| `CustomHeaders` | `null` | Extra HTTP headers added to OTLP exports, for example for a proxy or authentication. |
| `LogsApiLevel` | `ObservabilityLogLevel.Info` | Minimum severity of exported logs. `ObservabilityLogLevel.None` disables log export. |
| `Instrumentation` | All enabled | `NetworkRequests` and `LaunchTimes` toggles. |

```csharp
var observabilityOptions = new ObservabilityOptions(
serviceName: "my-maui-app",
serviceVersion: "2.4.0",
customHeaders: new Dictionary<string, string> { { "X-Proxy-Token", "..." } },
logsApiLevel: ObservabilityLogLevel.Warn
);
```

After initialization, `LDObserve.NativeVersion` returns the version of the underlying native observability SDK, which is useful in diagnostics and support requests.

### Recording Observability Data

After initialization of the LaunchDarkly client, use `LDObserve` to record metrics, logs, and errors:
Expand Down Expand Up @@ -398,6 +426,25 @@ public static class MauiProgram

The same third argument works with the standalone variant: `LDObserve.Init(mobileKey, observabilityOptions, sessionReplayOptions)`.

### Capture Options

`SessionReplayOptions` controls how often and at what quality frames are captured:

* `SampleRate`: (Default: `1.0`) Probability from `0.0` to `1.0` that a session is recorded when replay is enabled. `0.25` records about a quarter of sessions.
* `FrameRate`: (Default: `1.0`) Target capture rate in frames per second.
* `Scale`: (Default: `1.0`) Capture resolution: `1.0` is 1x (160 DPI), `2.0` is 2x, and so on. Higher values capture more detail but produce larger frames.
* `ImageQuality`: (Default: `0.3`) JPEG quality of exported frames, from `0.0` (smallest) to `1.0` (best).

```csharp
var replayOptions = new SessionReplayOptions(
isEnabled: true,
sampleRate: 0.25,
frameRate: 2.0,
scale: 1.5,
imageQuality: 0.5
);
```

### Privacy Options

You can control what information is captured during a session using `PrivacyOptions`:
Expand All @@ -406,10 +453,11 @@ You can control what information is captured during a session using `PrivacyOpti
* `MaskWebViews`: (Default: `false`) Masks all web view content.
* `MaskLabels`: (Default: `false`) Masks all text labels.
* `MaskImages`: (Default: `false`) Masks all images.
* `MinimumAlpha`: (Default: `0.02`) Opacity below which a view is treated as invisible and not masked.

### Manual Masking

You can manually mask or unmask specific UI components using the provided extension methods on any MAUI `View`.
You can manually mask, unmask, or ignore specific UI components using the provided extension methods on any MAUI `View`. Call them once the view has been attached to the screen (its `Handler` is set), for example from `OnHandlerChanged` or a page's `OnAppearing`; before that they have no effect.

```csharp
using LaunchDarkly.SessionReplay;
Expand All @@ -419,6 +467,9 @@ mySensitiveView.LDMask();

// Unmask a specific view
myPublicView.LDUnmask();

// Leave a view out of recordings (masked instead on Android)
myVideoView.LDIgnore();
```

## Contributing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ public class ObservabilityBridge(
debug = false,
otlpEndpoint = observability.otlpEndpoint,
backendUrl = observability.backendUrl,
contextFriendlyName = observability.contextFriendlyName,
customHeaders = observability.customHeaders ?: emptyMap(),
logsApiLevel = toLogLevel(observability.logsApiLevel),
tracesApi = com.launchdarkly.observability.api.ObservabilityOptions.TracesApi(includeErrors = true, includeSpans = true),
metricsApi = com.launchdarkly.observability.api.ObservabilityOptions.MetricsApi.enabled(),
// Screen views are reported from the MAUI layer, so the Android-side span is left off
Expand All @@ -101,8 +104,13 @@ public class ObservabilityBridge(
maskTextInputs = privacy.maskTextInputs,
maskText = privacy.maskLabels,
maskImageViews = privacy.maskImages,
maskWebViews = privacy.maskWebViews
)
maskWebViews = privacy.maskWebViews,
minimumAlpha = privacy.minimumAlpha.toFloat()
),
sampleRate = replay.sampleRate,
frameRate = replay.frameRate,
scale = replay.scale,
imageQuality = replay.imageQuality
)
} catch (t: Throwable) {
printException("LD:ObservabilityBridge failed to build ReplayOptions", t)
Expand Down Expand Up @@ -141,6 +149,11 @@ public class ObservabilityBridge(
private const val MAUI_DISTRO_NAME = "observability-maui-android"
}

private fun toLogLevel(severity: Int): com.launchdarkly.observability.api.ObservabilityOptions.LogLevel =
com.launchdarkly.observability.api.ObservabilityOptions.LogLevel.values()
.firstOrNull { it.level == severity }
?: com.launchdarkly.observability.api.ObservabilityOptions.LogLevel.INFO

private fun printException(prefix: String, t: Throwable) {
logger.error("$prefix ${t::class.java.name}: ${t.message}")
val writer = java.io.StringWriter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public class LDObservabilityOptions {
@JvmField var contextFriendlyName: String? = null
@JvmField var attributes: HashMap<String, Any?>? = null
@JvmField var launchTime: Boolean = true
@JvmField var customHeaders: HashMap<String, String>? = null
// OpenTelemetry severity number; Int.MAX_VALUE disables log export.
@JvmField var logsApiLevel: Int = 9

constructor()

Expand All @@ -19,8 +22,10 @@ public class LDObservabilityOptions {
otlpEndpoint: String,
backendUrl: String,
contextFriendlyName: String?,
attributes: HashMap<String, Any?>? = null,
launchTime: Boolean = true
attributes: HashMap<String, Any?>?,
launchTime: Boolean,
customHeaders: HashMap<String, String>?,
logsApiLevel: Int
) {
this.isEnabled = isEnabled
this.serviceName = serviceName
Expand All @@ -30,6 +35,8 @@ public class LDObservabilityOptions {
this.contextFriendlyName = contextFriendlyName
this.attributes = attributes
this.launchTime = launchTime
this.customHeaders = customHeaders
this.logsApiLevel = logsApiLevel
}
}

Expand Down Expand Up @@ -61,16 +68,28 @@ public class LDSessionReplayOptions {
@JvmField var isEnabled: Boolean = true
@JvmField var serviceName: String = ""
@JvmField var privacy: LDPrivacyOptions = LDPrivacyOptions()
@JvmField var sampleRate: Double = 1.0
@JvmField var frameRate: Double = 1.0
@JvmField var scale: Double = 1.0
@JvmField var imageQuality: Double = 0.3

constructor()

constructor(
isEnabled: Boolean,
serviceName: String,
privacy: LDPrivacyOptions
privacy: LDPrivacyOptions,
sampleRate: Double,
frameRate: Double,
scale: Double,
imageQuality: Double
) {
this.isEnabled = isEnabled
this.serviceName = serviceName
this.privacy = privacy
this.sampleRate = sampleRate
this.frameRate = frameRate
this.scale = scale
this.imageQuality = imageQuality
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ interface ObjcObservabilityOptions

[Export("launchTimes")]
bool LaunchTimes { get; set; }

[NullAllowed, Export("customHeaders")]
NSDictionary CustomHeaders { get; set; }

[Export("logsApiLevel")]
nint LogsApiLevel { get; set; }
}

[BaseType(typeof(NSObject))]
Expand All @@ -55,6 +61,21 @@ interface ObjcSessionReplayOptions

[Export("maskImages")]
bool MaskImages { get; set; }

[Export("minimumAlpha")]
double MinimumAlpha { get; set; }

[Export("sampleRate")]
double SampleRate { get; set; }

[Export("frameRate")]
double FrameRate { get; set; }

[Export("scale")]
double Scale { get; set; }

[Export("imageQuality")]
double ImageQuality { get; set; }
}

[BaseType(typeof(NSObject))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ internal func buildResourceAttributes(_ source: NSDictionary?) -> [String: Attri
return result
}

internal func buildCustomHeaders(_ source: NSDictionary?) -> [String: String] {
guard let source, source.count > 0 else {
return [:]
}
var result = [String: String](minimumCapacity: source.count)
for (key, value) in source {
result[String(describing: key)] = (value as? String) ?? String(describing: value)
}
return result
}

@objc(ObservabilityBridge)
public final class ObservabilityBridge: NSObject {

Expand Down Expand Up @@ -47,6 +58,8 @@ public final class ObservabilityBridge: NSObject {
otlpEndpoint: observability.otlpEndpoint,
backendUrl: observability.backendUrl,
resourceAttributes: buildResourceAttributes(observability.attributes),
customHeaders: buildCustomHeaders(observability.customHeaders),
logsApiLevel: .init(rawValue: observability.logsApiLevel) ?? .none,
crashReporting: .init(source: .none),
instrumentation: .init(
urlSession: .disabled, // Network tracing happens on the .NET side via System.Net.Http activities.
Expand All @@ -66,12 +79,17 @@ public final class ObservabilityBridge: NSObject {
observabilityPlugin,
SessionReplay(options: .init(
isEnabled: replay.isEnabled,
sampleRate: replay.sampleRate,
privacy: .init(
maskTextInputs: replay.maskTextInputs,
maskWebViews: replay.maskWebViews,
maskLabels: replay.maskLabels,
maskImages: replay.maskImages
)
maskImages: replay.maskImages,
minimumAlpha: CGFloat(replay.minimumAlpha)
),
frameRate: replay.frameRate,
scale: CGFloat(replay.scale),
imageQuality: CGFloat(replay.imageQuality)
))
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public final class ObjcObservabilityOptions: NSObject {
// Not consumed yet — network tracing is handled on the .NET side. Kept for future iOS-native URL session instrumentation.
@objc public var networkRequests: Bool = true
@objc public var launchTimes: Bool = true
@objc public var customHeaders: NSDictionary?
/// OpenTelemetry severity number; values above `fatal4` (24) disable log export.
@objc public var logsApiLevel: Int = 9

@objc public override init() {
super.init()
Expand All @@ -23,6 +26,11 @@ public final class ObjcSessionReplayOptions: NSObject {
@objc public var maskWebViews: Bool = false
@objc public var maskLabels: Bool = false
@objc public var maskImages: Bool = false
@objc public var minimumAlpha: Double = 0.02
@objc public var sampleRate: Double = 1.0
@objc public var frameRate: Double = 1.0
@objc public var scale: Double = 1.0
@objc public var imageQuality: Double = 0.3

@objc public override init() {
super.init()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<PackageId>LaunchDarkly.SessionReplay</PackageId>
<Version>0.11.3</Version>
<Version>0.14.1</Version>
<Authors>LaunchDarkly</Authors>
<Owners>LaunchDarkly</Owners>
<Company>LaunchDarkly</Company>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using LaunchDarkly.SessionReplay;

#if ANDROID
Expand All @@ -10,12 +11,14 @@ internal static class LDNativeAndroidMapping
return new global::LDObserveAndroid.LDObservabilityOptions(
options.IsEnabled,
options.ServiceName,
options.ServiceVersion,
options.ResolvedServiceVersion,
options.OtlpEndpoint,
options.BackendUrl,
options.ContextFriendlyName,
DictionaryTypeConverters.ToJavaDictionary(options.Attributes),
options.Instrumentation.LaunchTimes
options.Instrumentation.LaunchTimes,
options.CustomHeaders is null ? null : new Dictionary<string, string>(options.CustomHeaders),
(int)options.LogsApiLevel
);
}

Expand All @@ -32,7 +35,11 @@ internal static class LDNativeAndroidMapping
return new global::LDObserveAndroid.LDSessionReplayOptions(
options.IsEnabled,
options.ServiceName,
nativePrivacy
nativePrivacy,
options.SampleRate,
options.FrameRate,
options.Scale,
options.ImageQuality
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ internal class LDNative
public SessionReplayOptions Replay { get; set; }
public String NativeVersion { get; set; } = string.Empty;

internal static string CurrentNativeVersion { get; private set; } = string.Empty;

private LDNative(ObservabilityOptions observability, SessionReplayOptions replay)
{
Observability = observability;
Expand Down Expand Up @@ -57,6 +59,8 @@ internal static LDNative Start(string mobileKey, ObservabilityOptions observabil
bridge.Start(mobileKey, observability, replay, observabilityVersion);
#endif

CurrentNativeVersion = ldNative.NativeVersion;

return ldNative;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if IOS
using System.Linq;
using Foundation;
using LDObserveMaciOS;
using LaunchDarkly.SessionReplay;
Expand All @@ -23,12 +24,18 @@ public void Start(string mobileKey, ObservabilityOptions observability, SessionR
var objcObs = new ObjcObservabilityOptions
{
ServiceName = observability.ServiceName ?? "observability-maui",
ServiceVersion = observability.ServiceVersion ?? "0.1.0",
ServiceVersion = observability.ResolvedServiceVersion,
OtlpEndpoint = observability.OtlpEndpoint ?? "https://otel.observability.app.launchdarkly.com:4318",
BackendUrl = observability.BackendUrl ?? "https://pub.observability.app.launchdarkly.com",
Attributes = DictionaryTypeConverters.ToNSDictionary(observability.Attributes),
NetworkRequests = observability.Instrumentation.NetworkRequests,
LaunchTimes = observability.Instrumentation.LaunchTimes
LaunchTimes = observability.Instrumentation.LaunchTimes,
CustomHeaders = observability.CustomHeaders is null
? null
: NSDictionary.FromObjectsAndKeys(
observability.CustomHeaders.Values.Select(v => (NSObject)new NSString(v)).ToArray(),
observability.CustomHeaders.Keys.Select(k => (NSObject)new NSString(k)).ToArray()),
LogsApiLevel = (nint)(int)observability.LogsApiLevel
};

var objcReplay = new ObjcSessionReplayOptions
Expand All @@ -37,7 +44,12 @@ public void Start(string mobileKey, ObservabilityOptions observability, SessionR
MaskTextInputs = replay.Privacy?.MaskTextInputs ?? true,
MaskWebViews = replay.Privacy?.MaskWebViews ?? false,
MaskLabels = replay.Privacy?.MaskLabels ?? false,
MaskImages = replay.Privacy?.MaskImages ?? false
MaskImages = replay.Privacy?.MaskImages ?? false,
MinimumAlpha = replay.Privacy?.MinimumAlpha ?? 0.02,
SampleRate = replay.SampleRate,
FrameRate = replay.FrameRate,
Scale = replay.Scale,
ImageQuality = replay.ImageQuality
};

_native.Start(mobileKey, objcObs, objcReplay, observabilityVersion);
Expand Down
Loading
Loading