diff --git a/.github/scripts/run-emulator-tests.sh b/.github/scripts/run-emulator-tests.sh
index dc492a7..56e0d88 100755
--- a/.github/scripts/run-emulator-tests.sh
+++ b/.github/scripts/run-emulator-tests.sh
@@ -24,11 +24,26 @@ POLL_INTERVAL=5
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PROJECT="${REPO_ROOT}/tests/DatadogNet.Android.DeviceTests/DatadogNet.Android.DeviceTests.csproj"
-# The .NET 9 band builds net8/net9 and the .NET 10 band builds net9/net10, so pick the SDK that
-# owns the requested target framework. The SDK is resolved from the working directory, and the
-# repository's global.json pins .NET 9, hence the scratch directory.
+# The SDK band is chosen by the *Android API level* in the target framework, not by the .NET
+# version alone, because that is what decides which workload owns the runtime packs:
+#
+# net8.0-android34.0 -> android 34.0.x, in the .NET 8 band
+# net9.0-android35.0 -> android 35.0.x, in the .NET 9 band
+# net10.0-android36.0 -> android 36.0.x, in the .NET 10 band
+#
+# The .NET 9 band compiles a net8 app happily - it has the API 34 *reference* packs - and then
+# fails at packaging time, because it has no API 34 *runtime* packs and they cannot be restored
+# from NuGet:
+#
+# error NETSDK1112: The runtime pack for Microsoft.Android.Runtime.34.android-x64 was not
+# downloaded. Try running a NuGet restore with the RuntimeIdentifier 'android-x64'.
+#
+# The restore that error suggests does not help; the packs come from the workload. The SDK is
+# resolved from the working directory, and this repository's global.json pins .NET 9, hence the
+# scratch directory below.
case "${TARGET_FRAMEWORK}" in
net10.0-*) sdk_major=10 ;;
+ net8.0-*) sdk_major=8 ;;
*) sdk_major=9 ;;
esac
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2ea0f61..bc40027 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,13 +18,15 @@ on:
required: false
default: ''
type: string
- e2e-target-framework:
+ e2e-target-frameworks:
description: >
- Which of the packages' target frameworks the emulator smoke test runs against. Defaults
- to net10, whose assets are produced by the merge step in BuildNugets.sh and so are the
- ones worth proving at runtime.
+ Which of the packages' target frameworks the emulator smoke tests run against, as a JSON
+ array. Defaults to the two extremes: net8, the oldest asset set and the one nothing else
+ exercises, and net10, whose assets are produced by the merge step in BuildNugets.sh and so
+ are the only ones that could be grafted in wrong. net9 sits between them and comes out of
+ the same pack pass as net8, so testing it too would buy little for the runner minutes.
required: false
- default: net10.0-android36.0
+ default: '["net8.0-android34.0", "net10.0-android36.0"]'
type: string
e2e-api-level:
description: Android API level the emulator smoke test boots.
@@ -190,34 +192,49 @@ jobs:
-p:DatadogPackageVersion="${{ inputs.version }}" )
e2e:
- name: emulator smoke test
+ name: emulator smoke test (${{ matrix.target-framework }})
timeout-minutes: 60
needs: pack
runs-on: ubuntu-latest
+ strategy:
+ # fail-fast off: when one target framework breaks it is worth knowing whether the other did
+ # too, since "net8 only" and "both" point at very different causes.
+ fail-fast: false
+ matrix:
+ target-framework: ${{ fromJSON(inputs.e2e-target-frameworks) }}
steps:
- uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
+ # 8.0.x is here for the net8 matrix leg only - see the workload step below.
dotnet-version: |
+ 8.0.x
9.0.x
10.0.x
- name: Install Android workloads
run: |
- # The device app is built for the target framework under test, so install the workload
- # for the band that owns it. global.json pins .NET 9; the app may need .NET 10.
- mkdir -p "${RUNNER_TEMP}/sdk10"
- ( cd "${RUNNER_TEMP}/sdk10" \
- && dotnet new globaljson --sdk-version "$(dotnet --list-sdks | grep '^10\.' | tail -1 | cut -d' ' -f1)" --force \
- && dotnet workload install android )
- dotnet workload install android
+ # The device app is built for the target framework under test, and the band that owns it
+ # is decided by the *API level* rather than the .NET version - the .NET 9 band has API 34
+ # reference packs but no API 34 runtime packs, so a net8 app compiles and then fails to
+ # package. See run-emulator-tests.sh. Install all three bands' workloads so any target
+ # framework in the matrix can build.
+ for sdk in 8 9 10; do
+ version="$(dotnet --list-sdks | grep "^${sdk}\." | tail -1 | cut -d' ' -f1)"
+ [ -z "${version}" ] && continue
+
+ mkdir -p "${RUNNER_TEMP}/sdk${sdk}"
+ ( cd "${RUNNER_TEMP}/sdk${sdk}" \
+ && dotnet new globaljson --sdk-version "${version}" --force \
+ && dotnet workload install android )
+ done
- name: Set up the Android SDK
uses: android-actions/setup-android@v3
with:
- packages: 'platforms;android-35 platforms;android-36'
+ packages: 'platforms;android-34 platforms;android-35 platforms;android-36'
- name: Download packages
uses: actions/download-artifact@v4
@@ -243,13 +260,13 @@ jobs:
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
disable-animations: true
- script: ./.github/scripts/run-emulator-tests.sh "${{ inputs.version }}" "${{ inputs.e2e-target-framework }}"
+ script: ./.github/scripts/run-emulator-tests.sh "${{ inputs.version }}" "${{ matrix.target-framework }}"
- name: Upload emulator logs
if: always()
uses: actions/upload-artifact@v4
with:
- name: emulator-test-logs
+ name: emulator-test-logs-${{ matrix.target-framework }}
path: emulator-tests.log
if-no-files-found: ignore
retention-days: 7
diff --git a/Directory.Build.props b/Directory.Build.props
index 5ebacaf..16da222 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
not bound here at all.
-->
3.12.1
- 1
+ 2$(DatadogNativeVersion).$(DatadogBindingRevision)
diff --git a/build/packages.tsv b/build/packages.tsv
index 8387982..9d68d25 100644
--- a/build/packages.tsv
+++ b/build/packages.tsv
@@ -21,3 +21,36 @@ SessionReplayCompose dd-sdk-android-session-replay-compose SessionReplay,Interna
Ndk dd-sdk-android-ndk Core,Internal
WebView dd-sdk-android-webview Core,Internal
OkHttp dd-sdk-android-okhttp Internal,RUM,Trace
+
+# ----------------------------------------------------------------------------------------------
+# NOT BOUND, and why.
+#
+# Datadog publishes 27 dd-sdk-android* artifacts; the 13 above are the ones a .NET or MAUI app can
+# use. This section exists so "is X missing on purpose?" has an answer here rather than in someone's
+# memory. Nothing reads it - it is documentation, kept next to the list it is about.
+#
+# dd-sdk-android An umbrella/BOM artifact, not a library.
+# -gradle-plugin A Gradle build plugin. Nothing to bind.
+# -benchmark-internal Datadog's own benchmarking harness, not product API.
+#
+# -ktx Kotlin extension functions. They bind as static methods on synthetic
+# -rx *Kt classes taking Kotlin function interfaces, and every one of them
+# -rum-coroutines wraps a Kotlin idiom - coroutines, Rx, scope functions - that has no
+# -trace-coroutines C# meaning. A caller would write more code than they saved.
+#
+# -coil Instrument third-party Kotlin/Java libraries. A MAUI app draws
+# -fresco through MAUI handlers and loads images through MAUI, so none of
+# -glide these libraries is present to instrument. Add the artifact yourself
+# -sqldelight if you host native views that use one.
+# -timber
+#
+# -compose RUM auto-instrumentation for Jetpack Compose - distinct from
+# session-replay-compose, which IS bound. Nothing in MAUI draws
+# Compose. Worth revisiting for an app hosting Compose content.
+#
+# -tv Android TV. Worth revisiting if .NET for Android TV becomes a
+# target.
+#
+# -trace-otel OpenTelemetry interop. Reachable in principle, but a .NET app that
+# -okhttp-otel wants OTel semantics is better served by the OpenTelemetry .NET SDK
+# exporting to Datadog's OTLP intake than by a binding over Java's.
diff --git a/docs/release-notes/3.12.1.2.md b/docs/release-notes/3.12.1.2.md
new file mode 100644
index 0000000..6d9954c
--- /dev/null
+++ b/docs/release-notes/3.12.1.2.md
@@ -0,0 +1,197 @@
+## What's changed
+
+Binding-only release. The native SDK is unchanged — still
+[dd-sdk-android 3.12.1](https://github.com/DataDog/dd-sdk-android/releases/tag/3.12.1) — and so are
+the package IDs, namespaces and every existing signature. Upgrading is a version bump.
+
+Everything here is an **addition to the convenience layer**, and every one exists because the
+generated binding leaves a member technically reachable but effectively unusable from C#.
+
+The two that matter most are blocked by Kotlin function types, which bind as *interfaces* rather
+than delegates: you cannot pass a lambda, a method group, or an `Action<>`, so calling them means
+declaring a `Java.Lang.Object` subclass and returning `null` for Kotlin's `Unit`. That is a lot of
+ceremony for a header setter and a session id.
+
+> **Package versions are `.`.** `3.12.1.2` is
+> dd-sdk-android `3.12.1`, binding revision `2`. The fourth component belongs to this repository and
+> advances when the bindings or packaging change while the native artifacts stay put.
+
+## Added
+
+### Trace header injection — `DatadogPropagation.Inject`
+
+The one that matters most. Distributed tracing is the reason most apps enable Trace at all, and this
+was the wall in front of it:
+
+```kotlin
+fun inject(context: DatadogSpanContext, carrier: C, setter: (C, String, String) -> Unit)
+```
+
+That trailing lambda binds as `Kotlin.Jvm.Functions.IFunction3`. Getting it wrong does not fail
+loudly — the request simply goes out with no trace headers, and the trace stops at the app.
+
+```csharp
+var span = GlobalDatadogTracer.Get ().BuildSpan ("http.request").Start ();
+
+foreach (var header in GlobalDatadogTracer.Get ().Propagate ().Inject (span.Context ()))
+ request.Headers.TryAddWithoutValidation (header.Key, header.Value);
+```
+
+Three forms: one returning a new dictionary, one writing into a dictionary you own, and one taking
+an `Action` for carriers that are not dictionaries — an `HttpRequestMessage`, gRPC
+metadata, a message envelope.
+
+**`extract` is deliberately not wrapped.** Its signature nests a *second* Kotlin lambda — the SDK
+hands you a visitor you call per header, whose `Boolean` return governs whether iteration continues
+— and that contract is not documented anywhere the binding can see. Guessing it would produce
+something that silently reads one header and stops. Extraction is also the rare direction for a
+mobile app, which receives responses rather than serving requests.
+
+### Span outcome and identity — `DatadogSpanExtensions`
+
+```csharp
+span.SetError (exception); // or SetError (kind, message, stack)
+var traceId = span.GetTraceId (); // 32 lowercase hex
+var spanId = span.GetSpanId (); // decimal
+```
+
+`SetError` exists because `addThrowable` takes a `java.lang.Throwable`, which a .NET exception is
+not — so the managed type, message and stack have to be set as separate fields for any of them to
+reach Datadog. `setError` additionally takes a `java.lang.Boolean`, so even the flag needs boxing.
+
+`GetTraceId` and `GetSpanId` render the ids **the way dd-sdk-android's own `DatadogInterceptor`
+does** when it links a RUM resource to its APM trace: `DatadogTraceId.toHexString()` and
+`String.valueOf(long)`. The asymmetry — hex for one, decimal for the other — is Datadog's wire
+format rather than a choice, and matching it exactly is what makes the correlation work.
+`toHexString()` is `toHexStringPadded(…, 32)` on both `DD128bTraceId` and `DD64bTraceId`, so the
+result is always 32 characters; a 64-bit id is the low half behind sixteen zeros, never a
+16-character string.
+
+### Session id — `RumMonitorExtensions.GetCurrentSessionIdAsync`
+
+```csharp
+var sessionId = await GlobalRumMonitor.Get ().GetCurrentSessionIdAsync ();
+```
+
+`getCurrentSessionId` takes a `kotlin.jvm.functions.Function1`, so reading a value most apps want in
+order to put it on a support ticket previously required an `IFunction1` implementation returning
+`null` for Kotlin's `Unit`.
+
+### Single-value attributes
+
+`DatadogAttributes.ToJava (value, key)` is now **public**. `RumMonitor.addAttribute`,
+`addFeatureFlagEvaluation` and `Logger.addAttribute` all take a bare `java.lang.Object` while
+`DatadogAttributes` only exposed the map form, so a caller either hand-wrapped the value — which is
+exactly what `DatadogAttributes` exists to avoid — or round-tripped a one-element dictionary.
+
+```csharp
+monitor.AddAttribute ("cart.id", cartId);
+monitor.AddFeatureFlagEvaluation ("new-checkout", true);
+logger.AddAttribute ("tenant", tenantId);
+```
+
+### Resource overloads
+
+`StartResource`, `StopResource` and two `StopResourceWithError` forms on `RumMonitorExtensions`,
+taking `int?`/`long?` instead of `Java.Lang.Integer`/`Java.Lang.Long`, and an `Exception` overload.
+The exception form also guards a trap the C# signature does not show: `stackTrace` is `String` in
+Kotlin, **not** `String?`, so passing null reaches Java's own null check and throws — unlike
+`errorType` beside it, and unlike `addErrorWithStacktrace`, both of which are genuinely nullable.
+
+## One thing deliberately *not* added
+
+An earlier draft added a `BuildSpan(string)` overload, on the grounds that 3.x `DatadogTracer`
+declares `buildSpan(CharSequence)` and callers were writing
+`BuildSpan(new Java.Lang.String(name))`. That was wrong: the generator already emits
+`IDatadogTracerExtensions.BuildSpan(IDatadogTracer, string)` in the same namespace, and a second one
+made the call ambiguous. If you have been wrapping the operation name, you only need the `using`:
+
+```csharp
+using Com.Datadog.Android.Trace.Api.Tracer;
+```
+
+## Documentation
+
+`build/packages.tsv` now records **what is not bound, and why** — all fourteen `dd-sdk-android*`
+artifacts Datadog publishes that this repository does not wrap, each with a reason. Datadog ships
+27; thirteen are bound. The rest fall into four groups: Kotlin-ecosystem integrations whose idioms
+have no C# meaning (`-ktx`, `-rx`, the two coroutine modules), instrumentation for Java libraries a
+.NET app does not use (`-glide`, `-coil`, `-fresco`, `-timber`, `-sqldelight`), things that are not
+libraries (`-gradle-plugin`, the BOM), and three worth revisiting — `-compose`, `-tv` and the two
+OpenTelemetry modules.
+
+The file already listed what *is* bound; the point of the addition is that "is X missing on purpose?"
+now has an answer in the repository rather than in someone's memory.
+
+## Tests
+
+**94 package-layout tests pass**, and the on-emulator suite grows from **13 checks to 17**, all
+running against the packed packages.
+
+Until now this suite enabled Trace and stopped there — `EnablesTrace` registered a tracer and never
+started a span — so the tracing path was configured and never driven. That is now closed:
+
+- **A span is started, tagged, errored, logged and finished.** Its ids are asserted by *shape*
+ rather than for non-emptiness: 32 lowercase hex characters and not all zeros for the trace, decimal
+ for the span.
+- **Headers are injected and cross-checked two ways**: the id must equal the `traceparent` W3C value
+ across all 128 bits — derived independently of `GetTraceId`, so a second opinion rather than a
+ restatement — and must end with the low 64 bits the Datadog header carries in decimal. The
+ delegate form is driven too, and must write the same number of headers as the dictionary form.
+- **The single-value attribute overloads** are driven on RUM, feature flags and a logger, with
+ `ToJava` asserted directly.
+- **The session id** is read through `GetCurrentSessionIdAsync` and asserted non-null; a null means
+ the Kotlin callback never fired, which is the failure the `Task` wrapper exists to surface.
+
+The harness gained `async` support to do the last of those.
+
+### The e2e now runs on two bands
+
+It ran against `net10.0-android36.0` only. It is now a matrix over **net8 and net10** — the two
+extremes, matching what the DatadogNet façade does. net8 is the oldest asset set and nothing else
+exercised it; net10's assets come out of the merge step in `BuildNugets.sh` and so are the only ones
+that could be grafted in wrong. net9 sits between them and comes out of the same pack pass as net8.
+
+Standing net8 up found three real problems, none of which anything else would have caught:
+
+- **The SDK band has to be chosen by Android API level, not .NET version.** The .NET 9 band has API
+ 34 *reference* packs but no API 34 *runtime* packs, so a net8 app compiles and then fails to
+ package with `NETSDK1112`. `run-emulator-tests.sh` now maps `net8.0-* → .NET 8`.
+- **`NU1107` on `Xamarin.AndroidX.SavedState`.** Navigation.Common wants `>= 1.4.0` while
+ SavedState.Ktx pins `[1.3.2, 1.3.3)`. The .NET 9 and 10 SDKs resolve the higher version and warn;
+ the .NET 8 SDK refuses. Pinned to 1.4.0 **on the net8 head only** — pinning it everywhere is a
+ downgrade on net10, where Fragment 1.8.9.3 wants `>= 1.5.0.1`, and fails the other way with
+ `NU1605`. Each band gets what its own graph settles on, which is what a consumer on that band
+ would get. **Every net8 consumer of these packages needs this pin**, so it is worth knowing about
+ even if you never run the e2e.
+- **`XA1018`: no `AndroidManifest.xml`.** The .NET 9 and 10 Android SDKs generate one; the .NET 8
+ SDK defaults `$(AndroidManifest)` to the project root and fails because nothing is there.
+
+One honest limitation: **`DatadogNet.SessionReplayCompose.Android` is excluded from the net8 leg**,
+because it genuinely has no net8 asset — its last net8 build pins an AndroidX `SavedState` old
+enough to collide with the rest of the graph. The module check is compiled not to look for the
+Compose class there, so it keeps failing for the right reasons rather than being quietly weakened.
+
+17/17 on both legs.
+
+## Sample
+
+`samples/DatadogNet.Android.Example` was still largely the MAUI project template — a hovercraft, a
+click counter, and two Datadog calls. It is now the same shape as the iOS sample: sections for RUM,
+Trace and Logs, and an on-screen activity log so it is useful without a Datadog account to send to.
+The new **Trace** section traces an outgoing `HttpRequestMessage` end to end and records a failed
+span.
+
+It also had a real bug worth not shipping in a sample: `OnDisappearing` disposed the view scope and
+then immediately started a *new* view, leaving one open for the rest of the session — collecting
+every action and error that followed. Fixed.
+
+## Upgrading from 3.12.1.1
+
+```diff
+-
++
+```
+
+Nothing is removed or renamed, and the native `.aar` files are byte-for-byte the same. All thirteen
+packages move together, as they depend on each other at an exact version.
diff --git a/samples/DatadogNet.Android.Example/Datadog.cs b/samples/DatadogNet.Android.Example/Datadog.cs
index 38121b8..fe08172 100644
--- a/samples/DatadogNet.Android.Example/Datadog.cs
+++ b/samples/DatadogNet.Android.Example/Datadog.cs
@@ -42,6 +42,9 @@ public static class Datadog
public static Logger Logger =>
logger ?? throw new InvalidOperationException("Datadog.Initialize has not been called.");
+ /// Whether real credentials were supplied, so events actually reach Datadog.
+ public static bool IsConfigured => !ClientToken.StartsWith('<');
+
///
/// Initialises the SDK and every feature this sample uses.
///
diff --git a/samples/DatadogNet.Android.Example/MainPage.xaml b/samples/DatadogNet.Android.Example/MainPage.xaml
index 385480d..d1d7a15 100644
--- a/samples/DatadogNet.Android.Example/MainPage.xaml
+++ b/samples/DatadogNet.Android.Example/MainPage.xaml
@@ -1,41 +1,65 @@
-
+
+ x:Class="DatadogNet.Android.Example.MainPage"
+ Title="DatadogNet">
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/DatadogNet.Android.Example/MainPage.xaml.cs b/samples/DatadogNet.Android.Example/MainPage.xaml.cs
index 6a3ef34..4a813a5 100644
--- a/samples/DatadogNet.Android.Example/MainPage.xaml.cs
+++ b/samples/DatadogNet.Android.Example/MainPage.xaml.cs
@@ -1,56 +1,217 @@
-namespace DatadogNet.Android.Example;
+using Com.Datadog.Android.Log;
+using Com.Datadog.Android.Rum;
+// The hand-written convenience layers live beside the generated types they extend, so each
+// namespace has to be in scope: Tracer for BuildSpan(string), Span for the span's own helpers,
+// Propagation for Inject.
+using Com.Datadog.Android.Trace;
+using Com.Datadog.Android.Trace.Api.Propagation;
+using Com.Datadog.Android.Trace.Api.Span;
+using Com.Datadog.Android.Trace.Api.Tracer;
+
+namespace DatadogNet.Android.Example;
+
+///
+/// Each button drives one part of the Datadog API. The echoes what was
+/// reported, so the sample is useful even without a Datadog account to send it to.
+///
public partial class MainPage : ContentPage
{
- int count = 0;
-
- public MainPage()
- {
- InitializeComponent();
- }
-
- private void OnCounterClicked(object? sender, EventArgs e)
- {
- count++;
-
- if (count == 1)
- CounterBtn.Text = $"Clicked {count} time";
- else
- CounterBtn.Text = $"Clicked {count} times";
-
- SemanticScreenReader.Announce(CounterBtn.Text);
-
- // A RUM action, with an attribute worth querying on later.
- Datadog.TrackTap("counter", new Dictionary { ["count"] = count });
- }
-
- private IDisposable? viewScope;
-
- protected override void OnAppearing()
- {
- base.OnAppearing();
-
- // One RUM view per page. MAUI renders every page into a single Activity, so without this
- // the whole app reports as one view.
- viewScope = Datadog.StartView("main", "Main Page");
- }
-
- protected override void OnDisappearing()
- {
- viewScope?.Dispose();
- viewScope = Datadog.StartView("main", "Main Page");
- base.OnDisappearing();
- }
-
- private void OnReportErrorClicked(object? sender, EventArgs e)
- {
- try
- {
- throw new InvalidOperationException("Something the user did went wrong.");
- }
- catch (Exception exception)
- {
- Datadog.TrackError(exception);
- }
- }
+ private const string ViewKey = "main";
+
+ private readonly List activity = [];
+
+ private IDisposable? viewScope;
+ private int count;
+
+ public MainPage()
+ {
+ InitializeComponent();
+
+ StatusLabel.Text = Datadog.IsConfigured
+ ? "Reporting to Datadog."
+ : "No client token set, so events are collected but never delivered. "
+ + "Put your own values in Datadog.cs to send them for real.";
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+
+ // One RUM view per page. MAUI renders every page into a single Activity, so without this
+ // the whole app reports as a single view for its entire lifetime.
+ viewScope = Datadog.StartView(ViewKey, "Main Page");
+ }
+
+ protected override void OnDisappearing()
+ {
+ // Dispose and stop. An earlier version of this sample started a *new* view here, which is
+ // the mistake worth not shipping in a sample: it left a view open for the rest of the
+ // session, collecting every action and error that followed.
+ viewScope?.Dispose();
+ viewScope = null;
+
+ base.OnDisappearing();
+ }
+
+ private void OnCounterClicked(object? sender, EventArgs e)
+ {
+ count++;
+ CounterBtn.Text = count == 1 ? "Recorded 1 action" : $"Recorded {count} actions";
+ SemanticScreenReader.Announce(CounterBtn.Text);
+
+ // Attributes are plain C# values - the convenience overload converts them. The raw binding
+ // takes an IDictionary and needs every value hand-wrapped.
+ Datadog.TrackTap("counter", new Dictionary { ["count"] = count });
+
+ Record($"RUM action recorded (count {count})");
+ }
+
+ private void OnReportErrorClicked(object? sender, EventArgs e)
+ {
+ try
+ {
+ throw new InvalidOperationException("Something the user did went wrong.");
+ }
+ catch (Exception exception)
+ {
+ // Reports the managed type, message and stack, so errors group by where they were
+ // thrown rather than by message text alone.
+ Datadog.TrackError(exception);
+ Record($"RUM error recorded: {exception.Message}");
+ }
+ }
+
+ private async void OnTrackWork(object? sender, EventArgs e)
+ {
+ // A view scope stops the view however the block is left, including on an exception.
+ using (Datadog.StartView("work", "Background Work"))
+ {
+ Record("started tracking a unit of work");
+ await Task.Delay(750);
+ }
+
+ Record("work finished, view stopped");
+ }
+
+ private async void OnShowSessionId(object? sender, EventArgs e)
+ {
+ // Worth attaching to a support ticket: it is what turns "the app was slow" into a session
+ // you can watch. The raw API takes a Kotlin Function1, which C# cannot express as a lambda.
+ var sessionId = await GlobalRumMonitor.Get().GetCurrentSessionIdAsync();
+
+ Record($"session {sessionId ?? "(none - RUM is off, or this session was sampled out)"}");
+ }
+
+ private async void OnTraceRequest(object? sender, EventArgs e)
+ {
+ var tracer = GlobalDatadogTracer.Get()!;
+
+ // The operation name is what APM groups by, so it has to be low cardinality - never a URL
+ // and never anything carrying an id.
+ var span = tracer.BuildSpan("http.request")!.Start()!;
+
+ try
+ {
+ span.SetTag("http.method", "GET");
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api/items");
+
+ // This is the whole point of distributed tracing: the receiving service reads these
+ // headers and continues the same trace, so one flame graph spans both sides.
+ //
+ // The raw setter is a Kotlin (C, String, String) -> Unit, which binds as an interface -
+ // so without this overload every call site needs a Java.Lang.Object subclass, and
+ // getting it wrong sends the request untraced with nothing reported.
+ foreach (var header in tracer.Propagate()!.Inject(span.Context()!))
+ {
+ request.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ Record($"trace {span.GetTraceId()}");
+ Record($"propagating {request.Headers.Count()} headers");
+
+ using var client = new HttpClient();
+ using var response = await client.SendAsync(request);
+
+ span.SetTag("http.status_code", (long)(int)response.StatusCode);
+ Record($"request finished: {(int)response.StatusCode}");
+ }
+ catch (Exception exception)
+ {
+ span.SetError(exception);
+ Record($"request failed: {exception.GetType().Name}");
+ }
+ finally
+ {
+ span.Finish();
+ }
+ }
+
+ private void OnFailedSpan(object? sender, EventArgs e)
+ {
+ var span = GlobalDatadogTracer.Get()!.BuildSpan("checkout.submit")!.Start()!;
+
+ try
+ {
+ throw new InvalidOperationException("The cart expired before checkout completed.");
+ }
+ catch (Exception exception)
+ {
+ // Sets the error flag and message together. io.opentracing.Span had no setError at all
+ // in 2.x, so this was four log fields written by convention and easy to get wrong.
+ span.SetError(exception);
+ Record($"span {span.GetSpanId()} marked as failed");
+ }
+ finally
+ {
+ span.Finish();
+ }
+ }
+
+ private void OnWriteLogs(object? sender, EventArgs e)
+ {
+ Datadog.Logger.Log(DatadogLogLevel.Debug, "a debug message");
+ Datadog.Logger.Log(DatadogLogLevel.Info, "an info message");
+ Datadog.Logger.Log(DatadogLogLevel.Warn, "a warning");
+ Datadog.Logger.Log(DatadogLogLevel.Error, "an error");
+
+ // A logger-wide attribute goes on every subsequent entry from this logger.
+ Datadog.Logger.AddAttribute("screen", "main");
+
+ Record("wrote four log levels and a logger-wide attribute");
+ }
+
+ private void OnLogException(object? sender, EventArgs e)
+ {
+ try
+ {
+ _ = int.Parse("not a number");
+ }
+ catch (Exception exception)
+ {
+ // error.kind, error.message and error.stack are set from the exception, which is what
+ // makes it render as an error in the Logs UI rather than as a plain message.
+ Datadog.Logger.Log(
+ DatadogLogLevel.Error,
+ "parsing failed",
+ exception,
+ new Dictionary { ["input"] = "not a number" });
+
+ Record($"logged exception: {exception.GetType().Name}");
+ }
+ }
+
+ private void Record(string message)
+ {
+ activity.Insert(0, $"{DateTime.Now:HH:mm:ss} {message}");
+
+ // Keep the list short enough to stay on screen.
+ if (activity.Count > 12)
+ {
+ activity.RemoveAt(activity.Count - 1);
+ }
+
+ ActivityLabel.Text = string.Join(Environment.NewLine, activity);
+ }
}
diff --git a/src/DatadogNet.Core.Android/Additions/DatadogAttributes.cs b/src/DatadogNet.Core.Android/Additions/DatadogAttributes.cs
index 2829db0..9b91e7b 100644
--- a/src/DatadogNet.Core.Android/Additions/DatadogAttributes.cs
+++ b/src/DatadogNet.Core.Android/Additions/DatadogAttributes.cs
@@ -47,8 +47,23 @@ public static class DatadogAttributes
return converted;
}
- /// Converts one value, naming the attribute in any error so it can be found.
- private static Java.Lang.Object ToJava(object? value, string key) => value switch
+ ///
+ /// Converts a single value into the the Datadog SDK expects.
+ ///
+ /// The value.
+ ///
+ /// The attribute name. Used only to name the value in any error, so that a rejected attribute
+ /// can be found without guessing which one it was.
+ ///
+ /// The value has no Java representation.
+ ///
+ /// Several members take a bare value rather than a map - RumMonitor.addAttribute,
+ /// addFeatureFlagEvaluation, Logs.addAttribute, Logger.addAttribute - and
+ /// they all need the same conversion applies per entry. Without this a
+ /// caller either hand-wraps the value, which is what exists to avoid, or
+ /// round-trips a one-element dictionary through to get at the result.
+ ///
+ public static Java.Lang.Object ToJava(object? value, string key) => value switch
{
null => null!,
diff --git a/src/DatadogNet.Logs.Android/Additions/LoggerExtensions.cs b/src/DatadogNet.Logs.Android/Additions/LoggerExtensions.cs
index 1fe3d53..abe33e7 100644
--- a/src/DatadogNet.Logs.Android/Additions/LoggerExtensions.cs
+++ b/src/DatadogNet.Logs.Android/Additions/LoggerExtensions.cs
@@ -66,4 +66,18 @@ public static void Log(
exception?.ToString()!,
DatadogAttributes.From(attributes));
}
+
+ /// Adds an attribute to every subsequent entry from this logger.
+ ///
+ /// The generated overload takes a Java.Lang.Object, so a logger-wide attribute has to be
+ /// hand-wrapped - which is what exists to avoid for the
+ /// map-taking members.
+ ///
+ public static void AddAttribute(this Logger logger, string key, object? value)
+ {
+ ArgumentNullException.ThrowIfNull(logger);
+ ArgumentNullException.ThrowIfNull(key);
+
+ logger.AddAttribute(key, DatadogAttributes.ToJava(value, key));
+ }
}
diff --git a/src/DatadogNet.RUM.Android/Additions/RumMonitorExtensions.cs b/src/DatadogNet.RUM.Android/Additions/RumMonitorExtensions.cs
index 85e58dc..7caf2ac 100644
--- a/src/DatadogNet.RUM.Android/Additions/RumMonitorExtensions.cs
+++ b/src/DatadogNet.RUM.Android/Additions/RumMonitorExtensions.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
using Com.Datadog.Android;
namespace Com.Datadog.Android.Rum;
@@ -108,6 +109,167 @@ public static void AddError(
stacktrace: null!,
DatadogAttributes.From(attributes));
}
+
+ /// Adds an attribute to every subsequent RUM event.
+ ///
+ /// The generated overload takes a Java.Lang.Object, so setting a global attribute means
+ /// hand-wrapping the value - which is exactly what exists to
+ /// avoid for the map-taking members.
+ ///
+ public static void AddAttribute(this IRumMonitor monitor, string key, object? value)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+ ArgumentNullException.ThrowIfNull(key);
+
+ monitor.AddAttribute(key, DatadogAttributes.ToJava(value, key));
+ }
+
+ /// Records that a feature flag was evaluated, so RUM events can be split by variant.
+ public static void AddFeatureFlagEvaluation(this IRumMonitor monitor, string name, object? value)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+ ArgumentNullException.ThrowIfNull(name);
+
+ monitor.AddFeatureFlagEvaluation(name, DatadogAttributes.ToJava(value, name));
+ }
+
+ /// Begins tracking a network request as a RUM resource.
+ public static void StartResource(
+ this IRumMonitor monitor,
+ string key,
+ RumResourceMethod method,
+ string url,
+ IReadOnlyDictionary? attributes = null)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+
+ monitor.StartResource(key, method, url, DatadogAttributes.From(attributes));
+ }
+
+ /// Completes a resource begun by .
+ ///
+ /// The generated overload takes java.lang.Integer and java.lang.Long, because both
+ /// are nullable in Kotlin - so a C# caller has to box through Java.Lang.Integer.ValueOf
+ /// and remember that a null means "not known" rather than zero. Nullable value types say the
+ /// same thing in the language the caller is already writing.
+ ///
+ public static void StopResource(
+ this IRumMonitor monitor,
+ string key,
+ int? statusCode,
+ long? size,
+ RumResourceKind kind,
+ IReadOnlyDictionary? attributes = null)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+
+ monitor.StopResource(
+ key,
+ statusCode is { } code ? Java.Lang.Integer.ValueOf(code) : null,
+ size is { } bytes ? Java.Lang.Long.ValueOf(bytes) : null,
+ kind,
+ DatadogAttributes.From(attributes));
+ }
+
+ /// Completes a resource that failed, from a message.
+ ///
+ /// Guards a trap the C# signature does not show: stackTrace is String in Kotlin,
+ /// not String?, so passing null reaches Java's own null check and throws
+ ///
+ /// NullPointerException: Parameter specified as non-null is null: method
+ /// DatadogRumMonitor.stopResourceWithError, parameter stackTrace
+ ///
+ /// - unlike errorType beside it, and unlike addErrorWithStacktrace, both of which
+ /// are genuinely nullable. Nothing in the generated binding distinguishes the three.
+ ///
+ public static void StopResourceWithError(
+ this IRumMonitor monitor,
+ string key,
+ string message,
+ int? statusCode = null,
+ string? stackTrace = null,
+ string? errorType = null,
+ RumErrorSource? source = null,
+ IReadOnlyDictionary? attributes = null)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+ ArgumentNullException.ThrowIfNull(message);
+
+ monitor.StopResourceWithError(
+ key,
+ statusCode is { } code ? Java.Lang.Integer.ValueOf(code) : null,
+ message,
+ source ?? RumErrorSource.Network,
+ stackTrace: stackTrace ?? string.Empty,
+ errorType: errorType!,
+ DatadogAttributes.From(attributes));
+ }
+
+ /// Completes a resource that failed, from a .NET exception.
+ ///
+ /// The generated overloads take either a Java.Lang.Throwable, which a C# app does not
+ /// have, or six positional arguments including two nullable strings. This uses the string form
+ /// so the managed type, message and stack all reach Datadog.
+ ///
+ public static void StopResourceWithError(
+ this IRumMonitor monitor,
+ string key,
+ Exception exception,
+ int? statusCode = null,
+ RumErrorSource? source = null,
+ IReadOnlyDictionary? attributes = null)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+ ArgumentNullException.ThrowIfNull(exception);
+
+ monitor.StopResourceWithError(
+ key,
+ statusCode is { } code ? Java.Lang.Integer.ValueOf(code) : null,
+ exception.Message,
+ source ?? RumErrorSource.Network,
+ stackTrace: exception.ToString(),
+ errorType: exception.GetType().FullName!,
+ DatadogAttributes.From(attributes));
+ }
+
+ ///
+ /// The id of the current RUM session, or if there is none.
+ ///
+ ///
+ /// The generated GetCurrentSessionId takes a
+ /// kotlin.jvm.functions.Function1, which C# cannot express as a lambda at all: it binds
+ /// as an interface, so calling it requires declaring a subclass
+ /// implementing IFunction1 and returning null for Kotlin's Unit. That is a lot of
+ /// ceremony for a value most apps want in order to put it on a support ticket.
+ ///
+ /// dd-sdk-ios takes an ordinary block for the same call and needs none of this.
+ ///
+ ///
+ public static Task GetCurrentSessionIdAsync(this IRumMonitor monitor)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+
+ // RunContinuationsAsynchronously: the callback arrives on whichever thread the SDK answers
+ // on, and a synchronous continuation would run the caller's await-resumption there too.
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ monitor.GetCurrentSessionId(new SessionIdCallback(completion));
+
+ return completion.Task;
+ }
+
+ /// Adapts getCurrentSessionId's Kotlin lambda to a completion source.
+ private sealed class SessionIdCallback(TaskCompletionSource completion)
+ : Java.Lang.Object, Kotlin.Jvm.Functions.IFunction1
+ {
+ public Java.Lang.Object? Invoke(Java.Lang.Object? sessionId)
+ {
+ completion.TrySetResult(sessionId?.ToString());
+
+ // Kotlin's Unit, which the binding maps to null for a Unit-returning lambda.
+ return null;
+ }
+ }
}
///
diff --git a/src/DatadogNet.TraceApi.Android/Additions/DatadogPropagationExtensions.cs b/src/DatadogNet.TraceApi.Android/Additions/DatadogPropagationExtensions.cs
new file mode 100644
index 0000000..d10be75
--- /dev/null
+++ b/src/DatadogNet.TraceApi.Android/Additions/DatadogPropagationExtensions.cs
@@ -0,0 +1,123 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using Com.Datadog.Android.Trace.Api.Span;
+
+namespace Com.Datadog.Android.Trace.Api.Propagation;
+
+///
+/// Distributed tracing propagation, in a form C# can call.
+///
+///
+/// DatadogPropagation.inject is declared in Kotlin as
+///
+/// fun <C> inject(context: DatadogSpanContext, carrier: C, setter: (C, String, String) -> Unit)
+///
+/// and that trailing lambda is the problem. Kotlin function types bind as
+/// Kotlin.Jvm.Functions.IFunction3 — an interface, not a delegate — so C# cannot pass a
+/// lambda, a method group, or an . Calling it means declaring a
+/// subclass that implements IFunction3, marshalling three
+/// parameters back to strings, and returning
+/// for Kotlin's Unit.
+///
+/// Every consumer who wants distributed tracing has to write that, and getting it wrong does not
+/// fail loudly: the request simply goes out with no trace headers, and the trace stops at the app.
+/// dd-sdk-ios needs none of it — OTTracer.inject takes an ordinary writer object.
+///
+///
+/// Not wrapped:extract. Its signature nests a second Kotlin lambda — the SDK hands
+/// you a visitor you call per header, whose Boolean return governs whether iteration
+/// continues — and that contract is not documented anywhere the binding can see. Guessing it would
+/// produce something that silently reads one header and stops. Extraction is also the rare
+/// direction for a mobile app, which receives responses rather than serving requests.
+///
+///
+public static class DatadogPropagationExtensions
+{
+ ///
+ /// Writes the trace headers for into a new dictionary.
+ ///
+ /// The tracer's propagation, from DatadogTracer.Propagate().
+ /// The span context to propagate, from DatadogSpan.Context().
+ ///
+ /// The headers to add to the outgoing request. Which headers appear depends on the tracing
+ /// header types the tracer was built with; a single call writes all of them.
+ ///
+ ///
+ ///
+ /// var span = GlobalDatadogTracer.Get ().BuildSpan (new Java.Lang.String ("http.request")).Start ();
+ ///
+ /// foreach (var header in GlobalDatadogTracer.Get ().Propagate ().Inject (span.Context ()))
+ /// request.Headers.TryAddWithoutValidation (header.Key, header.Value);
+ ///
+ ///
+ public static IDictionary Inject(
+ this IDatadogPropagation propagation,
+ IDatadogSpanContext context)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ propagation.Inject(context, headers);
+
+ return headers;
+ }
+
+ /// Writes the trace headers for into an existing dictionary.
+ /// The tracer's propagation.
+ /// The span context to propagate.
+ /// The dictionary to write into. Existing keys are replaced.
+ public static void Inject(
+ this IDatadogPropagation propagation,
+ IDatadogSpanContext context,
+ IDictionary headers)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+
+ propagation.Inject(context, (name, value) => headers[name] = value);
+ }
+
+ /// Writes the trace headers for through a callback.
+ /// The tracer's propagation.
+ /// The span context to propagate.
+ /// Called once per header, with its name and value.
+ ///
+ /// The form to use when the destination is not a dictionary — an HttpRequestMessage, a
+ /// gRPC metadata collection, a message envelope.
+ ///
+ public static void Inject(
+ this IDatadogPropagation propagation,
+ IDatadogSpanContext context,
+ Action setter)
+ {
+ ArgumentNullException.ThrowIfNull(propagation);
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(setter);
+
+ // The carrier is the value the SDK hands back to the setter untouched, and this closes over
+ // the real destination instead - so it only has to be a non-null Java object. It cannot be
+ // `new Java.Lang.Object()`, whose constructor is protected.
+ propagation.Inject(context, new Java.Lang.String(string.Empty), new HeaderSetter(setter));
+ }
+
+ /// Adapts inject's Kotlin lambda to a C# delegate.
+ private sealed class HeaderSetter(Action setter)
+ : Java.Lang.Object, Kotlin.Jvm.Functions.IFunction3
+ {
+ public Java.Lang.Object? Invoke(
+ Java.Lang.Object? carrier,
+ Java.Lang.Object? name,
+ Java.Lang.Object? value)
+ {
+ // The SDK never passes a null name, but a null value is cheaper to tolerate than to
+ // crash an app's request pipeline over.
+ if (name is not null)
+ {
+ setter(name.ToString()!, value?.ToString() ?? string.Empty);
+ }
+
+ // Kotlin's Unit, which the binding maps to null for a Unit-returning lambda.
+ return null;
+ }
+ }
+}
diff --git a/src/DatadogNet.TraceApi.Android/Additions/DatadogSpanExtensions.cs b/src/DatadogNet.TraceApi.Android/Additions/DatadogSpanExtensions.cs
new file mode 100644
index 0000000..c7d3207
--- /dev/null
+++ b/src/DatadogNet.TraceApi.Android/Additions/DatadogSpanExtensions.cs
@@ -0,0 +1,95 @@
+#nullable enable
+
+using System;
+
+namespace Com.Datadog.Android.Trace.Api.Span;
+
+/// Describing a span's outcome, in a form C# can call directly.
+public static class DatadogSpanExtensions
+{
+ /// Marks the span as failed, from a .NET exception.
+ /// The span.
+ /// The exception that failed it.
+ ///
+ /// addThrowable takes a java.lang.Throwable, which a .NET exception is not — so
+ /// the managed type, message and stack have to be set as separate fields for any of them to
+ /// reach Datadog. Getting that split wrong is quiet: the span is marked as an error, and the
+ /// APM error panel shows nothing to act on.
+ ///
+ /// setError takes a java.lang.Boolean rather than a bool, so even the flag
+ /// needs boxing.
+ ///
+ ///
+ public static void SetError(this IDatadogSpan span, Exception exception)
+ {
+ ArgumentNullException.ThrowIfNull(span);
+ ArgumentNullException.ThrowIfNull(exception);
+
+ span.SetError(
+ exception.GetType().FullName ?? exception.GetType().Name,
+ exception.Message,
+ exception.ToString());
+ }
+
+ /// Marks the span as failed, from an error kind, message and stack.
+ /// The span.
+ /// The error type.
+ /// The error message.
+ /// The stack, attached as an error log on the span.
+ public static void SetError(
+ this IDatadogSpan span,
+ string kind,
+ string message,
+ string? stack = null)
+ {
+ ArgumentNullException.ThrowIfNull(span);
+ ArgumentNullException.ThrowIfNull(kind);
+ ArgumentNullException.ThrowIfNull(message);
+
+ // These are real members in 3.x. In 2.x io.opentracing.Span had no setError at all, and a
+ // caller had to set an "error" tag plus four log fields by convention - where getting one
+ // field name wrong produced a span that looked fine and was never counted as an error.
+ span.SetError(Java.Lang.Boolean.True!);
+ span.SetErrorMessage($"{kind}: {message}");
+
+ if (stack is not null)
+ span.LogErrorMessage(stack);
+ }
+
+ ///
+ /// The trace id, rendered the way Datadog's own instrumentation renders it.
+ ///
+ /// The span.
+ /// 32 lowercase hexadecimal characters, or an empty string if there is no context.
+ ///
+ /// DatadogTraceId.ToHexString(), which is what DatadogInterceptor writes as
+ /// _dd.trace_id when it links a RUM resource to its APM trace. Anything else — including
+ /// ToLong(), which silently returns the low half of a 128-bit id — produces a string the
+ /// backend will not correlate on.
+ ///
+ public static string GetTraceId(this IDatadogSpan span)
+ {
+ ArgumentNullException.ThrowIfNull(span);
+
+ return span.Context()?.TraceId?.ToHexString() ?? string.Empty;
+ }
+
+ ///
+ /// The span id, rendered the way Datadog's own instrumentation renders it.
+ ///
+ /// The span.
+ /// The decimal form, or an empty string if there is no context.
+ ///
+ /// Decimal, and deliberately not hexadecimal like . The asymmetry is
+ /// Datadog's wire format: DatadogInterceptor writes _dd.span_id as
+ /// String.valueOf(long).
+ ///
+ public static string GetSpanId(this IDatadogSpan span)
+ {
+ ArgumentNullException.ThrowIfNull(span);
+
+ return span.Context() is { } context
+ ? context.SpanId.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ : string.Empty;
+ }
+}
diff --git a/tests/DatadogNet.Android.DeviceTests/AndroidManifest.xml b/tests/DatadogNet.Android.DeviceTests/AndroidManifest.xml
new file mode 100644
index 0000000..b49ba5d
--- /dev/null
+++ b/tests/DatadogNet.Android.DeviceTests/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/tests/DatadogNet.Android.DeviceTests/DatadogNet.Android.DeviceTests.csproj b/tests/DatadogNet.Android.DeviceTests/DatadogNet.Android.DeviceTests.csproj
index eae6dc8..f1311ea 100644
--- a/tests/DatadogNet.Android.DeviceTests/DatadogNet.Android.DeviceTests.csproj
+++ b/tests/DatadogNet.Android.DeviceTests/DatadogNet.Android.DeviceTests.csproj
@@ -18,6 +18,19 @@
11.023
+
+
+ AndroidManifest.xmlapk
+
+ $(DefineConstants);DATADOG_HAS_COMPOSE
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/tests/DatadogNet.Android.DeviceTests/MainActivity.cs b/tests/DatadogNet.Android.DeviceTests/MainActivity.cs
index 903634c..124d580 100644
--- a/tests/DatadogNet.Android.DeviceTests/MainActivity.cs
+++ b/tests/DatadogNet.Android.DeviceTests/MainActivity.cs
@@ -35,10 +35,10 @@ protected override void OnCreate(Bundle? savedInstanceState)
// Off the UI thread: the SDK does file and network work during initialization, and a
// StrictMode violation on the main thread would be reported as a failure of whichever
// check happened to be running.
- Task.Run(RunSmokeTests);
+ Task.Run(RunSmokeTestsAsync);
}
- private static void RunSmokeTests()
+ private static async Task RunSmokeTestsAsync()
{
var passed = 0;
var failed = 0;
@@ -49,7 +49,7 @@ private static void RunSmokeTests()
{
try
{
- test.Execute();
+ await test.Execute();
passed++;
Log.Info(Tag, $"PASS {test.Name}");
}
diff --git a/tests/DatadogNet.Android.DeviceTests/SmokeTests.cs b/tests/DatadogNet.Android.DeviceTests/SmokeTests.cs
index 7c15db6..3f0e131 100644
--- a/tests/DatadogNet.Android.DeviceTests/SmokeTests.cs
+++ b/tests/DatadogNet.Android.DeviceTests/SmokeTests.cs
@@ -14,12 +14,29 @@
// fully qualifying every call.
using DdTrace = Com.Datadog.Android.Trace.Trace;
using Com.Datadog.Android.Trace;
+// The hand-written convenience layers in DatadogNet.TraceApi.Android live beside the generated
+// types they extend, so each namespace has to be in scope: Tracer for the generator's own
+// BuildSpan(string), Span for GetTraceId/GetSpanId/SetError, Propagation for Inject.
+using Com.Datadog.Android.Trace.Api.Propagation;
+using Com.Datadog.Android.Trace.Api.Span;
+using Com.Datadog.Android.Trace.Api.Tracer;
using Com.Datadog.Android.Webview;
namespace DatadogNet.Android.DeviceTests;
/// A single on-device check. Throws to fail.
-public sealed record SmokeTest(string Name, Action Execute);
+public sealed record SmokeTest(string Name, Func Execute)
+{
+ /// A synchronous check, which most of them are.
+ public SmokeTest(string name, Action execute)
+ : this(name, () =>
+ {
+ execute();
+ return Task.CompletedTask;
+ })
+ {
+ }
+}
///
/// End-to-end checks that only mean anything on a real device or emulator: they load the native
@@ -61,11 +78,15 @@ public static class SmokeTests
new("drives a RUM view, action, resource and error", DrivesRum),
new("enables Logs and writes every level", EnablesLogsAndWritesEveryLevel),
new("enables Trace", EnablesTrace),
+ new("drives a span and reads its ids", DrivesASpanAndReadsItsIds),
+ new("injects trace headers into a carrier", InjectsTraceHeaders),
new("enables Session Replay with the Material extension", EnablesSessionReplay),
new("enables NDK crash reporting", EnablesNdkCrashReporting),
new("constructs the OkHttp interceptors", ConstructsOkHttpInterceptors),
new("exposes WebView tracking", ExposesWebViewTracking),
new("drives RUM and Logs through the ergonomic overloads", ErgonomicOverloadsWork),
+ new("sets single attributes without hand-wrapping", SingleValueAttributesWork),
+ new("reads the current RUM session id", ReadsCurrentSessionId),
new("stops the RUM session and the SDK instance", StopsCleanly),
];
@@ -98,7 +119,11 @@ private static void EveryModuleIsLoadable()
"com.datadog.android.rum.Rum",
"com.datadog.android.sessionreplay.SessionReplay",
"com.datadog.android.sessionreplay.material.MaterialExtensionSupport",
+#if DATADOG_HAS_COMPOSE
+ // Absent on net8: the Compose extension ships net9/net10 only. See the device test
+ // project for why, and note that it is excluded there rather than here silently.
"com.datadog.android.sessionreplay.compose.ComposeExtensionSupport",
+#endif
"com.datadog.android.ndk.NdkCrashReports",
"com.datadog.android.webview.WebViewTracking",
"com.datadog.android.okhttp.DatadogInterceptor",
@@ -248,6 +273,154 @@ private static void EnablesTrace()
Report("Trace enabled and a tracer registered globally");
}
+ /// Starts a real span and reads back what the SDK made of it.
+ ///
+ /// Until 3.12.1.2 nothing in this suite started a span — registered a
+ /// tracer and stopped there — so the tracing path was enabled and never driven.
+ ///
+ private static void DrivesASpanAndReadsItsIds()
+ {
+ // BuildSpan(string) comes from the generator's own IDatadogTracerExtensions; the interface
+ // declares CharSequence, so calling it directly would need a Java.Lang.String.
+ var span = GlobalDatadogTracer.Get()!.BuildSpan("device-test-span")!.Start()!;
+
+ span.SetTag("kind", "smoke");
+ span.SetTag("count", 1L);
+ span.SetTag("enabled", true);
+
+ var traceId = span.GetTraceId();
+ var spanId = span.GetSpanId();
+
+ Report($"trace {traceId} span {spanId}");
+
+ // The shape is the assertion. These ids are what Datadog correlates a RUM resource to an
+ // APM trace on, and they must match what DatadogInterceptor writes: 32 lowercase hex for
+ // the trace, decimal for the span.
+ Assert(
+ traceId.Length == 32 && traceId.All(IsLowerHex),
+ $"The trace id '{traceId}' is not 32 lowercase hex characters.");
+
+ Assert(traceId.Any(c => c != '0'), "The trace id is all zeros, so no trace was started.");
+
+ Assert(
+ spanId.Length > 0 && spanId.All(char.IsAsciiDigit),
+ $"The span id '{spanId}' is not decimal.");
+
+ span.SetError(new InvalidOperationException("span failure"));
+ span.LogAttributes(DatadogAttributes.From(new Dictionary
+ {
+ ["event"] = "retry",
+ ["attempt"] = 2,
+ }));
+
+ span.Finish();
+
+ Report("span tagged, errored, logged and finished");
+ }
+
+ /// Injects trace headers, in both the dictionary and the delegate form.
+ ///
+ /// The native setter is a Kotlin (C, String, String) -> Unit, which binds as
+ /// IFunction3 and cannot be a C# lambda — so before Inject existed here, every
+ /// consumer wrote a Java.Lang.Object subclass, and getting it wrong produced a request with no
+ /// trace headers and nothing reported.
+ ///
+ private static void InjectsTraceHeaders()
+ {
+ var tracer = GlobalDatadogTracer.Get()!;
+ var span = tracer.BuildSpan("injected-span")!.Start()!;
+ var context = span.Context()!;
+
+ var headers = tracer.Propagate()!.Inject(context);
+
+ Report($"headers: {string.Join(", ", headers.Keys)}");
+
+ Assert(
+ headers.ContainsKey("x-datadog-trace-id"),
+ "Injection produced no x-datadog-trace-id, so a trace would not continue into a backend.");
+
+ var traceId = span.GetTraceId();
+
+ // One call writes every configured format, so traceparent is here too - and it carries the
+ // full 128 bits as hex, derived independently of GetTraceId. A second opinion, not a
+ // restatement.
+ if (headers.TryGetValue("traceparent", out var traceparent))
+ {
+ var parts = traceparent.Split('-');
+
+ Assert(
+ parts.Length >= 2 && parts[1] == traceId,
+ $"The trace id '{traceId}' disagrees with traceparent '{traceparent}'.");
+ }
+
+ // The Datadog header carries the low 64 bits in decimal; they must be the tail of the id.
+ if (ulong.TryParse(headers["x-datadog-trace-id"], out var low))
+ {
+ var expected = low.ToString("x16", System.Globalization.CultureInfo.InvariantCulture);
+
+ Assert(
+ traceId.EndsWith(expected, StringComparison.Ordinal),
+ $"The trace id '{traceId}' does not end with '{expected}', the low 64 bits the " +
+ $"x-datadog-trace-id header carries as '{headers["x-datadog-trace-id"]}'.");
+ }
+
+ // The delegate form, for carriers that are not dictionaries.
+ var collected = new List();
+ tracer.Propagate()!.Inject(context, (name, _) => collected.Add(name));
+
+ Assert(
+ collected.Count == headers.Count,
+ $"The delegate form wrote {collected.Count} headers where the dictionary form wrote " +
+ $"{headers.Count}.");
+
+ span.Finish();
+ }
+
+ /// The single-value attribute overloads, which need no hand-wrapped Java object.
+ private static void SingleValueAttributesWork()
+ {
+ var monitor = GlobalRumMonitor.Get();
+
+ using (monitor.StartView("single-value-view"))
+ {
+ monitor.AddAttribute("global.string", "text");
+ monitor.AddAttribute("global.int", 42);
+ monitor.AddAttribute("global.null", null);
+
+ monitor.AddFeatureFlagEvaluation("new-checkout", true);
+ monitor.AddFeatureFlagEvaluation("checkout-variant", "b");
+
+ monitor.RemoveAttribute("global.int");
+ }
+
+ var logger = new Logger.Builder().SetName("single-value").Build();
+ logger.AddAttribute("tenant", "acme");
+ logger.AddAttribute("retries", 3);
+ logger.Log(DatadogLogLevel.Info, "with logger-wide attributes");
+
+ // The converter itself, now public - the reason none of the above needs a Java.Lang.Object.
+ Assert(
+ DatadogAttributes.ToJava("text", "k") is Java.Lang.String,
+ "ToJava did not convert a string to a Java.Lang.String.");
+
+ Report("single-value attributes accepted on RUM, feature flags and a logger");
+ }
+
+ /// The session id, which the SDK answers through a Kotlin lambda.
+ private static async Task ReadsCurrentSessionId()
+ {
+ var sessionId = await GlobalRumMonitor.Get().GetCurrentSessionIdAsync();
+
+ Report($"session {sessionId ?? "(none)"}");
+
+ // Non-null because RUM is enabled and sampled at 100 above. A null means the callback never
+ // fired, which is the failure mode the Task wrapper exists to make visible.
+ Assert(sessionId is not null, "The SDK reported no RUM session id.");
+ }
+
+ /// Whether a character is a lowercase hex digit.
+ private static bool IsLowerHex(char c) => char.IsAsciiDigit(c) || c is >= 'a' and <= 'f';
+
private static void EnablesSessionReplay()
{
var configuration = new SessionReplayConfiguration.Builder(100f)