Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
16102a9
feat!: Add per-execution runId and at-most-once event tracking
jsonbailey Apr 15, 2026
4dee48f
feat!: Add per-execution runId, at-most-once tracking, and cross-proc…
jsonbailey Apr 15, 2026
c60b42e
refactor: Replace bool tracking guards with nullable metric values
jsonbailey Apr 16, 2026
4b43399
fix: Align constructor param order with spec and omit empty variation…
jsonbailey Apr 16, 2026
bd2aa39
fix: Include track data in at-most-once warning log messages
jsonbailey Apr 16, 2026
2547784
refactor: Move resumption token decoding to LdAiConfigTracker.FromRes…
jsonbailey Apr 16, 2026
23f93d8
fix: Omit variationKey from track data when null or empty
jsonbailey Apr 16, 2026
6a17dd5
fix: CreateTracker always returns a tracker, even for disabled configs
jsonbailey Apr 17, 2026
465b3b1
refactor!: CompletionConfig returns LdAiConfig with CreateTracker fac…
jsonbailey Apr 17, 2026
f477ee4
refactor: Clarify already-tracked warnings as skips with remedy
jsonbailey May 13, 2026
2991347
docs: Clarify runId purpose and per-method tracker semantics
jsonbailey May 13, 2026
279a38e
docs: Avoid "run...runId" duplicate phrasing in resumption docs
jsonbailey May 13, 2026
2e0e593
docs: Clarify composite-tracker re-call wording
jsonbailey May 14, 2026
958464a
fix: Validate feedback before consuming at-most-once slot in TrackFee…
jsonbailey May 14, 2026
6ad7497
refactor: Convert LdAiConfig to a class and expose CreateTracker as a…
jsonbailey May 15, 2026
31c1293
fix: Validate usage before consuming at-most-once slot in TrackTokens
jsonbailey May 15, 2026
8643334
fix: Reject null payload in FromResumptionToken with ArgumentException
jsonbailey May 15, 2026
6b09826
Apply suggestions from code review
jsonbailey May 15, 2026
64e0a90
fix: Use Interlocked.CompareExchange to make tracker slot writes atomic
jsonbailey May 27, 2026
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
33 changes: 31 additions & 2 deletions pkgs/sdk/server-ai/src/Config/LdAiConfig.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LaunchDarkly.Sdk.Server.Ai.DataModel;
using LaunchDarkly.Sdk.Server.Ai.Interfaces;

namespace LaunchDarkly.Sdk.Server.Ai.Config;

/// <summary>
/// Represents an AI Config, which contains model parameters and prompt messages.
/// </summary>
public record LdAiConfig
public class LdAiConfig
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would have had auto-generated equals, hashcode, tostring before. So we should consider if we need any of them. (It would have also worked with with expressions, but nothing we can do about that.)

