Skip to content

Commit 33f4975

Browse files
jsonbaileyclaude
andcommitted
feat!: Add per-execution runId and at-most-once event tracking
- Each tracker now carries a run_id (UUIDv4) included in all emitted events, scoping every metric to a single execution - At-most-once semantics: duplicate calls to track_duration, track_tokens, track_success/track_error, track_feedback, and track_time_to_first_token on the same tracker are dropped with a warning Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1fe67cc commit 33f4975

2 files changed

Lines changed: 131 additions & 7 deletions

File tree

lib/server/ai/ai_config_tracker.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'ldclient-rb'
4+
require 'securerandom'
45

56
module LaunchDarkly
67
module Server
@@ -64,6 +65,13 @@ def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, p
6465
@provider_name = provider_name
6566
@context = context
6667
@summary = MetricSummary.new
68+
@run_id = SecureRandom.uuid
69+
@tracked_duration = false
70+
@tracked_time_to_first_token = false
71+
@tracked_tokens = false
72+
@tracked_success = nil
73+
@tracked_feedback = false
74+
@logger = LaunchDarkly::Server::AI.default_logger
6775
end
6876

6977
#
@@ -72,6 +80,11 @@ def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, p
7280
# @param duration [Integer] The duration in milliseconds
7381
#
7482
def track_duration(duration)
83+
if @tracked_duration
84+
@logger&.warn("Duration has already been tracked for this execution.")
85+
return
86+
end
87+
@tracked_duration = true
7588
@summary.duration = duration
7689
@ld_client.track(
7790
'$ld:ai:duration:total',
@@ -101,6 +114,11 @@ def track_duration_of(&block)
101114
# @param duration [Integer] The duration in milliseconds
102115
#
103116
def track_time_to_first_token(time_to_first_token)
117+
if @tracked_time_to_first_token
118+
@logger&.warn("Time to first token has already been tracked for this execution.")
119+
return
120+
end
121+
@tracked_time_to_first_token = true
104122
@summary.time_to_first_token = time_to_first_token
105123
@ld_client.track(
106124
'$ld:ai:tokens:ttf',
@@ -116,6 +134,11 @@ def track_time_to_first_token(time_to_first_token)
116134
# @param kind [Symbol] The kind of feedback (:positive or :negative)
117135
#
118136
def track_feedback(kind:)
137+
if @tracked_feedback
138+
@logger&.warn("Feedback has already been tracked for this execution.")
139+
return
140+
end
141+
@tracked_feedback = true
119142
@summary.feedback = kind
120143
event_name = kind == :positive ? '$ld:ai:feedback:user:positive' : '$ld:ai:feedback:user:negative'
121144
@ld_client.track(
@@ -130,6 +153,11 @@ def track_feedback(kind:)
130153
# Track a successful AI generation
131154
#
132155
def track_success
156+
unless @tracked_success.nil?
157+
@logger&.warn("Success or error has already been tracked for this execution.")
158+
return
159+
end
160+
@tracked_success = true
133161
@summary.success = true
134162
@ld_client.track(
135163
'$ld:ai:generation:success',
@@ -143,6 +171,11 @@ def track_success
143171
# Track an error in AI generation
144172
#
145173
def track_error
174+
unless @tracked_success.nil?
175+
@logger&.warn("Success or error has already been tracked for this execution.")
176+
return
177+
end
178+
@tracked_success = false
146179
@summary.success = false
147180
@ld_client.track(
148181
'$ld:ai:generation:error',
@@ -158,6 +191,11 @@ def track_error
158191
# @param token_usage [TokenUsage] An object containing token usage details
159192
#
160193
def track_tokens(token_usage)
194+
if @tracked_tokens
195+
@logger&.warn("Tokens have already been tracked for this execution.")
196+
return
197+
end
198+
@tracked_tokens = true
161199
@summary.usage = token_usage
162200
if token_usage.total.positive?
163201
@ld_client.track(
@@ -223,6 +261,7 @@ def track_bedrock_converse_metrics(&block)
223261

224262
private def flag_data
225263
{
264+
runId: @run_id,
226265
variationKey: @variation_key,
227266
configKey: @config_key,
228267
version: @version,

spec/server/ai/config_tracker_spec.rb

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
end
2828

2929
let(:context) { LaunchDarkly::LDContext.create({ key: 'user-key', kind: 'user' }) }
30-
let(:tracker_flag_data) { { variationKey: 'test-variation', configKey: 'test-config', version: 1, modelName: 'fakeModel', providerName: 'fakeProvider' } }
30+
let(:tracker_flag_data) { { runId: kind_of(String), variationKey: 'test-variation', configKey: 'test-config', version: 1, modelName: 'fakeModel', providerName: 'fakeProvider' } }
3131
let(:tracker) do
3232
described_class.new(
3333
ld_client: ld_client,
@@ -322,25 +322,107 @@
322322
expect(tracker.summary.success).to be false
323323
end
324324

325-
it 'overwrites success with error if both are tracked' do
325+
it 'does not track error if success has already been tracked' do
326326
expect(ld_client).to receive(:track).with(
327327
'$ld:ai:generation:success',
328328
context,
329329
tracker_flag_data,
330330
1
331331
)
332+
333+
tracker.track_success
334+
expect(tracker.summary.success).to be true
335+
tracker.track_error
336+
expect(tracker.summary.success).to be true
337+
end
338+
end
339+
340+
describe 'at-most-once tracking' do
341+
it 'only tracks duration once' do
332342
expect(ld_client).to receive(:track).with(
333-
'$ld:ai:generation:error',
343+
'$ld:ai:duration:total',
334344
context,
335345
tracker_flag_data,
336-
1
337-
)
346+
100
347+
).once
348+
tracker.track_duration(100)
349+
tracker.track_duration(200)
350+
expect(tracker.summary.duration).to eq(100)
351+
end
352+
353+
it 'only tracks time to first token once' do
354+
expect(ld_client).to receive(:track).with(
355+
'$ld:ai:tokens:ttf',
356+
context,
357+
tracker_flag_data,
358+
100
359+
).once
360+
tracker.track_time_to_first_token(100)
361+
tracker.track_time_to_first_token(200)
362+
expect(tracker.summary.time_to_first_token).to eq(100)
363+
end
364+
365+
it 'only tracks tokens once' do
366+
tokens1 = LaunchDarkly::Server::AI::TokenUsage.new(total: 300, input: 200, output: 100)
367+
tokens2 = LaunchDarkly::Server::AI::TokenUsage.new(total: 600, input: 400, output: 200)
368+
expect(ld_client).to receive(:track).with(
369+
'$ld:ai:tokens:total',
370+
context,
371+
tracker_flag_data,
372+
300
373+
).once
374+
expect(ld_client).to receive(:track).with(
375+
'$ld:ai:tokens:input',
376+
context,
377+
tracker_flag_data,
378+
200
379+
).once
380+
expect(ld_client).to receive(:track).with(
381+
'$ld:ai:tokens:output',
382+
context,
383+
tracker_flag_data,
384+
100
385+
).once
386+
tracker.track_tokens(tokens1)
387+
tracker.track_tokens(tokens2)
388+
expect(tracker.summary.usage).to eq(tokens1)
389+
end
338390

391+
it 'only tracks success once' do
392+
expect(ld_client).to receive(:track).with(
393+
'$ld:ai:generation:success',
394+
context,
395+
tracker_flag_data,
396+
1
397+
).once
398+
tracker.track_success
339399
tracker.track_success
340400
expect(tracker.summary.success).to be true
401+
end
402+
403+
it 'only tracks error once' do
404+
expect(ld_client).to receive(:track).with(
405+
'$ld:ai:generation:error',
406+
context,
407+
tracker_flag_data,
408+
1
409+
).once
410+
tracker.track_error
341411
tracker.track_error
342412
expect(tracker.summary.success).to be false
343413
end
414+
415+
it 'only tracks feedback once' do
416+
expect(ld_client).to receive(:track).with(
417+
'$ld:ai:feedback:user:positive',
418+
context,
419+
tracker_flag_data,
420+
1
421+
).once
422+
tracker.track_feedback(kind: :positive)
423+
tracker.track_feedback(kind: :negative)
424+
expect(tracker.summary.feedback).to eq(:positive)
425+
end
344426
end
345427

346428
describe '#summary' do
@@ -370,11 +452,14 @@
370452
end
371453

372454
describe '#flag_data' do
373-
it 'includes model_name and provider_name in flag data' do
374-
expect(tracker.send(:flag_data)).to include(
455+
it 'includes runId, model_name, and provider_name in flag data' do
456+
flag_data = tracker.send(:flag_data)
457+
expect(flag_data).to include(
458+
runId: kind_of(String),
375459
modelName: 'fakeModel',
376460
providerName: 'fakeProvider'
377461
)
462+
expect(flag_data[:runId]).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i)
378463
end
379464
end
380465

0 commit comments

Comments
 (0)