Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions sdk/@launchdarkly/mobile-dotnet/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"dotnet.defaultSolution": "mobile-dotnet.sln"
}
41 changes: 35 additions & 6 deletions sdk/@launchdarkly/mobile-dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,33 @@ The LaunchDarkly Observability SDK for .NET MAUI provides automatic and manual i
### Automatic Instrumentation

The .NET MAUI observability plugin automatically instruments:
- **HTTP Requests**: Outgoing HTTP requests
- **Crash Reporting**: Automatic crash reporting and stack traces
- **HTTP Requests**: Outgoing HTTP requests made through `HttpClient` (`instrumentation.networkRequests`; requires `HttpActivityPropagationSupport`, see [Enabling HTTP request tracing](#enabling-http-request-tracing))
- **Launch Times**: App launch performance (`instrumentation.launchTimes`)
- **Feature Flag Evaluations**: Evaluation events added to your spans
- **Session Management**: User session tracking and background timeout handling

Automatic crash reporting is not enabled yet; use `LDObserve.RecordError` to report exceptions.

## Prerequisites

* **.NET 9.0** or higher is required.
* MAUI support for **iOS** and **Android**.
* **.NET 9** or **.NET 10** with the MAUI workload.
* **Android** API 23 or later and **iOS** 15 or later.

### Enabling HTTP request tracing

HTTP tracing needs one setting in your app's project file:

```xml
<PropertyGroup>
<HttpActivityPropagationSupport>true</HttpActivityPropagationSupport>
</PropertyGroup>
```

The Android SDK sets this to `false` by default in every configuration, and the iOS SDK sets it to `false` in Release builds. While it is `false`, the trimmer removes the `System.Net.Http` diagnostics that the SDK listens to, so no HTTP spans are recorded even with `networkRequests: true`. The SDK cannot turn it back on at runtime.

## Example Application

A complete example application is available in the [sample](./sample) directory.
A complete example application is available in the [sample](./sample) directory. `MauiSample9.csproj` builds it for .NET 9 and `MauiSample10.csproj` for .NET 10; both share the same sources.

## Usage

Expand Down Expand Up @@ -64,6 +78,21 @@ public static class MauiProgram
}
```

Alternatively, register the plugin yourself when building the client configuration. `ObservabilityPlugin` takes the same options, including optional `SessionReplayOptions` as its second argument:

```csharp
using LaunchDarkly.Sdk.Client.Integrations;
using LaunchDarkly.SessionReplay;

var ldConfig = Configuration.Builder(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Enabled)
.Plugins(new PluginConfigurationBuilder()
.Add(new ObservabilityPlugin(
new ObservabilityOptions(isEnabled: true, serviceName: "maui-sample-app"),
new SessionReplayOptions(isEnabled: true))))
.Build();
var client = LdClient.Init(ldConfig, context, TimeSpan.FromSeconds(10));
```

#### Standalone (without a LaunchDarkly client)

If you are not using the LaunchDarkly client, pass your mobile key directly. This boots the native observability stack without registering a plugin:
Expand Down Expand Up @@ -162,7 +191,7 @@ LDObserve.RecordLog(
);
```

Supported severity levels: `Trace`, `Debug`, `Info`, `Warn`, `Error`, `Fatal`.
Supported severity levels: `Trace`, `Debug`, `Info`, `Warn`, `Error`, `Fatal`, each with numbered variants (for example `Info2` to `Info4`) matching the OpenTelemetry severity numbers.

##### Logs with Span Context

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@
<PackageReference Include="CommunityToolkit.Maui.NativeLibraryInterop.BuildTasks" Version="0.0.1-pre1" PrivateAssets="all" />
</ItemGroup>

<!-- KotlinX Serialization (Json) via NuGet so apps get it transitively -->
<ItemGroup>
<PackageReference Include="Org.Jetbrains.Kotlinx.KotlinxSerializationJson" Version="1.9.0" />
</ItemGroup>

<!-- AndroidX Compose UI and Lifecycle -->
<ItemGroup>
<!-- These are only needed to build the binding project itself.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import org.objectweb.asm.commons.ClassRemapper
import org.objectweb.asm.commons.Remapper

// ASM powers the package relocation performed by BundleOtelJarsTask. It is only
// needed on the build classpath; nothing from it is shipped.
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.ow2.asm:asm-commons:9.9")
}
}

