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
21 changes: 18 additions & 3 deletions .github/scripts/run-emulator-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 33 additions & 16 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
not bound here at all.
-->
<DatadogNativeVersion>3.12.1</DatadogNativeVersion>
<DatadogBindingRevision>1</DatadogBindingRevision>
<DatadogBindingRevision>2</DatadogBindingRevision>
<VersionPrefix>$(DatadogNativeVersion).$(DatadogBindingRevision)</VersionPrefix>

</PropertyGroup>
Expand Down
33 changes: 33 additions & 0 deletions build/packages.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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.
197 changes: 197 additions & 0 deletions docs/release-notes/3.12.1.2.md
Original file line number Diff line number Diff line change
@@ -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 `<dd-sdk-android version>.<binding revision>`.** `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 <C> 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<string, string>` 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
-<PackageReference Include="DatadogNet.RUM.Android" Version="3.12.1.1" />
+<PackageReference Include="DatadogNet.RUM.Android" Version="3.12.1.2" />
```

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.
3 changes: 3 additions & 0 deletions samples/DatadogNet.Android.Example/Datadog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public static class Datadog
public static Logger Logger =>
logger ?? throw new InvalidOperationException("Datadog.Initialize has not been called.");

/// <summary>Whether real credentials were supplied, so events actually reach Datadog.</summary>
public static bool IsConfigured => !ClientToken.StartsWith('<');

/// <summary>
/// Initialises the SDK and every feature this sample uses.
/// </summary>
Expand Down
Loading
Loading