Skip to content

Commit 2ca9fc8

Browse files
ctawiahcursoragent
andcommitted
feat: add thread-safe AIConfigTracker, metrics & resumption tokens (AIC-2664)
Implements the AITRACK surface on LDAIConfigTracker: per-run UUID runId and track data, the full set of track methods (duration, time-to-first-token, success/error, feedback, tokens, tool calls, judge result) plus trackDurationOf and trackMetricsOf wrappers, and a metric summary. Record-once metrics use atomic claim-before-emit guards so exactly one event is produced under concurrency; tool-call and judge-result events are not once-only. Negative durations and token counts are clamped, and a null judge score is distinct from a legitimate 0.0. Resumption tokens are URL-safe Base64 of canonical JSON in fixed key order (runId, configKey, variationKey, version, graphKey); variationKey is always emitted for cross-SDK parity and modelName/providerName are not carried. Decode strictly type-validates each field and rejects malformed/oversized tokens. LDAIClientImpl now wires createTracker() on the config types to the real tracker and adds createTracker(token, context) to reconstruct a run across processes. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dfc1386 commit 2ca9fc8

10 files changed

Lines changed: 2129 additions & 38 deletions

File tree

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,19 @@ AIJudgeConfig judgeConfig(
8181
LDContext context,
8282
AIJudgeConfigDefault defaultValue,
8383
Map<String, Object> variables);
84+
85+
/**
86+
* Reconstructs a tracker from a resumption token produced by
87+
* {@link LDAIConfigTracker#getResumptionToken()}.
88+
* <p>
89+
* The reconstructed tracker shares the original run's {@code runId}, so events it emits (for
90+
* example deferred user feedback recorded in another process) correlate with the original AI run.
91+
* Model and provider names are not carried in the token and are reported as empty strings.
92+
*
93+
* @param resumptionToken the token to reconstruct from
94+
* @param context the context the tracker's events will be attributed to
95+
* @return a tracker sharing the original run's identity
96+
* @throws IllegalArgumentException if the token is malformed
97+
*/
98+
LDAIConfigTracker createTracker(String resumptionToken, LDContext context);
8499
}

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,23 @@
88
import com.launchdarkly.sdk.LDContext;
99
import com.launchdarkly.sdk.LDValue;
1010
import com.launchdarkly.sdk.LDValueType;
11-
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode;
1211
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message;
12+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode;
13+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model;
14+
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider;
1315
import com.launchdarkly.sdk.server.ai.internal.AIConfigFlagValue;
1416
import com.launchdarkly.sdk.server.ai.internal.AIConfigParser;
1517
import com.launchdarkly.sdk.server.ai.internal.AISdkInfo;
1618
import com.launchdarkly.sdk.server.ai.internal.Interpolator;
17-
import com.launchdarkly.sdk.server.ai.internal.NoOpAIConfigTracker;
19+
import com.launchdarkly.sdk.server.ai.internal.LDAIConfigTrackerImpl;
1820
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
1921

2022
import java.util.ArrayList;
2123
import java.util.LinkedHashMap;
2224
import java.util.List;
2325
import java.util.Map;
2426
import java.util.Objects;
27+
import java.util.UUID;
2528
import java.util.function.Supplier;
2629