{

/// <summary>
Expand Down Expand Up @@ -217,7 +219,10 @@ public LdAiConfig Build()
/// </summary>
public readonly ModelProvider Provider;

internal LdAiConfig(bool enabled, IEnumerable<Message> messages, Meta meta, Model model, Provider provider)
private readonly Func<ILdAiConfigTracker> _trackerFactory;

internal LdAiConfig(bool enabled, IEnumerable<Message> messages, Meta meta, Model model, Provider provider,
Func<ILdAiConfigTracker> createTracker = null)
{
Model = new ModelConfiguration(model?.Name ?? "", model?.Parameters ?? new Dictionary<string, LdValue>(),
model?.Custom ?? new Dictionary<string, LdValue>());
Expand All @@ -226,7 +231,20 @@ internal LdAiConfig(bool enabled, IEnumerable<Message> messages, Meta meta, Mode
Version = meta?.Version ?? 1;
Enabled = enabled;
Provider = new ModelProvider(provider?.Name ?? "");
_trackerFactory = createTracker;
}

internal LdAiConfig(LdAiConfig source, Func<ILdAiConfigTracker> createTracker)
{
Model = source.Model;
Messages = source.Messages;
VariationKey = source.VariationKey;
Version = source.Version;
Enabled = source.Enabled;
Provider = source.Provider;
_trackerFactory = createTracker;
}

internal LdValue ToLdValue()
{
return LdValue.ObjectFrom(new Dictionary<string, LdValue>
Expand Down Expand Up @@ -278,6 +296,17 @@ internal LdValue ToLdValue()
/// </summary>
public int Version { get; }

/// <summary>
/// Returns a new tracker for a fresh AI run. Each call mints a new runId (a UUIDv4)
/// that LaunchDarkly uses to correlate the run's events in metrics views. Call this
/// once per AI run; metrics from different runIds cannot be combined.
/// </summary>
/// <remarks>
/// Returns null for configs created directly via the builder; only configs returned by
/// <see cref="LdAiClient.CompletionConfig"/> have a factory wired in.
/// </remarks>
public ILdAiConfigTracker CreateTracker() => _trackerFactory?.Invoke();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we don't compile on a version of .Net with nullable reference types I don't really like having the option to return null.


/// <summary>
/// Convenient helper that returns a disabled LdAiConfig.
/// </summary>
Expand Down
26 changes: 20 additions & 6 deletions pkgs/sdk/server-ai/src/Interfaces/ILdAiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ public interface ILdAiClient

/// <summary>
/// Retrieves a LaunchDarkly AI Completion Config identified by the given key. The return value
/// is an <see cref="ILdAiConfigTracker"/>, which makes the configuration available and
/// provides convenience methods for generating events related to model usage.
/// is an <see cref="LdAiConfig"/>, which makes the configuration available and provides
/// a <see cref="LdAiConfig.CreateTracker"/> factory for generating trackers capable of
/// emitting events related to model usage.
///
/// Any variables provided will be interpolated into the prompt's messages.
/// Additionally, the current LaunchDarkly context will be available as 'ldctx' within
Expand All @@ -25,8 +26,8 @@ public interface ILdAiClient
/// <param name="defaultValue">the default config, if unable to retrieve from LaunchDarkly. When not provided,
/// a disabled config is used as the fallback.</param>
/// <param name="variables">the list of variables used when interpolating the prompt</param>
/// <returns>an AI Completion Config tracker</returns>
public ILdAiConfigTracker CompletionConfig(string key, Context context, LdAiConfig defaultValue = null,
/// <returns>an AI Completion Config</returns>
public LdAiConfig CompletionConfig(string key, Context context, LdAiConfig defaultValue = null,
IReadOnlyDictionary<string, object> variables = null);

/// <summary>
Expand All @@ -36,8 +37,21 @@ public ILdAiConfigTracker CompletionConfig(string key, Context context, LdAiConf
/// <param name="context">the context</param>
/// <param name="defaultValue">the default config, if unable to retrieve from LaunchDarkly</param>
/// <param name="variables">the list of variables used when interpolating the prompt</param>
/// <returns>an AI Completion Config tracker</returns>
/// <returns>an AI Completion Config</returns>
[Obsolete("Use CompletionConfig instead.")]
public ILdAiConfigTracker Config(string key, Context context, LdAiConfig defaultValue = null,
public LdAiConfig Config(string key, Context context, LdAiConfig defaultValue = null,
IReadOnlyDictionary<string, object> variables = null);

/// <summary>
/// Reconstructs a tracker from a resumption token. This enables cross-process scenarios
/// such as deferred feedback, where a tracker's runId needs to be reused in a different
/// process or at a later time.
///
/// The reconstructed tracker will have empty model and provider names, as these are not
/// included in the resumption token.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a durable decision? Probably?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tried to cut down as much data as possible in the resumption token to be just the necessary for proper reporting since theses tokens could be sent client side.

/// </summary>
/// <param name="resumptionToken">the resumption token obtained from <see cref="ILdAiConfigTracker.ResumptionToken"/></param>
/// <param name="context">the context to use for track events</param>
/// <returns>a tracker associated with the original runId</returns>
public ILdAiConfigTracker CreateTracker(string resumptionToken, Context context);
}
40 changes: 35 additions & 5 deletions pkgs/sdk/server-ai/src/Interfaces/ILdAiConfigTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,36 @@
namespace LaunchDarkly.Sdk.Server.Ai.Interfaces;

/// <summary>
/// A utility capable of generating events related to a specific AI model
/// configuration.
/// Records metrics for a single AI run.
/// </summary>
/// <remarks>
/// All events emitted by a tracker share a runId (a UUIDv4) so LaunchDarkly can correlate
/// them. See individual track methods for their specific semantics.
/// Call <c>CreateTracker</c> on the AI Config to start a new run. A
/// <see cref="ResumptionToken"/> preserves the runId, so events emitted by a tracker
/// reconstructed in another process correlate with the original tracker's runId.
/// </remarks>
public interface ILdAiConfigTracker
{
/// <summary>
/// The AI model configuration retrieved from LaunchDarkly, or a default value if unable to retrieve.
/// A URL-safe Base64-encoded token that can be used to reconstruct this tracker in a different
/// process or at a later time. The token contains the runId, configKey, variationKey, and version.
///
/// Use <see cref="ILdAiClient.CreateTracker"/> to reconstruct a tracker from this token.
/// </summary>
public LdAiConfig Config { get; }
public string ResumptionToken { get; }

/// <summary>
/// A summary of the metrics tracked by this tracker.
/// </summary>
public MetricSummary Summary { get; }

/// <summary>
/// Tracks a duration metric related to this config. For example, if a particular operation
/// related to usage of the AI model takes 100ms, this can be tracked and made available in
/// LaunchDarkly.
/// </summary>
/// <remarks>Records at most once per Tracker; further calls are ignored.</remarks>
/// <param name="durationMs">the duration in milliseconds</param>
public void TrackDuration(float durationMs);

Expand All @@ -36,28 +51,38 @@ public interface ILdAiConfigTracker
/// <typeparam name="T">type of the task's result</typeparam>
/// <returns>the task</returns>
public Task<T> TrackDurationOfTask<T>(Task<T> task);

/// <summary>
/// Tracks the time it takes for the first token to be generated.
/// </summary>
/// <remarks>Records at most once per Tracker; further calls are ignored.</remarks>
/// <param name="timeToFirstTokenMs">the duration in milliseconds</param>
public void TrackTimeToFirstToken(float timeToFirstTokenMs);

/// <summary>
/// Tracks feedback (positive or negative) related to the output of the model.
/// </summary>
/// <remarks>Records at most once per Tracker; further calls are ignored.</remarks>
/// <param name="feedback">the feedback</param>
/// <exception cref="ArgumentOutOfRangeException">thrown if the feedback value is not <see cref="Feedback.Positive"/> or <see cref="Feedback.Negative"/></exception>
public void TrackFeedback(Feedback feedback);

/// <summary>
/// Tracks a generation event related to this config.
/// </summary>
/// <remarks>
/// Records at most once per Tracker. TrackSuccess and TrackError share state; only
/// one of the two can record per Tracker, and subsequent calls are ignored.
/// </remarks>
public void TrackSuccess();

/// <summary>
/// Tracks an unsuccessful generation event related to this config.
/// </summary>
/// <remarks>
/// Records at most once per Tracker. TrackSuccess and TrackError share state; only
/// one of the two can record per Tracker, and subsequent calls are ignored.
/// </remarks>
public void TrackError();

/// <summary>
Expand Down Expand Up @@ -92,13 +117,18 @@ public interface ILdAiConfigTracker
/// Task is automatically measured and recorded as the latency metric associated with this request.
///
/// </summary>
/// <remarks>
/// Subsequent calls re-run the task but emit only metrics not already recorded on this Tracker.
/// Call <c>CreateTracker</c> on the AI Config to start a new run.
/// </remarks>
/// <param name="request">a task representing the request</param>
/// <returns>the task</returns>
public Task<Response> TrackRequest(Task<Response> request);

/// <summary>
/// Tracks token usage related to this config.
/// </summary>
/// <remarks>Records at most once per Tracker; further calls are ignored.</remarks>
/// <param name="usage">the token usage</param>
public void TrackTokens(Usage usage);
}
38 changes: 30 additions & 8 deletions pkgs/sdk/server-ai/src/LdAiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public LdAiClient(ILaunchDarklyClient client)
private const string LdContextVariable = "ldctx";

/// <inheritdoc/>
public ILdAiConfigTracker CompletionConfig(string key, Context context, LdAiConfig defaultValue = null,
public LdAiConfig CompletionConfig(string key, Context context, LdAiConfig defaultValue = null,
IReadOnlyDictionary<string, object> variables = null)
{
_client.Track(TrackUsageCompletionConfig, context, LdValue.Of(key), 1);
Expand All @@ -72,16 +72,15 @@ public ILdAiConfigTracker CompletionConfig(string key, Context context, LdAiConf
/// This allows higher-level SDK entry methods to track their own usage events without
/// double-counting.
/// </summary>
private ILdAiConfigTracker Evaluate(string key, Context context, LdAiConfig defaultValue,
private LdAiConfig Evaluate(string key, Context context, LdAiConfig defaultValue,
IReadOnlyDictionary<string, object> variables = null)
{
var result = _client.JsonVariation(key, context, defaultValue.ToLdValue());

var parsed = ParseConfig(result, key);
if (parsed == null)
{
// ParseConfig already does logging.
return new LdAiConfigTracker(_client, key, defaultValue, context);
return WithTrackerFactory(defaultValue, key, context);
}

var mergedVariables = new Dictionary<string, object> { { LdContextVariable, GetAllAttributes(context) } };
Expand Down Expand Up @@ -113,12 +112,28 @@ private ILdAiConfigTracker Evaluate(string key, Context context, LdAiConfig defa
{
_logger.Error(
$"AI model config prompt has malformed message at index {i}: {ex.Message} (returning default config, which will not contain interpolated prompt messages)");
return new LdAiConfigTracker(_client, key, defaultValue, context);
return WithTrackerFactory(defaultValue, key, context);
}
}
}

return new LdAiConfigTracker(_client, key, new LdAiConfig(parsed.Meta?.Enabled ?? false, prompt, parsed.Meta, parsed.Model, parsed.Provider), context);
var config = new LdAiConfig(parsed.Meta?.Enabled ?? false, prompt, parsed.Meta, parsed.Model, parsed.Provider);
return WithTrackerFactory(config, key, context);
}

private LdAiConfig WithTrackerFactory(LdAiConfig config, string configKey, Context context)
{
var variationKey = config.VariationKey;
var version = config.Version;
var modelName = config.Model?.Name ?? "";
var providerName = config.Provider?.Name ?? "";
var client = _client;

return new LdAiConfig(config, () =>
{
var runId = Guid.NewGuid().ToString();
return new LdAiConfigTracker(client, runId, configKey, variationKey, version, context, modelName, providerName);
});
}

/// <summary>
Expand All @@ -128,15 +143,22 @@ private ILdAiConfigTracker Evaluate(string key, Context context, LdAiConfig defa
/// <param name="context">the context</param>
/// <param name="defaultValue">the default config, if unable to retrieve from LaunchDarkly</param>
/// <param name="variables">the list of variables used when interpolating the prompt</param>
/// <returns>an AI Completion Config tracker</returns>
/// <returns>an AI Completion Config</returns>
[Obsolete("Use CompletionConfig instead.")]
public ILdAiConfigTracker Config(string key, Context context, LdAiConfig defaultValue = null,
public LdAiConfig Config(string key, Context context, LdAiConfig defaultValue = null,
IReadOnlyDictionary<string, object> variables = null)
{
return CompletionConfig(key, context, defaultValue, variables);
}


/// <inheritdoc/>
public ILdAiConfigTracker CreateTracker(string resumptionToken, Context context)
{
return LdAiConfigTracker.FromResumptionToken(resumptionToken, _client, context);
}


private static IDictionary<string, object> AddSingleKindContextAttributes(Context context)
{
var attributes = new Dictionary<string, object>
Expand Down
Loading
Loading