diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fc2b682..a990743 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -19,13 +19,15 @@ on:
required: false
default: ''
type: string
- e2e-target-framework:
+ e2e-target-frameworks:
description: >
- Which of the packages' target frameworks the simulator 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 simulator 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-ios26.0
+ default: '["net8.0-ios18.0", "net10.0-ios26.0"]'
type: string
env:
@@ -162,10 +164,16 @@ jobs:
-p:DatadogPackageVersion="${{ inputs.version }}"
e2e:
- name: simulator smoke test
+ name: simulator smoke test (${{ matrix.target-framework }})
timeout-minutes: 45
needs: pack
runs-on: macos-15
+ 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
@@ -196,13 +204,13 @@ jobs:
path: artifacts
- name: Run smoke tests on simulator
- run: ./.github/scripts/run-simulator-tests.sh "${{ inputs.version }}" "${{ inputs.e2e-target-framework }}"
+ run: ./.github/scripts/run-simulator-tests.sh "${{ inputs.version }}" "${{ matrix.target-framework }}"
- name: Upload simulator logs
if: always()
uses: actions/upload-artifact@v4
with:
- name: simulator-test-logs
+ name: simulator-test-logs-${{ matrix.target-framework }}
path: simulator-tests.log
if-no-files-found: ignore
retention-days: 7
diff --git a/Directory.Build.props b/Directory.Build.props
index 4b55054..7b1072c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -16,7 +16,7 @@
impossible to tell which Datadog release a given CrashReporter package belonged to.
-->
3.14.0
- 1
+ 2$(DatadogNativeVersion).$(DatadogBindingRevision)s.bokatuk
diff --git a/README.md b/README.md
index 59f51ad..d6448ce 100644
--- a/README.md
+++ b/README.md
@@ -305,11 +305,69 @@ there; nothing is hidden or renamed.
| `DDDatadog.SetUserInfoWithUserId(id, null, null, empty)` | `DDDatadog.SetUserInfo(id)` |
| `DDTrackingConsent.Granted()` (a factory, not an enum) | `DDDatadog.SetTrackingConsent(TrackingConsent.Granted)` |
| `DDURLSessionInstrumentation.EnableWithConfiguration(...)` with a raw `Class` handle | `DDURLSessionInstrumentation.Enable()` |
+| a one-element dictionary round-trip to convert one value | `DatadogAttributes.ToNSObject(value, key)` |
+| `monitor.AddAttributeForKey(k, nsObject)` | `monitor.AddAttribute(k, value)` / `AddViewAttribute` / `AddFeatureFlagEvaluation` |
+| `monitor.CurrentSessionIDWithCompletion(block)` | `await monitor.GetCurrentSessionIdAsync()` |
+| construct a writer, pass it as the carrier, read headers back off it — once per format | `span.InjectHeaders(tracer)` |
+| no way at all to read a span's ids | `span.GetTraceId(tracer)` / `span.GetSpanId(tracer)` |
+| `span.SetErrorWithKind(kind, message, stack)` from an exception you must decompose | `span.SetError(exception)` |
Attribute values may be strings, any numeric type, `bool`, `DateTime`, `DateTimeOffset`, `Guid`,
enums, `NSObject`s, arrays, and nested dictionaries. Anything else throws `ArgumentException` rather
than being silently dropped. `DatadogAttributes` lives in `DatadogCore`.
+### Trace ids
+
+`GetTraceId` returns **32 lowercase hexadecimal characters** and `GetSpanId` returns **decimal**.
+The asymmetry is not a choice — it is Datadog's wire format, and matching it is what makes a RUM
+resource correlate with its APM trace. dd-sdk-android's own `DatadogInterceptor` writes
+`_dd.trace_id` as `DatadogTraceId.toHexString()` and `_dd.span_id` as `String.valueOf(long)`.
+
+Reading the ids takes work because `OTSpanContext` exposes none: they are recovered by injecting
+into a Datadog-format writer, and the trace id arrives in two pieces — the decimal low 64 bits in
+`x-datadog-trace-id`, and the high 64 as `_dd.p.tid` inside `x-datadog-tags`. Using only the former
+yields a decimal string naming half of a different-looking id, which is a mistake that reached a
+release of a consumer of this package before it was caught.
+
+---
+
+## API coverage
+
+Measured by diffing each framework's generated `-Swift.h` against `ApiDefinitions.cs`, not estimated.
+
+| Framework | Objective-C types | Bound |
+| --- | ---: | ---: |
+| `DatadogRUM` | 377 | 377 |
+| `DatadogLogs` | 16 | 16 |
+| `DatadogCore` | 12 | 12 |
+| `DatadogTrace` | 12 | 12 |
+| `DatadogSessionReplay` | 4 | 4 |
+| `DatadogInternal` | 2 | 2 |
+| `DatadogCrashReporting` | 1 | 1 |
+| `DatadogWebViewTracking` | 1 | 1 |
+
+Member coverage is the same story: of 61 selectors and properties on `DatadogCore`, 59 are exported,
+and the two that are not are `init` and `new`, which `[DisableDefaultCtor]` removes on purpose.
+
+**What is missing is missing upstream.** Three parts of dd-sdk-ios have no Objective-C projection at
+all, so there is nothing for a binding to bind:
+
+| | Swift types | ObjC types | Consequence |
+| --- | ---: | ---: | --- |
+| `DatadogFlags` | 15 | **0** | Feature Flags are unreachable from C#. The API leans on generics (`FlagDetails`) and enums with associated values (`AnyValue`), neither of which Swift projects into Objective-C. |
+| `DatadogProfiling` | 2 | **0** | Profiling is unreachable. |
+| `OpenTelemetryApi` | — | no `-Swift.h` at all | A pure-Swift module. `DatadogNet.OpenTelemetryApi.iOS` can only ever be a link-time dependency of `DatadogNet.Trace.iOS`. |
+
+`DatadogTrace` is additionally 24 public Swift types projected down to 12, and the casualty is
+`OTelTracerProvider` — so OpenTelemetry tracing is unreachable even though OpenTracing is not.
+
+`DatadogNet.Flags.iOS` and `DatadogNet.Profiling.iOS` therefore ship the frameworks and expose no
+callable API. They exist so the SDK is mirrored and so a future projection needs no new package.
+
+There is a way around this — a hand-written Swift `@objc` wrapper, which we can compile ourselves —
+and a working prototype for Flags lives in [`shims/DatadogFlagsObjc/`](shims/DatadogFlagsObjc/).
+Nothing ships yet.
+
---
## Migrating from 2.x
diff --git a/docs/release-notes/3.14.0.2.md b/docs/release-notes/3.14.0.2.md
new file mode 100644
index 0000000..e757964
--- /dev/null
+++ b/docs/release-notes/3.14.0.2.md
@@ -0,0 +1,209 @@
+## What's changed
+
+Binding-only release. The native SDK is unchanged — still
+[dd-sdk-ios 3.14.0](https://github.com/DataDog/dd-sdk-ios/releases/tag/3.14.0) — and so are the
+package IDs, namespaces and every existing signature. Upgrading is a version bump.
+
+The headline is **`OTSpan.GetTraceId`**, and it is worth stating why a getter warrants one: reading a
+span's trace id from C# was not merely inconvenient, it was a trap that a consumer of these packages
+fell into and shipped. Details below.
+
+> **Package versions are `.`.** `3.14.0.2` is dd-sdk-ios
+> `3.14.0`, binding revision `2`. The fourth component belongs to this repository and advances when
+> the bindings or packaging change while the native binaries stay put.
+
+## Added
+
+### Span identity — `OTSpan.GetTraceId` / `GetSpanId`
+
+`OTSpanContext` declares nothing but `forEachBaggageItem`. There is no `traceID` or `spanID` on the
+protocol or on any bound type, and 3.x did not change that — so the ids can only be recovered by
+injecting into a Datadog-format headers writer and reading what comes back.
+
+That much is merely awkward. The trap is what you find when you look:
+
+```
+x-datadog-trace-id: 6096355397431041644 ← decimal, and only the LOW 64 bits
+x-datadog-tags: _dd.p.tid=6a61e4ff00000000 ← the HIGH 64, somewhere else entirely
+```
+
+A caller who reads the obvious header gets a decimal string naming half of an id that, rendered the
+way Datadog renders it, looks nothing like it. `DatadogNet 3.14.0.1` did exactly that and shipped
+with iOS reporting `6096355397431041644` where Android reported
+`6a61e4ff000000002e430f579ece9a6c` for the same span — which quietly broke RUM-resource-to-APM-trace
+correlation on iOS, because `_dd.trace_id` is the only thing that links them.
+
+```csharp
+var traceId = span.GetTraceId (DDTracer.Shared ()); // 32 lowercase hex, always
+var spanId = span.GetSpanId (DDTracer.Shared ()); // decimal
+```
+
+The rendering matches dd-sdk-android's own `DatadogInterceptor`, which is the reference
+implementation of this correlation: `DatadogTraceId.toHexString()` for the trace id and
+`String.valueOf(long)` for the span id. The asymmetry is Datadog's wire format rather than a choice.
+`toHexString()` is `toHexStringPadded(…, 32)` on both the 128-bit and 64-bit implementations, so the
+result is always 32 characters — with `_dd.p.tid` absent, the high half is genuinely zero and is
+padded, not omitted.
+
+### Header injection — `OTSpan.InjectHeaders`
+
+```csharp
+foreach (var header in span.InjectHeaders (DDTracer.Shared ()))
+ request.Headers.TryAddWithoutValidation (header.Key, header.Value);
+```
+
+The bound API needs a dance that is not visible in the signatures: construct a writer, hand it to
+`Inject` **as though it were the carrier**, then read the headers back off the writer. There is also
+one writer type per format — unlike Android, where the formats are a property of the tracer and one
+call writes all of them — so several formats means several round trips.
+
+The format selector is a new `OTHeaderFormats` flags enum belonging to this repository, because the
+bound `DDTracingHeaderType` is an Objective-C class of static singletons rather than an enum (Swift's
+enum did not survive the projection) and so cannot be combined or compared. Defaults to Datadog plus
+W3C trace context.
+
+### Span outcome — `OTSpan.SetError` / `Log`
+
+```csharp
+span.SetError (exception);
+span.Log (new Dictionary { ["event"] = "retry", ["attempt"] = 2 });
+```
+
+`SetErrorWithKind` takes kind, message and stack separately, and a caller who passes only the message
+gets a span marked as an error with nothing in the APM error panel to act on.
+
+### RUM — single-value attributes and the session id
+
+```csharp
+monitor.AddAttribute ("cart.id", cartId);
+monitor.AddViewAttribute ("cart.items", 3);
+monitor.AddFeatureFlagEvaluation ("new-checkout", true);
+
+var sessionId = await monitor.GetCurrentSessionIdAsync ();
+```
+
+`DatadogAttributes.ToNSObject (value, key)` is now **public**, which is what these are built on.
+`AddAttributeForKey`, `AddViewAttributeForKey`, `AddFeatureFlagEvaluationWithName` and
+`DDLogger.AddAttributeForKey` all take a bare `NSObject` while `DatadogAttributes` only exposed the
+dictionary form — so a caller either hand-wrapped the value, which is exactly what
+`DatadogAttributes` exists to avoid, or round-tripped a one-element dictionary.
+
+`GetCurrentSessionIdAsync` wraps `CurrentSessionIDWithCompletion`, which answers through a block on
+the SDK's own queue.
+
+### Logs
+
+`DDLogger.AddAttribute (key, value)`, taking a plain value rather than an `NSObject`.
+
+## Documentation
+
+The README gains an **API coverage** section, measured by diffing each framework's generated
+`-Swift.h` against `ApiDefinitions.cs` rather than asserted:
+
+| Framework | ObjC types | Bound |
+| --- | ---: | ---: |
+| `DatadogRUM` | 377 | 377 |
+| `DatadogLogs` | 16 | 16 |
+| `DatadogCore` | 12 | 12 |
+| `DatadogTrace` | 12 | 12 |
+| `DatadogSessionReplay` | 4 | 4 |
+| `DatadogInternal` | 2 | 2 |
+| `DatadogCrashReporting` | 1 | 1 |
+| `DatadogWebViewTracking` | 1 | 1 |
+
+Member coverage is the same story — of 61 selectors and properties on `DatadogCore`, 59 are exported,
+and the two that are not are `init` and `new`, removed on purpose by `[DisableDefaultCtor]`.
+
+**What is missing is missing upstream**, and the section now says so precisely rather than vaguely:
+
+| | Swift types | ObjC types |
+| --- | ---: | ---: |
+| `DatadogFlags` | 15 | **0** |
+| `DatadogProfiling` | 2 | **0** |
+| `OpenTelemetryApi` | — | no `-Swift.h` at all |
+
+Feature Flags leans on generics (`FlagDetails`) and enums with associated values (`AnyValue`),
+neither of which Swift projects into Objective-C. `DatadogTrace` is additionally 24 public Swift
+types projected down to 12, and the casualty is `OTelTracerProvider` — so OpenTelemetry tracing is
+unreachable even though OpenTracing is not.
+
+That is also why `DatadogNet.Flags.iOS` and `DatadogNet.Profiling.iOS` ship frameworks with no
+callable API.
+
+## New: a prototype for reaching the Swift-only frameworks
+
+[`shims/DatadogFlagsObjc/`](../../shims/DatadogFlagsObjc/) is a hand-written Swift `@objc` wrapper
+around `DatadogFlags`. **It compiles against the real 3.14.0 framework with zero warnings** and emits
+exactly the header Objective Sharpie consumes, so the rest of the path to a NuGet is the ordinary one
+this repository already runs.
+
+Two things make Flags tractable: Datadog already flattened the generics at the convenience layer
+(`getBooleanValue`, `getStringValue`, `getIntegerValue`, `getDoubleValue`, `getObjectValue` and the
+matching `…Details`), and `FlagsClient.shared(named:in:)` returns a real object to wrap.
+`AnyValue` maps onto the Foundation object graph, which is what a C# caller wants anyway.
+
+**Nothing ships.** It is not built, packaged, bound or referenced — see its README for what remains.
+The standing cost is worth naming: the wrapper is ours, so when Datadog changes
+`FlagsClientProtocol` it stops compiling, at build time rather than someone's runtime.
+
+## Tests
+
+**127 package-layout tests pass**, and the on-simulator suite grows from **17 checks to 21**, all
+running against the packed packages.
+
+Until now this suite enabled Trace and stopped there — `EnablesTrace` constructed the three header
+writers and never started a span — so the tracing path was configured and never driven. That is the
+gap the trace-id defect slipped through, and it 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. "Not empty" is what a caller checked before, and it is why the wrong rendering
+ shipped.
+- **Headers are injected in all three formats** 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. Selecting no formats must produce nothing rather than throwing.
+- **The single-value attribute overloads** are driven on RUM, view attributes, feature flags and a
+ logger, and `ToNSObject` is asserted to map `null` to `NSNull` — because an explicitly empty
+ attribute and an unset one are different things in a RUM event.
+- **The session id** is read through `GetCurrentSessionIdAsync` and asserted non-null; a null means
+ the completion block never fired, which is the failure the `Task` wrapper exists to surface.
+
+The harness gained `async` support to do the last of those, matching the shape the runner already
+had on the façade side.
+
+**The e2e now runs on two bands.** It ran against `net10.0-ios26.0` only; it is now a matrix over
+**net8 and net10**, 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, so testing it too would buy little for the runner minutes.
+
+Unlike the Android side, this needed no project changes — `run-simulator-tests.sh` already picked
+the SDK band from the target framework, and the iOS packages all ship net8 assets. 21/21 on both
+legs; the net8 leg takes appreciably longer, because a Release build for the simulator AOT-compiles.
+
+One thing the new checks turned up: `Info(message, attributes)` is generated without `[NullAllowed]`
+on the dictionary, so passing `null` throws rather than reaching the SDK. Objective-C accepts `nil`
+there. The single-argument overload and the ergonomic `Log` both handle it, so this is a papercut
+rather than a gap — but it is real, and worth knowing before you hit it.
+
+## Sample
+
+`samples/DatadogNet.iOS.Example` gains a **Trace** section, because it enabled Trace and never
+demonstrated it. Two buttons: one traces an outgoing `HttpRequestMessage` end to end — start a span,
+inject the headers a receiving service continues the trace from, report the status, finish — and one
+records a failed span with `SetError` and `Log`. RUM gains a button showing the current session id.
+
+## Upgrading from 3.14.0.1
+
+```diff
+-
++
+```
+
+Nothing is removed or renamed, and the native xcframeworks are byte-for-byte the same build. All
+packages move together, as they depend on each other at an exact version.
+
+**If you read span ids yourself**, replace whatever you were doing with `GetTraceId`/`GetSpanId` and
+check what you get: a decimal trace id, or one shorter than 32 characters, means the RUM-to-APM link
+was not working.
diff --git a/samples/DatadogNet.iOS.Example/MainPage.xaml b/samples/DatadogNet.iOS.Example/MainPage.xaml
index f1a0544..c93cb1c 100644
--- a/samples/DatadogNet.iOS.Example/MainPage.xaml
+++ b/samples/DatadogNet.iOS.Example/MainPage.xaml
@@ -28,6 +28,19 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/DatadogNet.iOS.Example/MainPage.xaml.cs b/samples/DatadogNet.iOS.Example/MainPage.xaml.cs
index 82d8247..52486be 100644
--- a/samples/DatadogNet.iOS.Example/MainPage.xaml.cs
+++ b/samples/DatadogNet.iOS.Example/MainPage.xaml.cs
@@ -1,5 +1,6 @@
using DatadogLogs;
using DatadogRUM;
+using DatadogTrace;
namespace DatadogNetExample;
@@ -81,6 +82,88 @@ private async void OnTrackWork(object? sender, EventArgs e)
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 binding answers through a completion block on the SDK's own queue.
+ var sessionId = await Datadog.Rum.GetCurrentSessionIdAsync();
+
+ Record($"session {sessionId ?? "(none - RUM is off, or this session was sampled out)"}");
+ }
+
+ private async void OnTraceRequest(object? sender, EventArgs e)
+ {
+ var tracer = DDTracer.Shared();
+
+ // The operation name is what APM groups by, so it has to be low cardinality - never a URL
+ // and never anything with an id in it.
+ var span = tracer.StartSpan("http.request");
+
+ 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 API needs a dance that is not obvious from the signatures - construct a
+ // writer, hand it to Inject as though it were the carrier, then read the headers back
+ // off the writer, once per format.
+ foreach (var header in span.InjectHeaders(tracer))
+ {
+ request.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ Record($"trace {span.GetTraceId(tracer)}");
+ Record($"propagating {request.Headers.Count()} headers");
+
+ using var client = new HttpClient();
+ using var response = await client.SendAsync(request);
+
+ span.SetTag("http.status_code", (double)(int)response.StatusCode);
+ Record($"request finished: {(int)response.StatusCode}");
+ }
+ catch (Exception exception)
+ {
+ // Sets error.type, error.message and error.stack together. Setting only the message
+ // leaves a span marked as an error with nothing in the APM error panel to act on.
+ span.SetError(exception);
+ Record($"request failed: {exception.GetType().Name}");
+ }
+ finally
+ {
+ span.Finish();
+ }
+ }
+
+ private void OnFailedSpan(object? sender, EventArgs e)
+ {
+ var tracer = DDTracer.Shared();
+ var span = tracer.StartSpan("checkout.submit");
+
+ span.Log(new Dictionary
+ {
+ ["event"] = "validation",
+ ["fields"] = 3,
+ });
+
+ try
+ {
+ throw new InvalidOperationException("The cart expired before checkout completed.");
+ }
+ catch (Exception exception)
+ {
+ span.SetError(exception);
+ Record($"span {span.GetSpanId(tracer)} marked as failed");
+ }
+ finally
+ {
+ span.Finish();
+ }
+ }
+
private void OnWriteLogs(object? sender, EventArgs e)
{
Datadog.Logger.Log(DDLogLevel.Debug, "a debug message");
diff --git a/shims/DatadogFlagsObjc/DatadogFlagsObjc.swift b/shims/DatadogFlagsObjc/DatadogFlagsObjc.swift
new file mode 100644
index 0000000..7271933
--- /dev/null
+++ b/shims/DatadogFlagsObjc/DatadogFlagsObjc.swift
@@ -0,0 +1,335 @@
+// A hand-written Objective-C projection of DatadogFlags, which Datadog ships as Swift only.
+//
+// Nothing here adds behaviour. Every member forwards to the Swift API; the work is entirely in
+// reshaping what Swift can express and @objc cannot: generics, enums with associated values,
+// Result-typed completions, and structs.
+
+import Foundation
+import DatadogFlags
+import DatadogInternal
+
+// MARK: - Enums
+//
+// Swift enums reach Objective-C only when they are Int-backed, so each is restated. FlagsClientState
+// and FlagEvaluationError are both simple cases with no payload, so this is lossless.
+
+@objc(DDFlagsClientState)
+public enum DDFlagsClientState: Int {
+ case notReady = 0
+ case ready = 1
+ case reconciling = 2
+ case stale = 3
+ case error = 4
+
+ init(_ state: FlagsClientState) {
+ switch state {
+ case .notReady: self = .notReady
+ case .ready: self = .ready
+ case .reconciling: self = .reconciling
+ case .stale: self = .stale
+ case .error: self = .error
+ @unknown default: self = .notReady
+ }
+ }
+}
+
+/// The evaluation error, with a `none` case standing in for Swift's `nil`.
+@objc(DDFlagEvaluationError)
+public enum DDFlagEvaluationError: Int {
+ case none = 0
+ case providerNotReady = 1
+ case flagNotFound = 2
+ case typeMismatch = 3
+
+ init(_ error: FlagEvaluationError?) {
+ switch error {
+ case .none: self = .none
+ case .some(.providerNotReady): self = .providerNotReady
+ case .some(.flagNotFound): self = .flagNotFound
+ case .some(.typeMismatch): self = .typeMismatch
+ @unknown default: self = .none
+ }
+ }
+}
+
+// MARK: - AnyValue
+//
+// AnyValue is an enum with associated values, which cannot cross into Objective-C at all. It maps
+// naturally onto the Foundation object graph instead, which is also what a C# caller wants: it
+// arrives as NSString/NSNumber/NSDictionary/NSArray/NSNull and needs no further translation.
+
+enum DDAnyValue {
+ static func toObjC(_ value: AnyValue) -> Any {
+ switch value {
+ case .string(let value): return value as NSString
+ case .bool(let value): return NSNumber(value: value)
+ case .int(let value): return NSNumber(value: value)
+ case .double(let value): return NSNumber(value: value)
+ case .dictionary(let value): return value.mapValues(toObjC) as NSDictionary
+ case .array(let value): return value.map(toObjC) as NSArray
+ case .null: return NSNull()
+ @unknown default: return NSNull()
+ }
+ }
+
+ static func fromObjC(_ value: Any) -> AnyValue {
+ switch value {
+ case let value as NSNumber:
+ // NSNumber erases Bool, so the CFBoolean check is the only way to keep `true` from
+ // arriving as the integer 1 - which would change a boolean flag's type and make the
+ // SDK report a typeMismatch against a perfectly good flag.
+ if CFGetTypeID(value) == CFBooleanGetTypeID() {
+ return .bool(value.boolValue)
+ }
+
+ let type = String(cString: value.objCType)
+ return (type == "d" || type == "f") ? .double(value.doubleValue) : .int(value.intValue)
+
+ case let value as NSString: return .string(value as String)
+ case let value as NSDictionary:
+ var result: [String: AnyValue] = [:]
+ for (key, element) in value {
+ if let key = key as? String { result[key] = fromObjC(element) }
+ }
+ return .dictionary(result)
+
+ case let value as NSArray: return .array(value.map(fromObjC))
+ default: return .null
+ }
+ }
+}
+
+// MARK: - FlagDetails
+//
+// FlagDetails is generic, so it cannot be exposed as-is. Flattening the value to `Any` collapses
+// the five generic instantiations into one class, and loses nothing a C# caller can act on.
+
+@objc(DDFlagDetails)
+public final class DDFlagDetails: NSObject {
+ @objc public let key: String
+ @objc public let value: Any
+ @objc public let variant: String?
+ @objc public let reason: String?
+ @objc public let allocationKey: String?
+ @objc public let error: DDFlagEvaluationError
+
+ init(_ details: FlagDetails, value: Any) {
+ self.key = details.key
+ self.value = value
+ self.variant = details.variant
+ self.reason = details.reason
+ self.allocationKey = details.allocationKey
+ self.error = DDFlagEvaluationError(details.error)
+ }
+}
+
+@objc(DDFlagSnapshot)
+public final class DDFlagSnapshot: NSObject {
+ @objc public let value: Any
+ @objc public let variant: String
+ @objc public let reason: String
+
+ init(_ snapshot: FlagSnapshot) {
+ self.value = DDAnyValue.toObjC(snapshot.value)
+ self.variant = snapshot.variant
+ self.reason = snapshot.reason
+ }
+}
+
+// MARK: - Configuration
+
+@objc(DDFlagsConfiguration)
+public final class DDFlagsConfiguration: NSObject {
+ @objc public var gracefulModeEnabled: Bool = true
+ @objc public var customFlagsEndpoint: URL?
+ @objc public var customFlagsHeaders: [String: String]?
+ @objc public var customExposureEndpoint: URL?
+ @objc public var trackExposures: Bool = true
+ @objc public var customEvaluationEndpoint: URL?
+ @objc public var trackEvaluations: Bool = true
+ @objc public var evaluationFlushInterval: TimeInterval = 10
+ @objc public var rumIntegrationEnabled: Bool = true
+
+ var swift: Flags.Configuration {
+ Flags.Configuration(
+ gracefulModeEnabled: gracefulModeEnabled,
+ customFlagsEndpoint: customFlagsEndpoint,
+ customFlagsHeaders: customFlagsHeaders,
+ customExposureEndpoint: customExposureEndpoint,
+ trackExposures: trackExposures,
+ customEvaluationEndpoint: customEvaluationEndpoint,
+ trackEvaluations: trackEvaluations,
+ evaluationFlushInterval: evaluationFlushInterval,
+ rumIntegrationEnabled: rumIntegrationEnabled
+ )
+ }
+}
+
+// MARK: - Entry point
+//
+// `Flags` is a caseless enum used as a namespace, which has no Objective-C equivalent. A final class
+// with static members reads the same from C#.
+
+@objc(DDFlags)
+public final class DDFlags: NSObject {
+ @objc public static func enable() {
+ Flags.enable()
+ }
+
+ @objc public static func enable(with configuration: DDFlagsConfiguration) {
+ Flags.enable(with: configuration.swift)
+ }
+}
+
+// MARK: - Listener registration
+//
+// The Swift API takes a listener object and hands back nothing, so unsubscribing means holding on
+// to the same instance. Returning a token instead removes a way to leak.
+
+@objc(DDFlagsStateSubscription)
+public final class DDFlagsStateSubscription: NSObject {
+ private weak var observable: (any FlagsStateObservable)?
+ private let listener: any FlagsStateListener
+
+ init(observable: any FlagsStateObservable, listener: any FlagsStateListener) {
+ self.observable = observable
+ self.listener = listener
+ }
+
+ @objc public func cancel() {
+ observable?.removeListener(listener)
+ observable = nil
+ }
+}
+
+private final class BlockStateListener: FlagsStateListener {
+ private let handler: (DDFlagsClientState) -> Void
+
+ init(handler: @escaping (DDFlagsClientState) -> Void) {
+ self.handler = handler
+ }
+
+ func flagsStateDidChange(_ newState: FlagsClientState) {
+ handler(DDFlagsClientState(newState))
+ }
+}
+
+// MARK: - Client
+
+@objc(DDFlagsClient)
+public final class DDFlagsClient: NSObject {
+ private let client: any FlagsClientProtocol
+
+ private init(_ client: any FlagsClientProtocol) {
+ self.client = client
+ }
+
+ @objc public static func shared() -> DDFlagsClient {
+ DDFlagsClient(FlagsClient.shared())
+ }
+
+ @objc public static func shared(named name: String) -> DDFlagsClient {
+ DDFlagsClient(FlagsClient.shared(named: name))
+ }
+
+ @objc public static func create(named name: String) -> DDFlagsClient {
+ DDFlagsClient(FlagsClient.create(name: name))
+ }
+
+ @objc public var currentState: DDFlagsClientState {
+ DDFlagsClientState(client.state.currentState)
+ }
+
+ // MARK: Values
+
+ @objc public func boolValue(forKey key: String, defaultValue: Bool) -> Bool {
+ client.getBooleanValue(key: key, defaultValue: defaultValue)
+ }
+
+ @objc public func stringValue(forKey key: String, defaultValue: String) -> String {
+ client.getStringValue(key: key, defaultValue: defaultValue)
+ }
+
+ // Int rather than Swift's Int, so the width is the platform's and not a surprise on 32-bit.
+ @objc public func integerValue(forKey key: String, defaultValue: Int) -> Int {
+ client.getIntegerValue(key: key, defaultValue: defaultValue)
+ }
+
+ @objc public func doubleValue(forKey key: String, defaultValue: Double) -> Double {
+ client.getDoubleValue(key: key, defaultValue: defaultValue)
+ }
+
+ @objc public func objectValue(forKey key: String, defaultValue: Any) -> Any {
+ DDAnyValue.toObjC(
+ client.getObjectValue(key: key, defaultValue: DDAnyValue.fromObjC(defaultValue)))
+ }
+
+ // MARK: Details
+
+ @objc public func boolDetails(forKey key: String, defaultValue: Bool) -> DDFlagDetails {
+ let details = client.getBooleanDetails(key: key, defaultValue: defaultValue)
+ return DDFlagDetails(details, value: NSNumber(value: details.value))
+ }
+
+ @objc public func stringDetails(forKey key: String, defaultValue: String) -> DDFlagDetails {
+ let details = client.getStringDetails(key: key, defaultValue: defaultValue)
+ return DDFlagDetails(details, value: details.value as NSString)
+ }
+
+ @objc public func integerDetails(forKey key: String, defaultValue: Int) -> DDFlagDetails {
+ let details = client.getIntegerDetails(key: key, defaultValue: defaultValue)
+ return DDFlagDetails(details, value: NSNumber(value: details.value))
+ }
+
+ @objc public func doubleDetails(forKey key: String, defaultValue: Double) -> DDFlagDetails {
+ let details = client.getDoubleDetails(key: key, defaultValue: defaultValue)
+ return DDFlagDetails(details, value: NSNumber(value: details.value))
+ }
+
+ @objc public func objectDetails(forKey key: String, defaultValue: Any) -> DDFlagDetails {
+ let details = client.getObjectDetails(
+ key: key, defaultValue: DDAnyValue.fromObjC(defaultValue))
+ return DDFlagDetails(details, value: DDAnyValue.toObjC(details.value))
+ }
+
+ // MARK: Evaluation context
+
+ /// Sets the evaluation context.
+ /// - Note: `Result` has no Objective-C form; the completion takes an
+ /// `NSError?` instead, which is also what a C# `Task` wants.
+ @objc public func setEvaluationContext(
+ targetingKey: String,
+ attributes: [String: Any],
+ completion: @escaping (NSError?) -> Void
+ ) {
+ let context = FlagsEvaluationContext(
+ targetingKey: targetingKey,
+ attributes: attributes.mapValues(DDAnyValue.fromObjC))
+
+ client.setEvaluationContext(context) { result in
+ switch result {
+ case .success:
+ completion(nil)
+ case .failure(let error):
+ completion(error as NSError)
+ }
+ }
+ }
+
+ // MARK: State
+
+ @objc public func addStateListener(
+ _ handler: @escaping (DDFlagsClientState) -> Void
+ ) -> DDFlagsStateSubscription {
+ let observable = client.state
+ let listener = BlockStateListener(handler: handler)
+ observable.addListener(listener)
+ return DDFlagsStateSubscription(observable: observable, listener: listener)
+ }
+
+ // MARK: Snapshot
+
+ @objc public func snapshot() -> [String: DDFlagSnapshot]? {
+ client.snapshot()?.assignments.mapValues(DDFlagSnapshot.init)
+ }
+}
diff --git a/shims/DatadogFlagsObjc/README.md b/shims/DatadogFlagsObjc/README.md
new file mode 100644
index 0000000..dc23279
--- /dev/null
+++ b/shims/DatadogFlagsObjc/README.md
@@ -0,0 +1,47 @@
+# DatadogFlagsObjc — a prototype, not yet shipped
+
+`DatadogFlags` is one of two frameworks Datadog ships as **Swift only**: 15 public Swift types and
+zero Objective-C ones. Nothing in this repository can bind it, because there is no Objective-C
+surface to bind — the API leans on generics (`FlagDetails`) and enums with associated values
+(`AnyValue`), neither of which Swift projects into Objective-C.
+
+This directory is the answer to "can we write that projection ourselves". It is a hand-written
+`@objc` wrapper around the Swift API, and **it compiles against the real 3.14.0 framework with zero
+warnings**:
+
+```bash
+xcrun swiftc -emit-module -emit-objc-header \
+ -emit-objc-header-path DatadogFlagsObjc-Swift.h \
+ -module-name DatadogFlagsObjc \
+ -target arm64-apple-ios12.2 \
+ -sdk "$(xcrun --sdk iphoneos --show-sdk-path)" \
+ -F \
+ DatadogFlagsObjc.swift
+```
+
+The `-Swift.h` that comes out is exactly what Objective Sharpie consumes, so the rest of the path to
+a NuGet is the ordinary one this repository already runs for every other package.
+
+## Status
+
+**Prototype.** It compiles and the shape is settled; it has never been run. Not built, not packaged,
+not bound, not referenced by anything. Nothing in `src/` knows it exists.
+
+What remains before it could ship:
+
+- an xcframework build producing **both** the `ios-arm64` and `ios-arm64_x86_64-simulator` slices,
+ with `-enable-library-evolution`, linking the Datadog frameworks rather than embedding them
+- a `DatadogNet.Flags.iOS` binding project with `ApiDefinitions.cs` over the generated header
+- a device check that evaluates a flag and asserts what came back
+- an `IFeatureFlags` in the façade — where **Android would be the platform with no implementation**,
+ since dd-sdk-android 3.12.1 has no flags module at all
+
+See [`docs/swift-interop-plan.md`](../../../DatadogNet/docs/swift-interop-plan.md) in the façade
+repository for the full design, including why the same approach is worth doing for `DatadogProfiling`
+and is **not** worth doing for OpenTelemetry.
+
+## The risk, stated plainly
+
+This wrapper is ours, and Datadog is under no obligation to keep `FlagsClientProtocol` source-stable
+between releases. When they change it, this stops compiling — at our build time, which is the good
+failure. That is the standing cost of the feature, and it should be a conscious one.
diff --git a/src/DatadogNet.Core.iOS/Additions/Attributes.cs b/src/DatadogNet.Core.iOS/Additions/Attributes.cs
index 4b7db7f..44b55cb 100644
--- a/src/DatadogNet.Core.iOS/Additions/Attributes.cs
+++ b/src/DatadogNet.Core.iOS/Additions/Attributes.cs
@@ -81,7 +81,25 @@ public static NSDictionary From (params (string Key, object?
return NSDictionary.FromObjectsAndKeys (values, keys, keys.Length);
}
- static NSObject ToNSObject (object? value, string key)
+ ///
+ /// 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 Objective-C representation.
+ ///
+ /// Several members take a bare value rather than a dictionary —
+ /// DDRUMMonitor.AddAttributeForKey, AddFeatureFlagEvaluationWithName,
+ /// AddViewAttributeForKey, DDLogger.AddAttributeForKey — 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 to get at the result.
+ ///
+ public static NSObject ToNSObject (object? value, string key)
{
// NSNull rather than skipping the key: "this attribute was explicitly empty" and "this
// attribute was not set" are different things in a RUM event, and dropping the key
diff --git a/src/DatadogNet.Logs.iOS/Additions/DDLogger.Ergonomics.cs b/src/DatadogNet.Logs.iOS/Additions/DDLogger.Ergonomics.cs
index fd3dee5..a538536 100644
--- a/src/DatadogNet.Logs.iOS/Additions/DDLogger.Ergonomics.cs
+++ b/src/DatadogNet.Logs.iOS/Additions/DDLogger.Ergonomics.cs
@@ -147,5 +147,19 @@ public void Log (
throw new ArgumentOutOfRangeException (nameof (level), level, "Unknown log level.");
}
}
+
+ /// Adds an attribute to every subsequent entry from this logger.
+ ///
+ /// The generated overload takes an , so a logger-wide attribute has to
+ /// be hand-wrapped — which is what exists to avoid for the
+ /// dictionary-taking members.
+ ///
+ public void AddAttribute (string key, object? value)
+ {
+ if (key is null)
+ throw new ArgumentNullException (nameof (key));
+
+ AddAttributeForKey (key, DatadogAttributes.ToNSObject (value, key));
+ }
}
}
diff --git a/src/DatadogNet.RUM.iOS/Additions/DDRUMMonitor.Ergonomics.cs b/src/DatadogNet.RUM.iOS/Additions/DDRUMMonitor.Ergonomics.cs
index 14d4eab..0761b9b 100644
--- a/src/DatadogNet.RUM.iOS/Additions/DDRUMMonitor.Ergonomics.cs
+++ b/src/DatadogNet.RUM.iOS/Additions/DDRUMMonitor.Ergonomics.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
using DatadogCore;
using Foundation;
@@ -115,6 +116,59 @@ public void RemoveViewAttributes (params string[] keys)
RemoveViewAttributesForKeys (keys);
}
+
+ /// Adds an attribute to every subsequent RUM event.
+ ///
+ /// The generated overload takes an , so setting a single attribute
+ /// means hand-wrapping the value — which is exactly what
+ /// exists to avoid for the dictionary-taking members.
+ ///
+ public void AddAttribute (string key, object? value)
+ {
+ if (key is null)
+ throw new ArgumentNullException (nameof (key));
+
+ AddAttributeForKey (key, DatadogAttributes.ToNSObject (value, key));
+ }
+
+ /// Adds an attribute to the active view, which propagates to its child events.
+ public void AddViewAttribute (string key, object? value)
+ {
+ if (key is null)
+ throw new ArgumentNullException (nameof (key));
+
+ AddViewAttributeForKey (key, DatadogAttributes.ToNSObject (value, key));
+ }
+
+ /// Records that a feature flag was evaluated, so RUM events can be split by variant.
+ public void AddFeatureFlagEvaluation (string name, object? value)
+ {
+ if (name is null)
+ throw new ArgumentNullException (nameof (name));
+
+ AddFeatureFlagEvaluationWithName (name, DatadogAttributes.ToNSObject (value, name));
+ }
+
+ ///
+ /// The id of the current RUM session, or if there is none — because
+ /// RUM is not enabled, or because this session was dropped by sampling.
+ ///
+ ///
+ /// Worth attaching to a support ticket: it is what turns "the app was slow" into a session
+ /// you can watch. The generated member answers through a completion block on the SDK's own
+ /// queue; this is the same call in the shape C# already has for asynchrony.
+ ///
+ public Task GetCurrentSessionIdAsync ()
+ {
+ // RunContinuationsAsynchronously: the completion arrives on whichever queue the SDK
+ // answers on, and a synchronous continuation would run the caller's await-resumption
+ // there too.
+ var completion = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously);
+
+ CurrentSessionIDWithCompletion (sessionId => completion.TrySetResult (sessionId?.ToString ()));
+
+ return completion.Task;
+ }
}
/// Keeps a RUM view open for the lifetime of a block.
diff --git a/src/DatadogNet.Trace.iOS/Additions/OTSpan.Ergonomics.cs b/src/DatadogNet.Trace.iOS/Additions/OTSpan.Ergonomics.cs
new file mode 100644
index 0000000..ee175e3
--- /dev/null
+++ b/src/DatadogNet.Trace.iOS/Additions/OTSpan.Ergonomics.cs
@@ -0,0 +1,262 @@
+// Nullable annotations are enabled per file rather than for the project: the generated binding
+// sources are not written against a nullable context, and switching the whole project over would
+// bury real warnings here under hundreds of generated ones.
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using DatadogCore;
+using Foundation;
+
+namespace DatadogTrace
+{
+ /// Which propagation formats to write.
+ ///
+ /// A flags enum of this repository's own, because the bound DDTracingHeaderType is an
+ /// Objective-C class of static singletons rather than an enum — Swift's enum did not survive the
+ /// projection — so it cannot be combined or compared in the way choosing several formats needs.
+ ///
+ [Flags]
+ public enum OTHeaderFormats
+ {
+ /// Datadog's own x-datadog-* headers.
+ Datadog = 1,
+
+ /// W3C Trace Context: traceparent and tracestate.
+ TraceContext = 2,
+
+ /// B3 single header.
+ B3 = 4,
+
+ /// B3 multiple headers.
+ B3Multi = 8,
+ }
+
+ ///
+ /// Reading a span's identity and describing its outcome, in a form C# can call directly.
+ ///
+ ///
+ /// Extension methods rather than a partial class, because OTSpan is a
+ /// [Protocol] — the usable form is the IOTSpan interface, which has no partial
+ /// declaration to extend.
+ ///
+ public static class OTSpanExtensions
+ {
+ /// The Datadog-format headers the ids are read out of.
+ ///
+ /// OTSpanContext declares nothing but forEachBaggageItem — there is no
+ /// traceID or spanID on the protocol or on any bound type, and 3.x did not
+ /// change that. Injecting into a Datadog-format writer and reading what comes back is the
+ /// only route to them from Objective-C.
+ ///
+ const string TraceIdHeader = "x-datadog-trace-id";
+
+ const string SpanIdHeader = "x-datadog-parent-id";
+
+ const string TagsHeader = "x-datadog-tags";
+
+ /// The propagation tag holding the high 64 bits of a 128-bit trace id.
+ const string HighOrderBitsTag = "_dd.p.tid";
+
+ ///
+ /// The trace id, rendered the way Datadog's own instrumentation renders it.
+ ///
+ /// The span.
+ /// The tracer that started it, usually DDTracer.Shared ().
+ /// 32 lowercase hexadecimal characters, or an empty string if there is no trace.
+ ///
+ /// Matches DatadogTraceId.toHexString() on Android, which is what Datadog's own
+ /// DatadogInterceptor writes as _dd.trace_id when it links a RUM resource to
+ /// its APM trace. Anything else produces a string the backend will not correlate on.
+ ///
+ /// The id arrives in two pieces and has to be reassembled: x-datadog-trace-id carries
+ /// the low 64 bits in decimal, and the high 64 travel separately as _dd.p.tid inside
+ /// x-datadog-tags. Reading only the former yields a decimal string that names half of
+ /// a different-looking id — which is a real mistake that shipped in a consumer of this
+ /// package before it was caught.
+ ///
+ ///
+ public static string GetTraceId (this IOTSpan span, IOTTracer tracer)
+ {
+ var headers = InjectDatadogHeaders (span, tracer);
+
+ headers.TryGetValue (TraceIdHeader, out var lowOrderBits);
+ headers.TryGetValue (TagsHeader, out var tags);
+
+ if (!ulong.TryParse (lowOrderBits?.Trim (), NumberStyles.None, CultureInfo.InvariantCulture, out var low))
+ return string.Empty;
+
+ // An absent _dd.p.tid means 128-bit ids are off, which is Android's DD64bTraceId case:
+ // the high half is genuinely zero rather than unknown, and Datadog still pads it out to
+ // the full 32 characters.
+ var high = ReadHighOrderBits (tags);
+
+ return high.ToString ("x16", CultureInfo.InvariantCulture)
+ + low.ToString ("x16", CultureInfo.InvariantCulture);
+ }
+
+ ///
+ /// The span id, rendered the way Datadog's own instrumentation renders it.
+ ///
+ /// The span.
+ /// The tracer that started it.
+ /// The decimal form, or an empty string if there is no trace.
+ ///
+ /// Decimal, and deliberately not hexadecimal like . The asymmetry is
+ /// Datadog's wire format: _dd.span_id is written as String.valueOf(long).
+ ///
+ public static string GetSpanId (this IOTSpan span, IOTTracer tracer)
+ {
+ InjectDatadogHeaders (span, tracer).TryGetValue (SpanIdHeader, out var spanId);
+
+ return spanId ?? string.Empty;
+ }
+
+ ///
+ /// Writes the trace headers for into a dictionary.
+ ///
+ /// The span to propagate.
+ /// The tracer that started it.
+ ///
+ /// Which formats to write. Defaults to Datadog and W3C trace context, which is what most
+ /// backends accept between them.
+ ///
+ ///
+ /// The bound API needs a dance that is not obvious from the signatures: you construct a
+ /// writer, hand it to Inject as though it were the carrier, and then read the headers
+ /// back off the writer. There is also one writer type per format — unlike Android, where the
+ /// formats are a property of the tracer and one call writes all of them — so several formats
+ /// means several round trips, merged here.
+ ///
+ /// TraceContextInjection.All so the headers still go out for a dropped trace, which is
+ /// what lets the receiving service stitch the request together even when nothing is stored.
+ ///
+ ///
+ public static IDictionary InjectHeaders (
+ this IOTSpan span,
+ IOTTracer tracer,
+ OTHeaderFormats headerTypes = OTHeaderFormats.Datadog | OTHeaderFormats.TraceContext)
+ {
+ if (span is null)
+ throw new ArgumentNullException (nameof (span));
+ if (tracer is null)
+ throw new ArgumentNullException (nameof (tracer));
+
+ var headers = new Dictionary (StringComparer.OrdinalIgnoreCase);
+
+ if (headerTypes.HasFlag (OTHeaderFormats.Datadog))
+ Merge (headers, Write (span, tracer, new DDHTTPHeadersWriter (DDTraceContextInjection.All)));
+
+ if (headerTypes.HasFlag (OTHeaderFormats.TraceContext))
+ Merge (headers, Write (span, tracer, new DDW3CHTTPHeadersWriter (DDTraceContextInjection.All)));
+
+ if (headerTypes.HasFlag (OTHeaderFormats.B3))
+ Merge (headers, Write (span, tracer,
+ new DDB3HTTPHeadersWriter (DDInjectEncoding.Single, DDTraceContextInjection.All)));
+
+ if (headerTypes.HasFlag (OTHeaderFormats.B3Multi))
+ Merge (headers, Write (span, tracer,
+ new DDB3HTTPHeadersWriter (DDInjectEncoding.Multiple, DDTraceContextInjection.All)));
+
+ return headers;
+ }
+
+ /// Marks the span as failed, from a .NET exception.
+ ///
+ /// SetErrorWithKind takes the three fields separately, and a caller who passes only
+ /// the message gets a span marked as an error with nothing in the APM error panel to act on.
+ ///
+ public static void SetError (this IOTSpan span, Exception exception)
+ {
+ if (span is null)
+ throw new ArgumentNullException (nameof (span));
+ if (exception is null)
+ throw new ArgumentNullException (nameof (exception));
+
+ span.SetErrorWithKind (
+ exception.GetType ().FullName ?? exception.GetType ().Name,
+ exception.Message,
+ exception.StackTrace);
+ }
+
+ /// Attaches a set of log fields to the span.
+ ///
+ /// The generated overload takes an , so every call site otherwise
+ /// repeats the conversion already does.
+ ///
+ public static void Log (this IOTSpan span, IReadOnlyDictionary fields)
+ {
+ if (span is null)
+ throw new ArgumentNullException (nameof (span));
+ if (fields is null)
+ throw new ArgumentNullException (nameof (fields));
+
+ span.Log (DatadogAttributes.From (fields));
+ }
+
+ static IDictionary InjectDatadogHeaders (IOTSpan span, IOTTracer tracer)
+ {
+ if (span is null)
+ throw new ArgumentNullException (nameof (span));
+ if (tracer is null)
+ throw new ArgumentNullException (nameof (tracer));
+
+ var headers = new Dictionary (StringComparer.OrdinalIgnoreCase);
+
+ Merge (headers, Write (span, tracer, new DDHTTPHeadersWriter (DDTraceContextInjection.All)));
+
+ return headers;
+ }
+
+ static NSDictionary Write (IOTSpan span, IOTTracer tracer, DDHTTPHeadersWriter writer)
+ {
+ tracer.Inject (span.Context, OT.FormatTextMap, writer, out _);
+ return writer.TraceHeaderFields;
+ }
+
+ static NSDictionary Write (IOTSpan span, IOTTracer tracer, DDW3CHTTPHeadersWriter writer)
+ {
+ tracer.Inject (span.Context, OT.FormatTextMap, writer, out _);
+ return writer.TraceHeaderFields;
+ }
+
+ static NSDictionary Write (IOTSpan span, IOTTracer tracer, DDB3HTTPHeadersWriter writer)
+ {
+ tracer.Inject (span.Context, OT.FormatTextMap, writer, out _);
+ return writer.TraceHeaderFields;
+ }
+
+ static void Merge (IDictionary headers, NSDictionary fields)
+ {
+ foreach (var key in fields.Keys)
+ headers[key.ToString ()] = fields[key].ToString ();
+ }
+
+ /// Reads _dd.p.tid out of the propagation tags.
+ static ulong ReadHighOrderBits (string? tags)
+ {
+ if (string.IsNullOrEmpty (tags))
+ return 0;
+
+ foreach (var pair in tags!.Split (',')) {
+ var separator = pair.IndexOf ('=');
+
+ if (separator < 0 || pair.Substring (0, separator).Trim () != HighOrderBitsTag)
+ continue;
+
+ var value = pair.Substring (separator + 1).Trim ();
+
+ // Datadog writes exactly sixteen hex characters. Anything else is a tag this does
+ // not understand, and a half-parsed value would name a real-looking trace that
+ // nothing ever reported - worse than reporting no high bits at all.
+ return value.Length == 16
+ && ulong.TryParse (value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var high)
+ ? high
+ : 0;
+ }
+
+ return 0;
+ }
+ }
+}
diff --git a/tests/DatadogNet.iOS.DeviceTests/Program.cs b/tests/DatadogNet.iOS.DeviceTests/Program.cs
index 68a3887..2ff2078 100644
--- a/tests/DatadogNet.iOS.DeviceTests/Program.cs
+++ b/tests/DatadogNet.iOS.DeviceTests/Program.cs
@@ -31,12 +31,14 @@ public override bool FinishedLaunching(UIApplication application, NSDictionary l
// The checks run on the main thread deliberately, unlike the FFmpegKit equivalent: the
// Datadog SDK instruments UIKit, and DDRUMMonitor asserts it is reached from the main
// thread. Nothing here blocks for long enough to trip the watchdog.
- RunAndReport();
+ // Fire and forget: FinishedLaunching cannot be async, and the continuations resume on the
+ // main thread through UIKit's synchronisation context - which is what the SDK requires.
+ _ = RunAndReportAsync();
return true;
}
- private static void RunAndReport()
+ private static async Task RunAndReportAsync()
{
SmokeTests.Reporter = message => Console.WriteLine($" {message}");
@@ -46,7 +48,7 @@ private static void RunAndReport()
{
try
{
- test.Execute();
+ await test.Execute();
Console.WriteLine($"PASS {test.Name}");
}
catch (Exception exception)
diff --git a/tests/DatadogNet.iOS.DeviceTests/SmokeTests.cs b/tests/DatadogNet.iOS.DeviceTests/SmokeTests.cs
index 2cd0d07..f0e8c18 100644
--- a/tests/DatadogNet.iOS.DeviceTests/SmokeTests.cs
+++ b/tests/DatadogNet.iOS.DeviceTests/SmokeTests.cs
@@ -18,7 +18,18 @@ namespace DatadogNet.iOS.DeviceTests;
/// A single on-simulator check. Throws to fail.
/// Human readable name, reported to stdout.
/// Runs the check.
-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 simulator: they load the native
@@ -64,12 +75,16 @@ public static class SmokeTests
new("propagates view-level attributes to child events", ViewAttributesPropagate),
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 in every format", InjectsTraceHeaders),
new("enables Session Replay", EnablesSessionReplay),
new("applies per-view Session Replay privacy overrides", SessionReplayPrivacyOverridesApply),
new("enables crash reporting", EnablesCrashReporting),
new("exposes WebView tracking", ExposesWebViewTracking),
new("instruments a URLSession delegate by type", InstrumentsUrlSessionByType),
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("invokes a RUM event mapper", RumEventMapperIsInvoked),
new("invokes a Logs event mapper and redacts a message", LogsEventMapperRedacts),
new("stops the RUM session and the SDK instance", StopsCleanly),
@@ -304,6 +319,162 @@ private static void EnablesTrace()
Report("Trace enabled; Datadog, B3 and W3C header writers all constructed");
}
+ /// Starts a real span and reads back what the SDK made of it.
+ ///
+ /// Until 3.14.0.2 nothing in this suite started a span at all — only
+ /// constructed the header writers — so the tracing path was enabled and never driven. That gap
+ /// is why a defect in reading span ids reached a consumer of these packages.
+ ///
+ private static void DrivesASpanAndReadsItsIds()
+ {
+ var tracer = DDTracer.Shared();
+ var span = tracer.StartSpan("device-test-span");
+
+ span.SetTag("kind", "smoke");
+ span.SetTag("count", NSNumber.FromInt32(1));
+ span.SetTag("enabled", true);
+
+ // Both are this repository's own members: OTSpanContext exposes no ids at all.
+ var traceId = span.GetTraceId(tracer);
+ var spanId = span.GetSpanId(tracer);
+
+ Report($"trace {traceId} span {spanId}");
+
+ // The shape is the assertion, not merely non-emptiness. A trace id is what Datadog
+ // correlates a RUM resource to an APM trace on, and it must match what dd-sdk-android's own
+ // DatadogInterceptor writes: 32 lowercase hex characters for the trace, decimal for the
+ // span. Asserting only "not empty" is exactly how the wrong rendering shipped once.
+ 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.Log(new Dictionary { ["event"] = "retry", ["attempt"] = 2 });
+
+ span.Finish();
+
+ Report("span tagged, errored, logged and finished");
+ }
+
+ /// Injects into every format and checks the ids agree across them.
+ ///
+ /// The writer-is-also-the-carrier dance lives in InjectHeaders now, so what is checked
+ /// here is that it produces the headers a backend needs — and that the id the span reports is
+ /// the same one that goes on the wire, which is the invariant that would have caught the
+ /// rendering defect.
+ ///
+ private static void InjectsTraceHeaders()
+ {
+ var tracer = DDTracer.Shared();
+ var span = tracer.StartSpan("injected-span");
+
+ var headers = span.InjectHeaders(
+ tracer,
+ OTHeaderFormats.Datadog | OTHeaderFormats.TraceContext | OTHeaderFormats.B3Multi);
+
+ 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.");
+
+ Assert(headers.ContainsKey("traceparent"), "Injection produced no W3C traceparent header.");
+ Assert(headers.ContainsKey("X-B3-TraceId"), "Injection produced no B3 headers.");
+
+ var traceId = span.GetTraceId(tracer);
+
+ // traceparent carries the full 128 bits as hex and is derived independently of everything
+ // GetTraceId does, so it is a second opinion rather than a restatement.
+ var traceparent = headers["traceparent"].Split('-');
+
+ Assert(
+ traceparent.Length >= 2 && traceparent[1] == traceId,
+ $"The trace id '{traceId}' disagrees with traceparent '{headers["traceparent"]}'.");
+
+ // The Datadog header carries only the low 64 bits, in decimal. They must be the tail of the
+ // reassembled id - this is the half that used to be reported on its own.
+ 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"]}'.");
+ }
+
+ span.Finish();
+
+ // A span with no formats selected must produce nothing rather than throwing.
+ var quiet = tracer.StartSpan("no-formats");
+ Assert(quiet.InjectHeaders(tracer, 0).Count == 0, "Selecting no formats still wrote headers.");
+ quiet.Finish();
+ }
+
+ /// The single-value attribute overloads, which need no hand-wrapped NSObject.
+ private static void SingleValueAttributesWork()
+ {
+ var monitor = DDRUMMonitor.Shared();
+
+ using (monitor.StartView("single-value-view"))
+ {
+ monitor.AddAttribute("global.string", "text");
+ monitor.AddAttribute("global.int", 42);
+ monitor.AddAttribute("global.null", null);
+
+ monitor.AddViewAttribute("view.bool", true);
+ monitor.AddViewAttribute("view.date", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ monitor.AddFeatureFlagEvaluation("new-checkout", true);
+ monitor.AddFeatureFlagEvaluation("checkout-variant", "b");
+
+ monitor.RemoveAttributeForKey("global.int");
+ }
+
+ var logger = DDLogger.Create(name: "single-value");
+ logger.AddAttribute("tenant", "acme");
+ logger.AddAttribute("retries", 3);
+ // The single-argument overload: `Info(message, attributes)` is generated without
+ // [NullAllowed] on the dictionary, so passing null throws rather than reaching the SDK.
+ logger.Info("with logger-wide attributes");
+
+ // The ergonomic Log does accept a null attributes dictionary, and should.
+ logger.Log(DDLogLevel.Info, "with no attributes at all");
+
+ // The converter itself, now public - the reason none of the above needs an NSObject.
+ Assert(
+ DatadogAttributes.ToNSObject("text", "k") is NSString,
+ "ToNSObject did not convert a string to an NSString.");
+
+ Assert(
+ DatadogAttributes.ToNSObject(null, "k") is NSNull,
+ "ToNSObject did not convert null to NSNull - an explicitly empty attribute and an " +
+ "unset one are different things in a RUM event.");
+
+ Report("single-value attributes accepted on RUM, view, feature flags and a logger");
+ }
+
+ /// The session id, which answers through a completion block on the SDK's queue.
+ private static async Task ReadsCurrentSessionId()
+ {
+ var sessionId = await DDRUMMonitor.Shared().GetCurrentSessionIdAsync();
+
+ Report($"session {sessionId ?? "(none)"}");
+
+ // Non-null because RUM is enabled and sampled at 100 above. A null here 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 DDSessionReplayConfiguration(