2730
/**
@@ -51,9 +54,6 @@ public final class LDAIClientImpl implements LDAIClient {
5154
.anonymous(true)
5255
.build();
5356

54-
// Tracking is implemented in a later step; until then every config hands out the no-op tracker.
55-
private static final Supplier<LDAIConfigTracker> TRACKER_FACTORY = () -> NoOpAIConfigTracker.INSTANCE;
56-
5757
private final LDClientInterface client;
5858
private final LDLogger logger;
5959
private final Interpolator interpolator;
@@ -140,6 +140,11 @@ public AIJudgeConfig judgeConfig(
140140
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables);
141141
}
142142

143+
@Override
144+
public LDAIConfigTracker createTracker(String resumptionToken, LDContext context) {
145+
return LDAIConfigTrackerImpl.fromResumptionToken(resumptionToken, client, context, logger);
146+
}
147+
143148
private AIAgentConfig evaluateAgent(
144149
String key, LDContext context, AIAgentConfigDefault defaultValue, Map<String, Object> variables) {
145150
AIAgentConfigDefault effectiveDefault =
@@ -186,6 +191,8 @@ private AIConfig buildConfig(
186191
AIConfigFlagValue parsed,
187192
LDContext context,
188193
Map<String, Object> variables) {
194+
Supplier<LDAIConfigTracker> trackerFactory = trackerFactory(
195+
key, parsed.getVariationKey(), parsed.getVersion(), parsed.getModel(), parsed.getProvider(), context);
189196
switch (mode) {
190197
case AGENT:
191198
return new AIAgentConfig(
@@ -196,7 +203,7 @@ private AIConfig buildConfig(
196203
interpolate(parsed.getInstructions(), variables, context),
197204
parsed.getJudgeConfiguration(),
198205
parsed.getTools(),
199-
TRACKER_FACTORY);
206+
trackerFactory);
200207
case JUDGE:
201208
return new AIJudgeConfig(
202209
key,
@@ -205,7 +212,7 @@ private AIConfig buildConfig(
205212
parsed.getProvider(),
206213
interpolateMessages(parsed.getMessages(), variables, context),
207214
parsed.getEvaluationMetricKey(),
208-
TRACKER_FACTORY);
215+
trackerFactory);
209216
case COMPLETION:
210217
default:
211218
return new AICompletionConfig(
@@ -216,7 +223,7 @@ private AIConfig buildConfig(
216223
interpolateMessages(parsed.getMessages(), variables, context),
217224
parsed.getJudgeConfiguration(),
218225
parsed.getTools(),
219-
TRACKER_FACTORY);
226+
trackerFactory);
220227
}
221228
}
222229

@@ -241,7 +248,7 @@ private AIConfig buildConfigFromDefault(
241248
interpolate(agent.getInstructions(), variables, context),
242249
agent.getJudgeConfiguration(),
243250
agent.getTools(),
244-
TRACKER_FACTORY);
251+
trackerFactory(key, null, null, agent.getModel(), agent.getProvider(), context));
245252
}
246253
case JUDGE: {
247254
AIJudgeConfigDefault judge = (AIJudgeConfigDefault) defaultValue;
@@ -252,7 +259,7 @@ private AIConfig buildConfigFromDefault(
252259
judge.getProvider(),
253260
interpolateMessages(judge.getMessages(), variables, context),
254261
judge.getEvaluationMetricKey(),
255-
TRACKER_FACTORY);
262+
trackerFactory(key, null, null, judge.getModel(), judge.getProvider(), context));
256263
}
257264
case COMPLETION:
258265
default: {
@@ -265,11 +272,25 @@ private AIConfig buildConfigFromDefault(
265272
interpolateMessages(completion.getMessages(), variables, context),
266273
completion.getJudgeConfiguration(),
267274
completion.getTools(),
268-
TRACKER_FACTORY);
275+
trackerFactory(key, null, null, completion.getModel(), completion.getProvider(), context));
269276
}
270277
}
271278
}
272279

280+
/**
281+
* Builds a factory that produces a fresh tracker, with a new {@code runId}, on each call. The
282+
* factory captures the config's correlation data and the evaluation context.
283+
*/
284+
private Supplier<LDAIConfigTracker> trackerFactory(
285+
String key, String variationKey, Integer version, Model model, Provider provider, LDContext context) {
286+
String varKey = variationKey == null ? "" : variationKey;
287+
int ver = version == null ? 0 : version;
288+
String modelName = model != null && model.getName() != null ? model.getName() : "";
289+
String providerName = provider != null && provider.getName() != null ? provider.getName() : "";
290+
return () -> new LDAIConfigTrackerImpl(
291+
client, UUID.randomUUID().toString(), key, varKey, ver, modelName, providerName, context, null, logger);
292+
}
293+
273294
private List<Message> interpolateMessages(
274295
List<Message> messages, Map<String, Object> variables, LDContext context) {
275296
if (messages == null) {
Lines changed: 159 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,167 @@
11
package com.launchdarkly.sdk.server.ai;
22

3+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.FeedbackKind;
4+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.JudgeResult;
5+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.Metrics;
6+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.MetricSummary;
7+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.TokenUsage;
8+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.TrackData;
9+
10+
import java.time.Duration;
11+
import java.util.List;
12+
import java.util.concurrent.Callable;
13+
import java.util.function.Function;
14+
315
/**
4-
* Reports events related to a single AI run of an {@link AIConfig}.
16+
* Reports metrics related to a single AI run of an {@link AIConfig}.
517
* <p>
6-
* A tracker is obtained from a retrieved config via {@link AIConfig#createTracker()}. Each tracker
7-
* corresponds to one AI run and is used to record metrics such as model usage, duration, and
8-
* feedback against the AI Config it was created from.
18+
* A tracker is obtained from a retrieved config via {@link AIConfig#createTracker()}, or
19+
* reconstructed across process boundaries via
20+
* {@link LDAIClient#createTracker(String, com.launchdarkly.sdk.LDContext)}. Each tracker corresponds
21+
* to one AI run; every event it emits shares a {@code runId} (a UUIDv4) so LaunchDarkly can
22+
* correlate them in metrics views. Start a new run by calling {@link AIConfig#createTracker()} again.
923
* <p>
10-
* <strong>This interface is an intentional placeholder.</strong> The metric- and feedback-reporting
11-
* methods (and resumption-token support) are introduced in a later step of the AI SDK build-out; it
12-
* is defined here so that the public config types expose a stable {@code createTracker()} surface.
13-
* The only implementation in this release is an internal no-op.
24+
* <strong>Thread-safety.</strong> Implementations are safe to share across threads. The
25+
* "record-once" metrics ({@link #trackDuration}, {@link #trackTimeToFirstToken},
26+
* {@link #trackSuccess}/{@link #trackError}, {@link #trackFeedback}, {@link #trackTokens}) each emit
27+
* at most once per tracker even under concurrent calls; later calls are ignored and logged.
28+
* {@link #trackToolCall}/{@link #trackToolCalls} and {@link #trackJudgeResult} may be called any
29+
* number of times and emit on every call.
1430
*/
1531
public interface LDAIConfigTracker {
32+
/**
33+
* Returns the correlation data attached to every event this tracker emits.
34+
*
35+
* @return the track data, never {@code null}
36+
*/
37+
TrackData getTrackData();
38+
39+
/**
40+
* Returns a URL-safe Base64 token that encodes this tracker's {@code runId}, {@code configKey},
41+
* {@code variationKey}, and {@code version}.
42+
* <p>
43+
* Pass it to {@link LDAIClient#createTracker(String, com.launchdarkly.sdk.LDContext)} to
44+
* reconstruct a tracker in another process so deferred events (for example user feedback) still
45+
* correlate with the original run.
46+
*
47+
* @return the resumption token, never {@code null}
48+
*/
49+
String getResumptionToken();
50+
51+
/**
52+
* Records the duration of the generation.
53+
* <p>
54+
* Records at most once per tracker; later calls are ignored. Negative durations (for example from
55+
* clock skew) are clamped to zero.
56+
*
57+
* @param duration the generation duration; must not be {@code null}
58+
*/
59+
void trackDuration(Duration duration);
60+
61+
/**
62+
* Runs the given operation, recording its duration even if it throws.
63+
* <p>
64+
* This does not record success or error; use {@link #trackMetricsOf} for that. Because
65+
* {@link #trackDuration} records at most once, calling this twice on the same tracker re-runs the
66+
* operation but emits no second duration event.
67+
*
68+
* @param operation the operation to time
69+
* @param <T> the operation's result type
70+
* @return the operation's result
71+
* @throws Exception if the operation throws
72+
*/
73+
<T> T trackDurationOf(Callable<T> operation) throws Exception;
74+
75+
/**
76+
* Records the time to first token for a streaming generation.
77+
* <p>
78+
* Records at most once per tracker; later calls are ignored. Negative values are clamped to zero.
79+
*
80+
* @param duration the time to first token; must not be {@code null}
81+
*/
82+
void trackTimeToFirstToken(Duration duration);
83+
84+
/**
85+
* Records that the generation succeeded.
86+
* <p>
87+
* Success and error share state: only the first of {@link #trackSuccess}/{@link #trackError}
88+
* recorded on a tracker takes effect; later calls are ignored.
89+
*/
90+
void trackSuccess();
91+
92+
/**
93+
* Records that the generation failed.
94+
* <p>
95+
* Success and error share state: only the first of {@link #trackSuccess}/{@link #trackError}
96+
* recorded on a tracker takes effect; later calls are ignored.
97+
*/
98+
void trackError();
99+
100+
/**
101+
* Records end-user feedback about the generation.
102+
* <p>
103+
* Records at most once per tracker; later calls are ignored.
104+
*
105+
* @param kind the feedback sentiment; must not be {@code null}
106+
*/
107+
void trackFeedback(FeedbackKind kind);
108+
109+
/**
110+
* Records token usage for the generation.
111+
* <p>
112+
* Records at most once per tracker; later calls are ignored. Negative counts are clamped to zero,
113+
* and an individual count is only emitted when it is greater than zero.
114+
*
115+
* @param tokens the token usage; must not be {@code null}
116+
*/
117+
void trackTokens(TokenUsage tokens);
118+
119+
/**
120+
* Records a single tool invocation. May be called any number of times.
121+
*
122+
* @param toolKey the identifier of the invoked tool; must not be {@code null}
123+
*/
124+
void trackToolCall(String toolKey);
125+
126+
/**
127+
* Records several tool invocations. May be called any number of times.
128+
*
129+
* @param toolKeys the identifiers of the invoked tools; must not be {@code null}
130+
*/
131+
void trackToolCalls(List<String> toolKeys);
132+
133+
/**
134+
* Records a judge evaluation result. May be called any number of times.
135+
* <p>
136+
* No event is emitted when the result was not sampled, did not succeed, or carries no metric key
137+
* or score. A {@code null} score is treated as "no score" and is distinct from {@code 0.0}.
138+
*
139+
* @param result the judge result; must not be {@code null}
140+
*/
141+
void trackJudgeResult(JudgeResult result);
142+
143+
/**
144+
* Runs the given operation, recording its duration and then its outcome and metrics.
145+
* <p>
146+
* The operation is timed via {@link #trackDurationOf}. If it throws, an error is recorded and the
147+
* exception is rethrown. Otherwise the extractor is applied to the result; if the extractor
148+
* throws, an error is recorded and the exception is rethrown. On success the extracted metrics
149+
* drive {@link #trackSuccess}/{@link #trackError}, {@link #trackTokens}, and
150+
* {@link #trackToolCalls}.
151+
*
152+
* @param metricsExtractor extracts {@link Metrics} from the operation's result
153+
* @param operation the AI operation to run
154+
* @param <T> the operation's result type
155+
* @return the operation's result
156+
* @throws Exception if the operation or the extractor throws
157+
*/
158+
<T> T trackMetricsOf(Function<? super T, Metrics> metricsExtractor, Callable<T> operation)
159+
throws Exception;
160+
161+
/**
162+
* Returns an immutable snapshot of the metrics recorded on this tracker so far.
163+
*
164+
* @return the metric summary, never {@code null}
165+
*/
166+
MetricSummary getSummary();
16167
}

0 commit comments

Comments
 (0)