Skip to content
2 changes: 1 addition & 1 deletion pkgs/sdk/server-ai/src/Interfaces/ILdAiConfigTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public interface ILdAiConfigTracker
/// </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);
public void TrackDuration(double durationMs);

/// <summary>
/// Wraps a callable operation, measures its wall-clock duration, and records the result via
Expand Down
67 changes: 54 additions & 13 deletions pkgs/sdk/server-ai/src/LdAiConfigTracker.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -47,6 +49,10 @@ public class LdAiConfigTracker : ILdAiConfigTracker
private StrongBox<Feedback> _feedback;
private StrongBox<bool> _trackedSuccess; // true = success, false = error

// Accumulates tool keys from TrackToolCall calls. Guarded by a lock since tool calls
// have no at-most-once semantics and may be called concurrently.
private readonly List<string> _toolCallKeys = new();

// Lazy<T> caches the encoded token so repeated reads avoid re-encoding the immutable
// payload. All resumption-token inputs are readonly for a tracker's lifetime.
private readonly Lazy<string> _resumptionToken;
Expand Down Expand Up @@ -134,16 +140,29 @@ private string BuildResumptionToken()
}

/// <inheritdoc/>
public MetricSummary Summary => new MetricSummary(
_durationMs?.Value,
_feedback?.Value,
_tokens?.Value,
_trackedSuccess?.Value,
_timeToFirstTokenMs?.Value
);
public MetricSummary Summary
{
get
{
IReadOnlyList<string> toolCalls;
lock (_toolCallKeys)
{
toolCalls = _toolCallKeys.Count > 0 ? _toolCallKeys.ToImmutableArray() : null;
}
return new MetricSummary(
_durationMs?.Value,
_feedback?.Value,
_tokens?.Value,
_trackedSuccess?.Value,
_timeToFirstTokenMs?.Value,
toolCalls,
ResumptionToken
);
}
}

/// <inheritdoc/>
public void TrackDuration(float durationMs)
public void TrackDuration(double durationMs)
{
if (Interlocked.CompareExchange(ref _durationMs,
new StrongBox<double>(durationMs), null) != null)
Expand All @@ -166,7 +185,7 @@ public async Task<T> TrackDurationOf<T>(Func<Task<T>> operation)
finally
{
sw.Stop();
TrackDuration((float)sw.Elapsed.TotalMilliseconds);
TrackDuration(sw.Elapsed.TotalMilliseconds);
}
}

Expand Down Expand Up @@ -255,16 +274,32 @@ public async Task<T> TrackMetricsOf<T>(Func<T, AiMetrics> metricsExtractor, Func
}
catch (Exception)
{
sw.Stop();
TrackDuration(sw.Elapsed.TotalMilliseconds);
TrackError();
throw;
}
finally

// Capture elapsed immediately so a slow extractor doesn't inflate the metric.
sw.Stop();
var operationElapsedMs = sw.Elapsed.TotalMilliseconds;

// Extractor failure: track duration but NOT error — the AI operation itself succeeded.
// Matches Java's LDAIConfigTrackerImpl.trackMetricsOf behavior.
AiMetrics metrics;
try
{
sw.Stop();
TrackDuration((float)sw.Elapsed.TotalMilliseconds);
metrics = metricsExtractor(result);
}
catch (Exception)
{
TrackDuration(operationElapsedMs);
throw;
}

var metrics = metricsExtractor(result);
// Honor an explicit duration override from the caller; fall back to the measured value.
TrackDuration(metrics.DurationMs ?? operationElapsedMs);

if (metrics.Success)
{
TrackSuccess();
Expand All @@ -279,6 +314,11 @@ public async Task<T> TrackMetricsOf<T>(Func<T, AiMetrics> metricsExtractor, Func
TrackTokens(metrics.Tokens.Value);
}

if (metrics.ToolCalls?.Count > 0)
{
TrackToolCalls(metrics.ToolCalls);
}

return result;
}

Expand Down Expand Up @@ -359,6 +399,7 @@ public void TrackJudgeResult(JudgeResult result)
/// <inheritdoc/>
public void TrackToolCall(string toolKey)
{
lock (_toolCallKeys) { _toolCallKeys.Add(toolKey); }
var data = MergeTrackData("toolKey", LdValue.Of(toolKey));
_client.Track(ToolCall, _context, data, 1);
}
Expand Down
20 changes: 19 additions & 1 deletion pkgs/sdk/server-ai/src/Tracking/AiMetrics.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace LaunchDarkly.Sdk.Server.Ai.Tracking;

/// <summary>
Expand All @@ -16,14 +18,30 @@ public sealed record AiMetrics
/// </summary>
public readonly Usage? Tokens;

/// <summary>
/// Optional list of tool keys invoked during the operation.
/// </summary>
public readonly IReadOnlyList<string> ToolCalls;

/// <summary>
/// Optional duration override in milliseconds. When set, this value is used instead
/// of the stopwatch measurement in <c>TrackMetricsOf</c>.
/// </summary>
public readonly double? DurationMs;
Comment thread
jsonbailey marked this conversation as resolved.

/// <summary>
/// Constructs an <see cref="AiMetrics"/> value.
/// </summary>
/// <param name="success">whether the operation succeeded</param>
/// <param name="tokens">optional token usage</param>
public AiMetrics(bool success, Usage? tokens = null)
/// <param name="toolCalls">optional list of tool keys invoked</param>
/// <param name="durationMs">optional duration override in milliseconds</param>
public AiMetrics(bool success, Usage? tokens = null,
IReadOnlyList<string> toolCalls = null, double? durationMs = null)
{
Success = success;
Tokens = tokens;
ToolCalls = toolCalls;
DurationMs = durationMs;
}
}
8 changes: 7 additions & 1 deletion pkgs/sdk/server-ai/src/Tracking/MetricSummary.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace LaunchDarkly.Sdk.Server.Ai.Tracking;

/// <summary>
Expand All @@ -8,10 +10,14 @@ namespace LaunchDarkly.Sdk.Server.Ai.Tracking;
/// <param name="Tokens">the token usage</param>
/// <param name="Success">whether the generation was successful</param>
/// <param name="TimeToFirstTokenMs">the time to first token in milliseconds</param>
/// <param name="ToolCalls">the tool keys invoked during the operation, or null if none were tracked</param>
/// <param name="ResumptionToken">the resumption token for cross-process continuation</param>
public record struct MetricSummary(
double? DurationMs,
Feedback? Feedback,
Usage? Tokens,
bool? Success,
double? TimeToFirstTokenMs
double? TimeToFirstTokenMs,
IReadOnlyList<string> ToolCalls = null,
string ResumptionToken = null
);
Loading
Loading