From a110387c310d070e9b9efeae072015d6fdccd765 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Thu, 23 Jul 2026 14:09:50 +0300 Subject: [PATCH 1/4] add missing func --- Directory.Build.props | 2 +- README.md | 58 +++ shims/DatadogFlagsObjc/DatadogFlagsObjc.swift | 335 ++++++++++++++++++ shims/DatadogFlagsObjc/README.md | 47 +++ .../Additions/Attributes.cs | 20 +- .../Additions/DDLogger.Ergonomics.cs | 14 + .../Additions/DDRUMMonitor.Ergonomics.cs | 54 +++ .../Additions/OTSpan.Ergonomics.cs | 262 ++++++++++++++ 8 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 shims/DatadogFlagsObjc/DatadogFlagsObjc.swift create mode 100644 shims/DatadogFlagsObjc/README.md create mode 100644 src/DatadogNet.Trace.iOS/Additions/OTSpan.Ergonomics.cs 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/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; + } + } +} From d5cdb6604415e6f2491190359b9b87a78103fbbb Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Thu, 23 Jul 2026 14:17:57 +0300 Subject: [PATCH 2/4] release notes added --- docs/release-notes/3.14.0.2.md | 176 +++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/release-notes/3.14.0.2.md 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..198188e --- /dev/null +++ b/docs/release-notes/3.14.0.2.md @@ -0,0 +1,176 @@ +## 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.** No tests were added: the additions are C# on top of the same +native binaries, so nothing about the package shape changed. + +**The on-simulator suite in this repository does not yet cover the new members.** They were verified +end to end through [DatadogNet](https://github.com/sbokatuk/DatadogNet)'s device suite instead, which +drives them on a real simulator — `GetTraceId`, `GetSpanId`, `InjectHeaders`, `SetError`, `Log`, +`GetCurrentSessionIdAsync` and `ToNSObject` are all on its paths. Its trace check asserts that the id +is 32 lowercase hex characters, that its low half matches the `x-datadog-trace-id` header the SDK +itself emitted, and that it agrees with `traceparent` across all 128 bits — an independent second +opinion from the SDK's own W3C writer. 21/21 pass, and iOS and Android now return the same shape. + +Adding the equivalent checks here is worth doing and has not been done. + +## 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. From f3559ce145b1aa377a5f665f5ba3b1cef00f7b45 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Thu, 23 Jul 2026 14:35:31 +0300 Subject: [PATCH 3/4] Cover the new convenience API on device, and demonstrate Trace in the sample The simulator 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. Four checks close it, taking the suite from 17 to 21: - a span started, tagged, errored, logged and finished, with its ids 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 was asserted before, and why the wrong rendering shipped - injection in all three formats, cross-checked against traceparent across all 128 bits - derived independently of GetTraceId, so a second opinion rather than a restatement - and against the low 64 bits the Datadog header carries in decimal. Selecting no formats must produce nothing rather than throw - the single-value attribute overloads on RUM, view attributes, feature flags and a logger, with ToNSObject asserted to map null to NSNull: an explicitly empty attribute and an unset one are different things in a RUM event - the session id through GetCurrentSessionIdAsync, asserted non-null The harness gained async support for the last of those. Writing these turned up a papercut: Info(message, attributes) is generated without [NullAllowed] on the dictionary, so passing null throws rather than reaching the SDK, where Objective-C accepts nil. The single-argument overload and the ergonomic Log both handle it, so this is documented rather than changed. The sample enabled Trace and never demonstrated it. It gains a Trace section - one button traces an outgoing HttpRequestMessage end to end, another records a failed span through SetError and Log - and a button showing the session id. 21/21 on the simulator; 127/127 package tests. Co-Authored-By: Claude Opus 4.8 --- docs/release-notes/3.14.0.2.md | 47 +++-- samples/DatadogNet.iOS.Example/MainPage.xaml | 13 ++ .../DatadogNet.iOS.Example/MainPage.xaml.cs | 83 +++++++++ tests/DatadogNet.iOS.DeviceTests/Program.cs | 8 +- .../DatadogNet.iOS.DeviceTests/SmokeTests.cs | 173 +++++++++++++++++- 5 files changed, 308 insertions(+), 16 deletions(-) diff --git a/docs/release-notes/3.14.0.2.md b/docs/release-notes/3.14.0.2.md index 198188e..f3ff4c3 100644 --- a/docs/release-notes/3.14.0.2.md +++ b/docs/release-notes/3.14.0.2.md @@ -148,18 +148,41 @@ The standing cost is worth naming: the wrapper is ours, so when Datadog changes ## Tests -**127 package-layout tests pass.** No tests were added: the additions are C# on top of the same -native binaries, so nothing about the package shape changed. - -**The on-simulator suite in this repository does not yet cover the new members.** They were verified -end to end through [DatadogNet](https://github.com/sbokatuk/DatadogNet)'s device suite instead, which -drives them on a real simulator — `GetTraceId`, `GetSpanId`, `InjectHeaders`, `SetError`, `Log`, -`GetCurrentSessionIdAsync` and `ToNSObject` are all on its paths. Its trace check asserts that the id -is 32 lowercase hex characters, that its low half matches the `x-datadog-trace-id` header the SDK -itself emitted, and that it agrees with `traceparent` across all 128 bits — an independent second -opinion from the SDK's own W3C writer. 21/21 pass, and iOS and Android now return the same shape. - -Adding the equivalent checks here is worth doing and has not been done. +**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. + +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 --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 @@