Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions sdk/@launchdarkly/mobile-dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?> { { "utm_campaign", "spring_promo" } });
LDObserve.RemoveAttributes(new[] { "utm_campaign" });
```

#### Metrics
Expand Down Expand Up @@ -255,6 +259,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<string, object?>
{
{ "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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?>) {
LDObserve.setAttributes(AttributeConverter.convert(attributes))
}

/** Removes the global attributes with the given [keys]. Safe to call before [start]. */
public fun removeAttributes(keys: List<String>) {
LDObserve.removeAttributes(keys)
}

public fun start(
app: Application,
mobileKey: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ internal static LDNative Start(string mobileKey, ObservabilityOptions observabil
ldNative.NativeVersion = bridge.Version();
var bridgeObservabilityOptions = observability.ToNative();
var bridgeReplayOptions = replay.ToNative();

// 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();

LDObserve.OnNativeStarted(new NativeGlobalAttributesSink());
bridge.Start(mobileKey, observability, replay, observabilityVersion);
#endif

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;

namespace LaunchDarkly.Observability;

#if ANDROID
/// <summary>
/// Forwards global attribute writes into the native SDK's process-wide store, converting values
/// the same way as <see cref="LDObserve.RecordLog"/> attributes.
/// </summary>
internal sealed class NativeGlobalAttributesSink : IGlobalAttributesSink
{
private readonly LDObserveAndroid.ObservabilityBridge _bridge;

internal NativeGlobalAttributesSink(LDObserveAndroid.ObservabilityBridge bridge)
{
_bridge = bridge;
}

public void SetAttributes(IDictionary<string, object?> attributes)
{
var map = DictionaryTypeConverters.ToJavaDictionary(attributes);
if (map is { Count: > 0 })
_bridge.SetAttributes(map);
}

public void RemoveAttributes(IReadOnlyCollection<string> keys)
=> _bridge.RemoveAttributes(keys.ToList());
}
#elif IOS
/// <summary>
/// Forwards global attribute writes into the native SDK's process-wide store, converting values
/// the same way as <see cref="LDObserve.RecordLog"/> attributes.
/// </summary>
internal sealed class NativeGlobalAttributesSink : IGlobalAttributesSink
{
public void SetAttributes(IDictionary<string, object?> attributes)
{
var dict = DictionaryTypeConverters.ToNSDictionary(attributes);
if (dict is { Count: > 0 })
LDObserveMaciOS.ObservabilityBridge.SetAttributes(dict);
}

public void RemoveAttributes(IReadOnlyCollection<string> keys)
=> LDObserveMaciOS.ObservabilityBridge.RemoveAttributes(keys.ToArray());
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using System.Linq;

namespace LaunchDarkly.Observability;

/// <summary>
/// Receives global attribute writes once the native stack is running. The native SDK owns the
/// process-wide map; implementations only convert and forward.
/// </summary>
internal interface IGlobalAttributesSink
{
void SetAttributes(IDictionary<string, object?> attributes);
void RemoveAttributes(IReadOnlyCollection<string> keys);
}

/// <summary>
/// Captures <see cref="LDObserve.SetAttributes"/> and <see cref="LDObserve.RemoveAttributes"/> calls
/// made before the native stack starts, then replays them when <see cref="Bind"/> runs. After that,
/// writes are forwarded straight to the sink. Forwarding happens under the same lock as the replay so
/// a write racing with <see cref="Bind"/> can't be overtaken by older buffered values.
/// </summary>
internal sealed class GlobalAttributesBuffer
{
/// <summary>
/// Keys owned by native session management. Android ignores both, iOS only <c>session.id</c>,
/// so they are dropped here to behave the same on both platforms.
/// </summary>
internal static readonly IReadOnlySet<string> ReservedKeys = new HashSet<string>
{
"session.id",
"highlight.session_id",
};

private readonly object _lock = new();

private readonly Dictionary<string, object> _pendingSet = new();
private readonly HashSet<string> _pendingRemove = new();
private IGlobalAttributesSink? _sink;

public void Set(IDictionary<string, object?>? 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<string>? 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);
}
}
}

/// <summary>
/// Replays buffered writes into <paramref name="sink"/> and routes all later writes to it.
/// Removals are replayed first; they never overlap with buffered sets.
/// </summary>
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ namespace LaunchDarkly.Observability;
/// <summary>
/// Static facade over the <see cref="ObservabilityService"/>.
/// All methods are safe to call before initialization — they are no-ops
/// until <see cref="Initialize"/> is called.
/// until <see cref="Initialize"/> is called, except <see cref="SetAttributes"/>
/// and <see cref="RemoveAttributes"/>, which are buffered and applied once the
/// native stack starts.
/// </summary>
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)
Expand Down Expand Up @@ -56,8 +60,32 @@ public static void Init(LdClient client, ObservabilityOptions observability, Ses
/// </summary>
public static string NativeVersion => LDNative.CurrentNativeVersion;

internal static void OnNativeStarted(IGlobalAttributesSink sink) => GlobalAttributes.Bind(sink);

// -------- Public API --------

/// <summary>
/// Merges <paramref name="attributes"/> 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. <c>null</c> values are
/// ignored (use <see cref="RemoveAttributes"/>), as are the SDK-managed <c>session.id</c>
/// and <c>highlight.session_id</c> 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.
/// </summary>
public static void SetAttributes(IDictionary<string, object?> attributes)
=> GlobalAttributes.Set(attributes);

/// <summary>
/// Removes the global attributes with the given <paramref name="keys"/>. Unknown keys are ignored.
/// Safe to call before initialization; see <see cref="SetAttributes"/>.
/// </summary>
public static void RemoveAttributes(IEnumerable<string> keys)
=> GlobalAttributes.Remove(keys);

/// <summary>
/// Record a log with typed severity, routed through the native logger instance.
/// When <paramref name="spanContext"/> is provided, its trace/span IDs are used
Expand Down
31 changes: 31 additions & 0 deletions sdk/@launchdarkly/mobile-dotnet/sample/GlobalAttributesRepro.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using LaunchDarkly.Observability;

namespace MauiSample9;

/// <summary>
/// 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
/// <see cref="MainPage"/> print what to look for on each exported row.
/// </summary>
public static class GlobalAttributesRepro
{
public static readonly Dictionary<string, object?> 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)";

/// <summary>
/// Must run synchronously before <c>LDObserve.Init</c> to exercise the set-before-init buffer.
/// </summary>
public static void ApplyGlobalAttributes()
{
LDObserve.SetAttributes(GlobalAttributes);
Console.WriteLine("[#1] global attributes set before init: utm_campaign=spring_promo, utm_source=email");
}
}
7 changes: 6 additions & 1 deletion sdk/@launchdarkly/mobile-dotnet/sample/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
}

Expand Down
2 changes: 2 additions & 0 deletions sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ public static MauiApp CreateMauiApp()
)
);

GlobalAttributesRepro.ApplyGlobalAttributes();

_ = Task.Run(async () =>
{
try
Expand Down
Loading
Loading