11# frozen_string_literal: true
22
3+ require 'base64'
4+ require 'json'
35require 'ldclient-rb'
46
57module LaunchDarkly
@@ -39,7 +41,14 @@ def initialize
3941 end
4042
4143 #
42- # The AIConfigTracker class is used to track AI configuration usage.
44+ # The AIConfigTracker records metrics for a single AI run. Unless
45+ # otherwise noted, the tracker's methods are not safe for concurrent use.
46+ #
47+ # All events a tracker emits share a runId (a UUIDv4) so LaunchDarkly can
48+ # correlate them in metrics views. See individual track methods for their
49+ # specific semantics. Call create_tracker on the AI Config to start a new
50+ # run. A resumption token preserves the runId, so events emitted by a
51+ # tracker reconstructed in another process share the original runId.
4352 #
4453 class AIConfigTracker
4554 attr_reader :ld_client , :config_key , :context , :variation_key , :version , :summary , :model_name , :provider_name
@@ -55,7 +64,7 @@ class AIConfigTracker
5564 # @param provider_name [String] The name of the AI provider
5665 # @param context [LDContext] The context used for the flag evaluation
5766 #
58- def initialize ( ld_client :, variation_key :, config_key :, version :, model_name :, provider_name :, context :)
67+ def initialize ( ld_client :, run_id :, config_key :, variation_key : , version :, context :, model_name :, provider_name :)
5968 @ld_client = ld_client
6069 @variation_key = variation_key
6170 @config_key = config_key
@@ -64,14 +73,62 @@ def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, p
6473 @provider_name = provider_name
6574 @context = context
6675 @summary = MetricSummary . new
76+ @run_id = run_id
77+ @logger = LaunchDarkly ::Server ::AI . default_logger
78+ end
79+
80+ #
81+ # Returns a URL-safe Base64-encoded JSON token that can be used to reconstruct
82+ # a tracker in a different process (e.g. for deferred feedback).
83+ #
84+ # The token contains: runId, configKey, variationKey, version.
85+ # modelName and providerName are NOT included.
86+ #
87+ # @return [String] the resumption token
88+ #
89+ def resumption_token
90+ payload = { runId : @run_id , configKey : @config_key }
91+ payload [ :variationKey ] = @variation_key if @variation_key && !@variation_key . empty?
92+ payload [ :version ] = @version
93+ Base64 . urlsafe_encode64 ( JSON . generate ( payload ) , padding : false )
94+ end
95+
96+ #
97+ # Reconstructs a tracker from a resumption token.
98+ #
99+ # @param token [String] A URL-safe Base64-encoded JSON resumption token
100+ # @param ld_client [LDClient] The LaunchDarkly client instance
101+ # @param context [LDContext] The context for track events
102+ # @return [AIConfigTracker] A new tracker instance
103+ #
104+ def self . from_resumption_token ( token :, ld_client :, context :)
105+ json = Base64 . urlsafe_decode64 ( token )
106+ payload = JSON . parse ( json )
107+
108+ new (
109+ ld_client : ld_client ,
110+ run_id : payload [ 'runId' ] ,
111+ config_key : payload [ 'configKey' ] ,
112+ variation_key : payload . fetch ( 'variationKey' , '' ) ,
113+ version : payload [ 'version' ] ,
114+ context : context ,
115+ model_name : '' ,
116+ provider_name : ''
117+ )
67118 end
68119
69120 #
70- # Track the duration of an AI operation
121+ # Track the duration of an AI run.
122+ #
123+ # Records at most once per Tracker; further calls are ignored.
71124 #
72125 # @param duration [Integer] The duration in milliseconds
73126 #
74127 def track_duration ( duration )
128+ unless @summary . duration . nil?
129+ @logger &.warn ( "Skipping track_duration: duration already recorded on this tracker. Call create_tracker on the AI Config for a new run. #{ flag_data } " )
130+ return
131+ end
75132 @summary . duration = duration
76133 @ld_client . track (
77134 '$ld:ai:duration:total' ,
@@ -96,11 +153,18 @@ def track_duration_of(&block)
96153 end
97154
98155 #
99- # Track time to first token
156+ # Track time to first token.
157+ #
158+ # Records at most once per Tracker; further calls are ignored.
100159 #
101160 # @param duration [Integer] The duration in milliseconds
102161 #
103162 def track_time_to_first_token ( time_to_first_token )
163+ unless @summary . time_to_first_token . nil?
164+ @logger &.warn ( "Skipping track_time_to_first_token: time-to-first-token already recorded on this tracker. " \
165+ "Call create_tracker on the AI Config for a new run. #{ flag_data } " )
166+ return
167+ end
104168 @summary . time_to_first_token = time_to_first_token
105169 @ld_client . track (
106170 '$ld:ai:tokens:ttf' ,
@@ -111,11 +175,17 @@ def track_time_to_first_token(time_to_first_token)
111175 end
112176
113177 #
114- # Track user feedback
178+ # Track user feedback.
179+ #
180+ # Records at most once per Tracker; further calls are ignored.
115181 #
116182 # @param kind [Symbol] The kind of feedback (:positive or :negative)
117183 #
118184 def track_feedback ( kind :)
185+ unless @summary . feedback . nil?
186+ @logger &.warn ( "Skipping track_feedback: feedback already recorded on this tracker. Call create_tracker on the AI Config for a new run. #{ flag_data } " )
187+ return
188+ end
119189 @summary . feedback = kind
120190 event_name = kind == :positive ? '$ld:ai:feedback:user:positive' : '$ld:ai:feedback:user:negative'
121191 @ld_client . track (
@@ -127,9 +197,17 @@ def track_feedback(kind:)
127197 end
128198
129199 #
130- # Track a successful AI generation
200+ # Track a successful AI generation.
201+ #
202+ # Records at most once per Tracker. track_success and track_error share
203+ # state; only one of the two can record per Tracker, and subsequent
204+ # calls are ignored.
131205 #
132206 def track_success
207+ unless @summary . success . nil?
208+ @logger &.warn ( "Skipping track_success: success/error already recorded on this tracker. Call create_tracker on the AI Config for a new run. #{ flag_data } " )
209+ return
210+ end
133211 @summary . success = true
134212 @ld_client . track (
135213 '$ld:ai:generation:success' ,
@@ -140,9 +218,17 @@ def track_success
140218 end
141219
142220 #
143- # Track an error in AI generation
221+ # Track an error in AI generation.
222+ #
223+ # Records at most once per Tracker. track_success and track_error share
224+ # state; only one of the two can record per Tracker, and subsequent
225+ # calls are ignored.
144226 #
145227 def track_error
228+ unless @summary . success . nil?
229+ @logger &.warn ( "Skipping track_error: success/error already recorded on this tracker. Call create_tracker on the AI Config for a new run. #{ flag_data } " )
230+ return
231+ end
146232 @summary . success = false
147233 @ld_client . track (
148234 '$ld:ai:generation:error' ,
@@ -153,11 +239,17 @@ def track_error
153239 end
154240
155241 #
156- # Track token usage
242+ # Track token usage.
243+ #
244+ # Records at most once per Tracker; further calls are ignored.
157245 #
158246 # @param token_usage [TokenUsage] An object containing token usage details
159247 #
160248 def track_tokens ( token_usage )
249+ unless @summary . usage . nil?
250+ @logger &.warn ( "Skipping track_tokens: token usage already recorded on this tracker. Call create_tracker on the AI Config for a new run. #{ flag_data } " )
251+ return
252+ end
161253 @summary . usage = token_usage
162254 if token_usage . total . positive?
163255 @ld_client . track (
@@ -191,6 +283,10 @@ def track_tokens(token_usage)
191283 # If the provided block raises, this method will also raise.
192284 # A failed operation will not have any token usage data.
193285 #
286+ # Subsequent calls re-run the inner block but emit only metrics not
287+ # already recorded on this Tracker. Call create_tracker on the AI
288+ # Config to start a new run.
289+ #
194290 # @yield The block to track.
195291 # @return The result of the tracked block.
196292 #
@@ -208,6 +304,10 @@ def track_openai_metrics(&block)
208304 # Track AWS Bedrock conversation operations.
209305 # This method tracks the duration, token usage, and success/error status.
210306 #
307+ # Subsequent calls re-run the inner block but emit only metrics not
308+ # already recorded on this Tracker. Call create_tracker on the AI
309+ # Config to start a new run.
310+ #
211311 # @yield The block to track.
212312 # @return [Hash] The original response hash.
213313 #
@@ -222,13 +322,15 @@ def track_bedrock_converse_metrics(&block)
222322 end
223323
224324 private def flag_data
225- {
226- variationKey : @variation_key ,
325+ data = {
326+ runId : @run_id ,
227327 configKey : @config_key ,
228328 version : @version ,
229329 modelName : @model_name ,
230330 providerName : @provider_name ,
231331 }
332+ data [ :variationKey ] = @variation_key if @variation_key && !@variation_key . empty?
333+ data
232334 end
233335
234336 private def openai_to_token_usage ( usage )
0 commit comments