From 232166bb5822ba676746105bd23d744fb7257f34 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 16:38:23 -0700 Subject: [PATCH 1/4] feat(maui): add SetAttributes and RemoveAttributes Co-authored-by: Cursor --- sdk/@launchdarkly/mobile-dotnet/README.md | 23 ++ .../example/LDObserve/ObservabilityBridge.kt | 13 ++ .../LDObserve.MaciOS.Binding/ApiDefinition.cs | 6 + .../Sources/ObservabilityBridge.swift | 15 ++ .../observability/bridge/LDNative.cs | 6 + .../bridge/NativeGlobalAttributesSink.cs | 47 +++++ .../observe/api/GlobalAttributesBuffer.cs | 107 ++++++++++ .../observability/observe/api/LDObserve.cs | 30 ++- .../GlobalAttributesBufferTests.cs | 196 ++++++++++++++++++ .../LDObservability.Tests.csproj | 24 +++ 10 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeGlobalAttributesSink.cs create mode 100644 sdk/@launchdarkly/mobile-dotnet/observability/observe/api/GlobalAttributesBuffer.cs create mode 100644 sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/GlobalAttributesBufferTests.cs create mode 100644 sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/LDObservability.Tests.csproj diff --git a/sdk/@launchdarkly/mobile-dotnet/README.md b/sdk/@launchdarkly/mobile-dotnet/README.md index 6daf1819c9..fa5b2c32ff 100644 --- a/sdk/@launchdarkly/mobile-dotnet/README.md +++ b/sdk/@launchdarkly/mobile-dotnet/README.md @@ -255,6 +255,29 @@ LDObserve.RecordError(exception); LDObserve.RecordError("Payment failed", "Timeout connecting to payment gateway."); ``` +#### Global Attributes + +Use `SetAttributes` to attach attributes to every span, log and metric recorded afterwards, including the native SDK's automatic telemetry: + +```csharp +LDObserve.SetAttributes(new Dictionary +{ + { "tenant.id", "acme" }, + { "app.build", 1234 } +}); + +// Later, stop sending some of them +LDObserve.RemoveAttributes(new[] { "app.build" }); +``` + +* Calls merge by key into one process-wide map; `RemoveAttributes` deletes keys, and unknown keys are ignored. +* Globals only fill keys a signal doesn't already carry: attributes passed to `RecordLog` or set on a span always win. +* Values use the same types as `RecordLog` attributes. `null` values are ignored, as are the SDK-managed `session.id` and `highlight.session_id` keys. +* The map is kept in memory only. It is not persisted and survives session rotation. +* Unlike the other `LDObserve` methods, these are not dropped before initialization: calls are buffered and applied as soon as the native SDK starts. + +Custom span and log processors are not available in the MAUI SDK, because telemetry is produced and exported by the native Android and iOS SDKs. + #### Traces > For in-depth examples covering nested spans, error handling, manual context propagation, and more, see the [Distributed Tracing Guide](./docs/distributed-tracing.md). diff --git a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt index ea37acc3e3..a4e1f772ab 100644 --- a/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt +++ b/sdk/@launchdarkly/mobile-dotnet/android/native/LDObserve/src/main/java/com/example/LDObserve/ObservabilityBridge.kt @@ -49,6 +49,19 @@ public class ObservabilityBridge( LDObserve.recordUpDownCounter(Metric(name = name, value = value)) } + /** + * Merges [attributes] into the process-wide global attributes. Values are converted with the + * same rules as log/span attributes from C#. Safe to call before [start]. + */ + public fun setAttributes(attributes: Map) { + LDObserve.setAttributes(AttributeConverter.convert(attributes)) + } + + /** Removes the global attributes with the given [keys]. Safe to call before [start]. */ + public fun removeAttributes(keys: List) { + LDObserve.removeAttributes(keys) + } + public fun start( app: Application, mobileKey: String, diff --git a/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs b/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs index e12dbf8630..855a09a5ff 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs +++ b/sdk/@launchdarkly/mobile-dotnet/macios/LDObserve.MaciOS.Binding/ApiDefinition.cs @@ -90,6 +90,12 @@ interface ObservabilityBridge [Export("getSessionReplayHookProxy")] [NullAllowed] SessionReplayHookProxy GetSessionReplayHookProxy(); + + [Static, Export("setAttributes:")] + void SetAttributes(NSDictionary attributes); + + [Static, Export("removeAttributes:")] + void RemoveAttributes(string[] keys); } [BaseType(typeof(NSObject))] diff --git a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift index 074c30dae8..030804c0c3 100644 --- a/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift +++ b/sdk/@launchdarkly/mobile-dotnet/macios/native/LDObserve/Sources/ObservabilityBridge.swift @@ -41,6 +41,21 @@ public final class ObservabilityBridge: NSObject { return LDReplay.shared.hookProxy } + /// Merges `attributes` into the process-wide global attributes, converting values the same + /// way as log/span attributes from C#. Safe to call before `start`. + @objc(setAttributes:) + public static func setAttributes(_ attributes: NSDictionary) { + let converted = AttributeConverter.convert((attributes as? [String: Any]) ?? [:]) + guard !converted.isEmpty else { return } + LDObserve.shared.setAttributes(converted) + } + + /// Removes the global attributes with the given `keys`. Safe to call before `start`. + @objc(removeAttributes:) + public static func removeAttributes(_ keys: [String]) { + LDObserve.shared.removeAttributes(keys) + } + @objc public func start(mobileKey: String, observability: ObjcObservabilityOptions, replay: ObjcSessionReplayOptions, diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs index 5f80e62d8b..2a5d78c67f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs @@ -53,10 +53,16 @@ internal static LDNative Start(string mobileKey, ObservabilityOptions observabil var bridgeObservabilityOptions = observability.ToNative(); var bridgeReplayOptions = replay.ToNative(); bridge.Start(app, mobileKey, bridgeObservabilityOptions, bridgeReplayOptions, observabilityVersion); + + // Replay LDObserve.SetAttributes/RemoveAttributes calls buffered before the native stack + // started; later calls go straight to the native process-wide store. + LDObserve.OnNativeStarted(new NativeGlobalAttributesSink(bridge)); #elif IOS var bridge = new ObservabilityBridgeClient(); ldNative.NativeVersion = bridge.Version(); bridge.Start(mobileKey, observability, replay, observabilityVersion); + + LDObserve.OnNativeStarted(new NativeGlobalAttributesSink()); #endif CurrentNativeVersion = ldNative.NativeVersion; diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeGlobalAttributesSink.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeGlobalAttributesSink.cs new file mode 100644 index 0000000000..061961ef0b --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/NativeGlobalAttributesSink.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace LaunchDarkly.Observability; + +#if ANDROID +/// +/// Forwards global attribute writes into the native SDK's process-wide store, converting values +/// the same way as attributes. +/// +internal sealed class NativeGlobalAttributesSink : IGlobalAttributesSink +{ + private readonly LDObserveAndroid.ObservabilityBridge _bridge; + + internal NativeGlobalAttributesSink(LDObserveAndroid.ObservabilityBridge bridge) + { + _bridge = bridge; + } + + public void SetAttributes(IDictionary attributes) + { + var map = DictionaryTypeConverters.ToJavaDictionary(attributes); + if (map is { Count: > 0 }) + _bridge.SetAttributes(map); + } + + public void RemoveAttributes(IReadOnlyCollection keys) + => _bridge.RemoveAttributes(keys.ToList()); +} +#elif IOS +/// +/// Forwards global attribute writes into the native SDK's process-wide store, converting values +/// the same way as attributes. +/// +internal sealed class NativeGlobalAttributesSink : IGlobalAttributesSink +{ + public void SetAttributes(IDictionary attributes) + { + var dict = DictionaryTypeConverters.ToNSDictionary(attributes); + if (dict is { Count: > 0 }) + LDObserveMaciOS.ObservabilityBridge.SetAttributes(dict); + } + + public void RemoveAttributes(IReadOnlyCollection keys) + => LDObserveMaciOS.ObservabilityBridge.RemoveAttributes(keys.ToArray()); +} +#endif diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/GlobalAttributesBuffer.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/GlobalAttributesBuffer.cs new file mode 100644 index 0000000000..0992f9895c --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/GlobalAttributesBuffer.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; + +namespace LaunchDarkly.Observability; + +/// +/// Receives global attribute writes once the native stack is running. The native SDK owns the +/// process-wide map; implementations only convert and forward. +/// +internal interface IGlobalAttributesSink +{ + void SetAttributes(IDictionary attributes); + void RemoveAttributes(IReadOnlyCollection keys); +} + +/// +/// Captures and calls +/// made before the native stack starts, then replays them when runs. After that, +/// writes are forwarded straight to the sink. Forwarding happens under the same lock as the replay so +/// a write racing with can't be overtaken by older buffered values. +/// +internal sealed class GlobalAttributesBuffer +{ + /// + /// Keys owned by native session management. Android ignores both, iOS only session.id, + /// so they are dropped here to behave the same on both platforms. + /// + internal static readonly IReadOnlySet ReservedKeys = new HashSet + { + "session.id", + "highlight.session_id", + }; + + private readonly object _lock = new(); + + private readonly Dictionary _pendingSet = new(); + private readonly HashSet _pendingRemove = new(); + private IGlobalAttributesSink? _sink; + + public void Set(IDictionary? attributes) + { + if (attributes is null) return; + if (attributes.Keys.Any(ReservedKeys.Contains)) + attributes = attributes.Where(kv => !ReservedKeys.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + if (attributes.Count == 0) return; + + lock (_lock) + { + if (_sink != null) + { + _sink.SetAttributes(attributes); + return; + } + + foreach (var (key, value) in attributes) + { + // Native drops null values rather than treating them as removals; match that here so + // a buffered null can't erase a value set earlier. + if (value is null) continue; + _pendingSet[key] = value; + _pendingRemove.Remove(key); + } + } + } + + public void Remove(IEnumerable? keys) + { + if (keys is null) return; + var list = keys.Where(k => k != null).Distinct().ToList(); + if (list.Count == 0) return; + + lock (_lock) + { + if (_sink != null) + { + _sink.RemoveAttributes(list); + return; + } + + foreach (var key in list) + { + _pendingSet.Remove(key); + _pendingRemove.Add(key); + } + } + } + + /// + /// Replays buffered writes into and routes all later writes to it. + /// Removals are replayed first; they never overlap with buffered sets. + /// + public void Bind(IGlobalAttributesSink sink) + { + lock (_lock) + { + if (_pendingRemove.Count > 0) + sink.RemoveAttributes(_pendingRemove.ToList()); + if (_pendingSet.Count > 0) + sink.SetAttributes(_pendingSet.ToDictionary(kv => kv.Key, kv => (object?)kv.Value)); + + _pendingRemove.Clear(); + _pendingSet.Clear(); + _sink = sink; + } + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs index f6daf97cf2..3e0b4c52bb 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/observe/api/LDObserve.cs @@ -9,12 +9,16 @@ namespace LaunchDarkly.Observability; /// /// Static facade over the . /// All methods are safe to call before initialization — they are no-ops -/// until is called. +/// until is called, except +/// and , which are buffered and applied once the +/// native stack starts. /// public static partial class LDObserve { private static readonly ActivitySource NoopActivitySource = new("noop"); + private static readonly GlobalAttributesBuffer GlobalAttributes = new(); + private static volatile ObservabilityService? _service; internal static void Initialize(ObservabilityService service) @@ -56,8 +60,32 @@ public static void Init(LdClient client, ObservabilityOptions observability, Ses /// public static string NativeVersion => LDNative.CurrentNativeVersion; + internal static void OnNativeStarted(IGlobalAttributesSink sink) => GlobalAttributes.Bind(sink); + // -------- Public API -------- + /// + /// Merges by key into the process-wide global attributes. + /// They are added to every span, log and metric recorded after this call, including the + /// native SDK's automatic telemetry. Globals only fill keys a signal doesn't already carry: + /// attributes passed to an individual call (or set on a span) always win. + /// Values are kept in memory only and survive session rotation. null values are + /// ignored (use ), as are the SDK-managed session.id + /// and highlight.session_id keys. + /// Unlike the other methods, calls made before initialization are not dropped: they are + /// buffered and applied as soon as the native stack starts. + /// MAUI does not support custom span/log processors, because telemetry is produced natively. + /// + public static void SetAttributes(IDictionary attributes) + => GlobalAttributes.Set(attributes); + + /// + /// Removes the global attributes with the given . Unknown keys are ignored. + /// Safe to call before initialization; see . + /// + public static void RemoveAttributes(IEnumerable keys) + => GlobalAttributes.Remove(keys); + /// /// Record a log with typed severity, routed through the native logger instance. /// When is provided, its trace/span IDs are used diff --git a/sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/GlobalAttributesBufferTests.cs b/sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/GlobalAttributesBufferTests.cs new file mode 100644 index 0000000000..fbf7d4bbe4 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/GlobalAttributesBufferTests.cs @@ -0,0 +1,196 @@ +using LaunchDarkly.Observability; +using Xunit; + +namespace LDObservability.Tests; + +public class GlobalAttributesBufferTests +{ + private sealed class RecordingSink : IGlobalAttributesSink + { + public List Calls { get; } = new(); + + public void SetAttributes(IDictionary attributes) + => Calls.Add(new Dictionary(attributes)); + + public void RemoveAttributes(IReadOnlyCollection keys) + => Calls.Add(keys.ToList()); + } + + private static Dictionary Attrs(params (string Key, object? Value)[] entries) + => entries.ToDictionary(e => e.Key, e => e.Value); + + [Fact] + public void BindWithNothingBufferedMakesNoCalls() + { + var buffer = new GlobalAttributesBuffer(); + var sink = new RecordingSink(); + + buffer.Bind(sink); + + Assert.Empty(sink.Calls); + } + + [Fact] + public void SetBeforeBindIsMergedByKeyAndReplayed() + { + var buffer = new GlobalAttributesBuffer(); + buffer.Set(Attrs(("a", 1), ("b", "x"))); + buffer.Set(Attrs(("b", "y"), ("c", true))); + + var sink = new RecordingSink(); + buffer.Bind(sink); + + var call = Assert.Single(sink.Calls); + var set = Assert.IsType>(call); + Assert.Equal(Attrs(("a", 1), ("b", "y"), ("c", true)), set); + } + + [Fact] + public void RemoveBeforeBindDropsBufferedValueAndIsReplayed() + { + var buffer = new GlobalAttributesBuffer(); + buffer.Set(Attrs(("a", 1), ("b", 2))); + buffer.Remove(new[] { "a", "z" }); + + var sink = new RecordingSink(); + buffer.Bind(sink); + + Assert.Equal(2, sink.Calls.Count); + var removed = Assert.IsType>(sink.Calls[0]); + Assert.Equal(new[] { "a", "z" }, removed.OrderBy(k => k)); + var set = Assert.IsType>(sink.Calls[1]); + Assert.Equal(Attrs(("b", 2)), set); + } + + [Fact] + public void SetAfterRemoveBeforeBindWins() + { + var buffer = new GlobalAttributesBuffer(); + buffer.Remove(new[] { "a" }); + buffer.Set(Attrs(("a", "back"))); + + var sink = new RecordingSink(); + buffer.Bind(sink); + + var call = Assert.Single(sink.Calls); + Assert.Equal(Attrs(("a", "back")), Assert.IsType>(call)); + } + + [Fact] + public void NullValuesBeforeBindDoNotEraseEarlierValues() + { + var buffer = new GlobalAttributesBuffer(); + buffer.Set(Attrs(("a", 1))); + buffer.Set(Attrs(("a", null), ("b", null))); + + var sink = new RecordingSink(); + buffer.Bind(sink); + + var call = Assert.Single(sink.Calls); + Assert.Equal(Attrs(("a", 1)), Assert.IsType>(call)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReservedSessionKeysAreIgnored(bool bound) + { + var buffer = new GlobalAttributesBuffer(); + var sink = new RecordingSink(); + if (bound) buffer.Bind(sink); + + buffer.Set(Attrs(("session.id", "spoof"), ("highlight.session_id", "spoof"), ("k", "v"))); + buffer.Set(Attrs(("session.id", "only-reserved"))); + if (!bound) buffer.Bind(sink); + + var call = Assert.Single(sink.Calls); + Assert.Equal(Attrs(("k", "v")), Assert.IsType>(call)); + } + + [Fact] + public void WritesAfterBindAreForwardedDirectly() + { + var buffer = new GlobalAttributesBuffer(); + var sink = new RecordingSink(); + buffer.Bind(sink); + + buffer.Set(Attrs(("a", 1))); + buffer.Remove(new[] { "a", "a" }); + + Assert.Equal(2, sink.Calls.Count); + Assert.Equal(Attrs(("a", 1)), Assert.IsType>(sink.Calls[0])); + Assert.Equal(new[] { "a" }, Assert.IsType>(sink.Calls[1])); + } + + [Fact] + public void BufferedStateIsClearedAfterBind() + { + var buffer = new GlobalAttributesBuffer(); + buffer.Set(Attrs(("a", 1))); + buffer.Bind(new RecordingSink()); + + var second = new RecordingSink(); + buffer.Bind(second); + + Assert.Empty(second.Calls); + } + + [Fact] + public void NullAndEmptyInputsAreNoOps() + { + var buffer = new GlobalAttributesBuffer(); + var sink = new RecordingSink(); + buffer.Bind(sink); + + buffer.Set(null); + buffer.Set(new Dictionary()); + buffer.Remove(null); + buffer.Remove(Array.Empty()); + + Assert.Empty(sink.Calls); + } + + [Fact] + public void BufferedSetIsSnapshotOfCallerDictionary() + { + var buffer = new GlobalAttributesBuffer(); + var attrs = Attrs(("a", 1)); + buffer.Set(attrs); + attrs["a"] = 2; + attrs["b"] = 3; + + var sink = new RecordingSink(); + buffer.Bind(sink); + + var call = Assert.Single(sink.Calls); + Assert.Equal(Attrs(("a", 1)), Assert.IsType>(call)); + } + + [Fact] + public async Task ConcurrentWritesDuringBindAreNotLostOrOverwrittenByReplay() + { + var buffer = new GlobalAttributesBuffer(); + var sink = new RecordingSink(); + buffer.Set(Attrs(("k", -1))); + + var writers = Enumerable.Range(0, 8).Select(t => Task.Run(() => + { + for (var i = 0; i < 500; i++) + buffer.Set(Attrs(($"t{t}", i), ("k", i))); + })).ToArray(); + buffer.Bind(sink); + await Task.WhenAll(writers); + + // Fold the recorded calls the way the native store would. Sink calls are serialized by the + // buffer's lock, so the recorded order is the order native would see. + var state = new Dictionary(); + foreach (var call in sink.Calls) + { + if (call is Dictionary set) + foreach (var (key, value) in set) state[key] = value; + } + + for (var t = 0; t < 8; t++) + Assert.Equal(499, state[$"t{t}"]); + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/LDObservability.Tests.csproj b/sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/LDObservability.Tests.csproj new file mode 100644 index 0000000000..bfb14e58c4 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/tests/LDObservability.Tests/LDObservability.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + + + + + + From d21861d8d1b3b7adcb0b539391878c47f94679cd Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 16:39:42 -0700 Subject: [PATCH 2/4] fix(maui): replay buffered global attributes before native start Co-authored-by: Cursor --- .../mobile-dotnet/observability/bridge/LDNative.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs index 2a5d78c67f..d68a3dfb7f 100644 --- a/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs +++ b/sdk/@launchdarkly/mobile-dotnet/observability/bridge/LDNative.cs @@ -52,17 +52,17 @@ internal static LDNative Start(string mobileKey, ObservabilityOptions observabil ldNative.NativeVersion = bridge.Version(); var bridgeObservabilityOptions = observability.ToNative(); var bridgeReplayOptions = replay.ToNative(); - bridge.Start(app, mobileKey, bridgeObservabilityOptions, bridgeReplayOptions, observabilityVersion); - // Replay LDObserve.SetAttributes/RemoveAttributes calls buffered before the native stack - // started; later calls go straight to the native process-wide store. + // Replay buffered LDObserve.SetAttributes/RemoveAttributes calls before Start: the native + // store accepts writes before init, so telemetry recorded during startup carries them too. LDObserve.OnNativeStarted(new NativeGlobalAttributesSink(bridge)); + bridge.Start(app, mobileKey, bridgeObservabilityOptions, bridgeReplayOptions, observabilityVersion); #elif IOS var bridge = new ObservabilityBridgeClient(); ldNative.NativeVersion = bridge.Version(); - bridge.Start(mobileKey, observability, replay, observabilityVersion); LDObserve.OnNativeStarted(new NativeGlobalAttributesSink()); + bridge.Start(mobileKey, observability, replay, observabilityVersion); #endif CurrentNativeVersion = ldNative.NativeVersion; From 85dbaf980da7914c1c361d0e4babd3f7fb38927c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 17:17:01 -0700 Subject: [PATCH 3/4] docs(maui): show SetAttributes in the recording quick reference Co-authored-by: Cursor --- sdk/@launchdarkly/mobile-dotnet/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/@launchdarkly/mobile-dotnet/README.md b/sdk/@launchdarkly/mobile-dotnet/README.md index fa5b2c32ff..cbdc9b7ed9 100644 --- a/sdk/@launchdarkly/mobile-dotnet/README.md +++ b/sdk/@launchdarkly/mobile-dotnet/README.md @@ -191,6 +191,10 @@ LDObserve.RecordError(exception); // Or record errors from a message string LDObserve.RecordError("Something went wrong", "The underlying cause of the error."); + +// Attach attributes to everything recorded from now on (see Global Attributes below) +LDObserve.SetAttributes(new Dictionary { { "utm_campaign", "spring_promo" } }); +LDObserve.RemoveAttributes(new[] { "utm_campaign" }); ``` #### Metrics From a3c9fda59bc69020c4dd64c7c9c5be98bca4307b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 17:17:59 -0700 Subject: [PATCH 4/4] chore(maui): add global attributes repro to sample app Co-authored-by: Cursor --- .../sample/GlobalAttributesRepro.cs | 31 +++++++++++++++++++ .../mobile-dotnet/sample/MainPage.xaml.cs | 7 ++++- .../mobile-dotnet/sample/MauiProgram.cs | 2 ++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 sdk/@launchdarkly/mobile-dotnet/sample/GlobalAttributesRepro.cs diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/GlobalAttributesRepro.cs b/sdk/@launchdarkly/mobile-dotnet/sample/GlobalAttributesRepro.cs new file mode 100644 index 0000000000..8c74b3c141 --- /dev/null +++ b/sdk/@launchdarkly/mobile-dotnet/sample/GlobalAttributesRepro.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Observability; + +namespace MauiSample9; + +/// +/// Repro #1: global attributes (e.g. UTM params) on all telemetry. +/// Set once at startup before the SDK starts; the existing span/log buttons on +/// print what to look for on each exported row. +/// +public static class GlobalAttributesRepro +{ + public static readonly Dictionary GlobalAttributes = new() + { + ["utm_campaign"] = "spring_promo", + ["utm_source"] = "email", + }; + + public const string ExpectGlobal = "[#1] expect utm_campaign=spring_promo on this row"; + public const string ExpectPerCallWins = "[#1] expect utm_source=per_call (per-call wins over global)"; + + /// + /// Must run synchronously before LDObserve.Init to exercise the set-before-init buffer. + /// + public static void ApplyGlobalAttributes() + { + LDObserve.SetAttributes(GlobalAttributes); + Console.WriteLine("[#1] global attributes set before init: utm_campaign=spring_promo, utm_source=email"); + } +} diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/MainPage.xaml.cs b/sdk/@launchdarkly/mobile-dotnet/sample/MainPage.xaml.cs index ff62d4992b..fba7ce8386 100644 --- a/sdk/@launchdarkly/mobile-dotnet/sample/MainPage.xaml.cs +++ b/sdk/@launchdarkly/mobile-dotnet/sample/MainPage.xaml.cs @@ -174,10 +174,13 @@ private void OnTriggerLogClicked(object? sender, EventArgs e) { "nested-integer", 420 }, { "nested-double", 3.14159 }, { "nested-array", new double[] { 3.14159, 6.28318 } }} - } + }, + { "utm_source", "per_call" } } ); Console.WriteLine("Log triggered"); + Console.WriteLine(GlobalAttributesRepro.ExpectGlobal); + Console.WriteLine(GlobalAttributesRepro.ExpectPerCallWins); } private async void OnTriggerLogWithContextClicked(object? sender, EventArgs e) @@ -252,6 +255,7 @@ private void OnTriggerSequentialSpansClicked(object? sender, EventArgs e) } Console.WriteLine("Sequential independent spans triggered"); + Console.WriteLine(GlobalAttributesRepro.ExpectGlobal); } private void OnSendCustomSpanClicked(object? sender, EventArgs e) @@ -265,6 +269,7 @@ private void OnSendCustomSpanClicked(object? sender, EventArgs e) span.AddEvent("retry.started"); span.AddEvent("download.completed"); Console.WriteLine($"Custom span sent: {spanName}"); + Console.WriteLine(GlobalAttributesRepro.ExpectGlobal); } } diff --git a/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs b/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs index 1d2177cdaf..2657baa0d1 100644 --- a/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs +++ b/sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs @@ -115,6 +115,8 @@ public static MauiApp CreateMauiApp() ) ); + GlobalAttributesRepro.ApplyGlobalAttributes(); + _ = Task.Run(async () => { try