plugins {
id("com.android.library")
Expand Down Expand Up @@ -151,6 +166,97 @@ abstract class BundleOtelJarsTask : DefaultTask() {
@get:org.gradle.api.tasks.OutputDirectory
abstract val outputServicesDir: org.gradle.api.file.DirectoryProperty

// Okio and OkHttp are moved under a private prefix before being written to
// the bundle.
//
// They have to be shipped: the OTLP exporters resolve their transport at
// runtime through ServiceLoader, the only HttpSenderProvider we register is
// the OkHttp one, and OkHttp's entire I/O layer is Okio (its disk-buffering
// and protobuf code depends on okio.ByteString directly too). Drop either and
// the provider fails to initialise, which is the "No HttpSenderProvider found
// on classpath" crash.
//
// But apps routinely acquire their own copy from unrelated bindings --
// Square.OkIO arrives transitively through AndroidX DataStore, which Firebase
// depends on -- and two definitions of the same class is a hard R8 failure in
// Release. Relocating ours makes the collision impossible instead of relying
// on the app's graph to stay clear.
private val shadedPrefix = "com/launchdarkly/sessionreplay/shaded/"
private val relocatedPackages = listOf("okio", "okhttp3")

private val pathRelocations: List<Pair<String, String>> =
relocatedPackages.map { "$it/" to "$shadedPrefix$it/" }
private val nameRelocations: List<Pair<String, String>> =
relocatedPackages.map { "$it." to "${shadedPrefix.replace('/', '.')}$it." }

/** Relocates a JAR entry path. Anchored at the start, so only the owning package moves. */
private fun relocateEntryName(name: String): String {
pathRelocations.forEach { (from, to) ->
if (name.startsWith(from)) return to + name.removePrefix(from)
}
return name
}

/**
* Relocates package references inside a string constant.
*
* Needed because some references never appear as bytecode class references.
* OkHttp builds the path of its public-suffix resource by concatenating the
* literal "okhttp3/internal/publicsuffix/" with a simple class name, so a
* remapper that only rewrote class references would relocate the resource but
* leave the lookup pointing at the old path.
*
* Slashed forms are matched at a delimiter so JVM descriptors embedded in
* strings ("Lokio/Path;") relocate too; dotted forms are matched only at the
* start, where Kotlin's @JvmName and ReplaceWith metadata put them, since an
* unanchored "okio." would be too eager.
*/
private fun relocateString(value: String): String {
var result = value
pathRelocations.forEach { (from, to) ->
if (!result.contains(from)) return@forEach
val sb = StringBuilder(result.length)
var cursor = 0
while (true) {
val at = result.indexOf(from, cursor)
if (at < 0) {
sb.append(result, cursor, result.length)
break
}
// 'L' and '[' precede a type in a descriptor; the rest are plain separators.
val preceding = if (at == 0) null else result[at - 1]
val atBoundary = preceding == null || preceding in "L[(;,/ <>"
sb.append(result, cursor, at)
sb.append(if (atBoundary) to else from)
cursor = at + from.length
}
result = sb.toString()
}
nameRelocations.forEach { (from, to) ->
if (result.startsWith(from)) result = to + result.removePrefix(from)
}
return result
}

/** Rewrites class references, descriptors, signatures and string constants. */
private fun relocateClass(bytes: ByteArray): ByteArray {
// The no-arg constructor is deprecated in ASM 9.9 but is the only one that
// exists in the older ASM that Gradle/AGP put on the runtime classpath.
@Suppress("DEPRECATION")
val remapper = object : Remapper() {
override fun map(internalName: String): String = relocateEntryName(internalName)

override fun mapValue(value: Any?): Any? =
if (value is String) relocateString(value) else super.mapValue(value)
}
val writer = ClassWriter(0)
// Flag 0: frames are copied through and remapped rather than recomputed.
// COMPUTE_FRAMES would need to load the referenced types, which are not
// on this build's classpath.
ClassReader(bytes).accept(ClassRemapper(writer, remapper), 0)
return writer.toByteArray()
}

@org.gradle.api.tasks.TaskAction
fun bundle() {
val out = outputJar.get().asFile
Expand Down Expand Up @@ -190,9 +296,12 @@ abstract class BundleOtelJarsTask : DefaultTask() {
continue
}

if (!classOrResourceEntries.containsKey(name)) {
val outName = relocateEntryName(name)
if (!classOrResourceEntries.containsKey(outName)) {
zip.getInputStream(entry).use { input ->
classOrResourceEntries[name] = input.readBytes()
val raw = input.readBytes()
classOrResourceEntries[outName] =
if (name.endsWith(".class")) relocateClass(raw) else raw
}
}
}
Expand All @@ -201,11 +310,15 @@ abstract class BundleOtelJarsTask : DefaultTask() {

// Merge once, write twice: the same content goes into both the
// bundle JAR and the standalone files on disk.
// Provider names are relocated as well. None of the SPIs we ship live in a
// relocated package today (they are all io.opentelemetry.*), so this is a
// no-op now, but it keeps the registrations correct if that ever changes.
val mergedServiceFiles = serviceFiles.mapValues { (_, contents) ->
contents
.flatMap { it.lineSequence() }
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.map { relocateString(it) }
.distinct()
.joinToString("\n") + "\n"
}
Expand Down Expand Up @@ -249,6 +362,8 @@ abstract class BundleOtelJarsTask : DefaultTask() {

logger.lifecycle(
"ldobserve-otel-bundle: ${classOrResourceEntries.size} class/resource entries, " +
"${classOrResourceEntries.keys.count { it.startsWith(shadedPrefix) }} relocated " +
"under $shadedPrefix, " +
"${mergedServiceFiles.size} merged service files (in-JAR + standalone), " +
"${inputJars.files.size} input JARs"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ public class ObservabilityBridge(
backendUrl = observability.backendUrl,
tracesApi = com.launchdarkly.observability.api.ObservabilityOptions.TracesApi(includeErrors = true, includeSpans = true),
metricsApi = com.launchdarkly.observability.api.ObservabilityOptions.MetricsApi.enabled(),
// Page views rely on the OTel `activity` instrumentation AAR, which MAUI no longer ships.
analytics = com.launchdarkly.observability.api.ObservabilityOptions.Analytics(pageViews = false),
// Screen views are reported from the MAUI layer, so the Android-side span is left off
// to avoid double-reporting navigation.
analytics = com.launchdarkly.observability.api.ObservabilityOptions.Analytics(screenViews = false),
instrumentations = com.launchdarkly.observability.api.ObservabilityOptions.Instrumentations(
crashReporting = false, launchTime = observability.launchTime
),
Expand Down
6 changes: 0 additions & 6 deletions sdk/@launchdarkly/mobile-dotnet/global.json

This file was deleted.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.0</Version>
<Version>0.11.3</Version>
<Authors>LaunchDarkly</Authors>
<Owners>LaunchDarkly</Owners>
<Company>LaunchDarkly</Company>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
</ItemGroup>

<ItemGroup Condition="$(TargetFramework.Contains('android'))">
<PackageReference Include="Org.Jetbrains.Kotlinx.KotlinxSerializationJson" Version="1.9.0" />
<!-- Pin Collection.Jvm to avoid NU1107 conflict between Navigation.Runtime and Navigation.Common paths. -->
<PackageReference Include="Xamarin.AndroidX.Collection.Jvm" Version="[1.5.0.3, 2.0.0)" />
<!-- Keep Collection.Ktx aligned with Collection.Jvm to avoid duplicate ArrayMapKt classes. -->
<PackageReference Include="Xamarin.AndroidX.Collection.Ktx" Version="[1.5.0.3, 2.0.0)" />
<!-- Keep Lifecycle Runtime/Ktx aligned to avoid duplicate FlowExtKt between runtime-android and runtime-ktx. -->
<!-- Hold Lifecycle at the line MAUI itself ships (2.9.x). Our Java only uses lifecycle APIs
that exist in 2.9 (ProcessLifecycleOwner, DefaultLifecycleObserver), and Lifecycle 2.11
forces SavedState 1.5 / Collection 1.6, which turns the SavedState and Collection floors
below into NU1605 downgrade errors in consumer apps.
Keep Lifecycle Runtime/Ktx aligned to avoid duplicate FlowExtKt between runtime-android and runtime-ktx. -->
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="[2.9.2.1, 3.0.0)" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Runtime.Ktx" Version="[2.9.2.1, 3.0.0)" />
<!-- Keep Common, LiveData.Core, and Process aligned with Runtime to avoid NU1608 in consumers. -->
Expand Down
2 changes: 1 addition & 1 deletion sdk/@launchdarkly/mobile-dotnet/sample/AppShell.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
Shell.BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}">

<ShellContent
Title="{OnPlatform iOS='MAUI iOS', Android='MAUI Android', WinUI='MAUI Windows'}"
x:Name="MainContent"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />

Expand Down
15 changes: 15 additions & 0 deletions sdk/@launchdarkly/mobile-dotnet/sample/AppShell.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,23 @@ public AppShell()
{
InitializeComponent();

MainContent.Title = BuildTitle();

Routing.RegisterRoute(nameof(CreditCardPage), typeof(CreditCardPage));
Routing.RegisterRoute(nameof(NumberPadPage), typeof(NumberPadPage));
Routing.RegisterRoute(nameof(DialogsPage), typeof(DialogsPage));
}

// MauiSample9 and MauiSample10 share these sources, so the title is what tells the two
// builds apart on a device.
private static string BuildTitle()
{
#if DEBUG
const string configuration = "Debug";
#else
const string configuration = "Release";
#endif
var platform = DeviceInfo.Platform == DevicePlatform.WinUI ? "Windows" : DeviceInfo.Platform.ToString();
return $"{platform} · .NET {Environment.Version.Major} · {configuration}";
}
}
14 changes: 14 additions & 0 deletions sdk/@launchdarkly/mobile-dotnet/sample/DialogsPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
using CommunityToolkit.Maui.Views;
#if NET10_0_OR_GREATER
// Toolkit v12 moved ShowPopupAsync out of CommunityToolkit.Maui.Views.
using CommunityToolkit.Maui.Extensions;
#endif
using System.Linq;
using System.Threading;
#if IOS
Expand Down Expand Up @@ -236,7 +240,13 @@ private async void OnToolkitPopupSheetClicked(object? sender, EventArgs e)
BackgroundColor = Color.FromArgb("#F2B8B5"),
TextColor = Colors.White
};
// CommunityToolkit.Maui replaced Popup.Close() with CloseAsync() in v12. The .NET 9 build
// stays on toolkit v11, which caps Microsoft.Maui.Essentials below 10.0.0.
#if NET10_0_OR_GREATER
closeBtn.Clicked += async (s, args) => await popup.CloseAsync();
#else
closeBtn.Clicked += (s, _) => popup.Close();
#endif

popup.Content = new VerticalStackLayout
{
Expand Down Expand Up @@ -268,8 +278,12 @@ private async void OnToolkitPopupSheetClicked(object? sender, EventArgs e)
_ = this.ShowPopupAsync(popup);

await AutoClose(async () => {
#if NET10_0_OR_GREATER
await popup.CloseAsync();
#else
popup.Close();
await Task.CompletedTask;
#endif
});
}

Expand Down
Loading
Loading