Skip to content

Commit 20f06f1

Browse files
authored
feat!: Add per-execution runId, at-most-once tracking, and cross-process tracker resumption (#29)
1 parent a01d985 commit 20f06f1

7 files changed

Lines changed: 583 additions & 72 deletions

File tree

examples/chatbot/aws-bedrock/hello_bedrock.rb

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ def initialize(ai_config, bedrock_client)
2929
@bedrock_client = bedrock_client
3030
end
3131

32+
# Returns [response_content, resumption_token]. The resumption token can be
33+
# used to reconstruct a tracker for deferred operations like feedback.
3234
def ask_agent(question)
3335
@messages << LaunchDarkly::Server::AI::Message.new('user', question)
36+
tracker = @ai_config.create_tracker
3437
begin
35-
response = ai_config.tracker.track_bedrock_converse_metrics do
38+
response = tracker.track_bedrock_converse_metrics do
3639
@bedrock_client.converse(
3740
map_converse_arguments(
3841
ai_config.model.name,
@@ -41,15 +44,17 @@ def ask_agent(question)
4144
)
4245
end
4346
@messages << LaunchDarkly::Server::AI::Message.new('assistant', response.output.message.content[0].text)
44-
response.output.message.content[0].text
47+
[response.output.message.content[0].text, tracker.resumption_token]
4548
rescue StandardError => e
46-
"An error occured: #{e.message}"
49+
["An error occured: #{e.message}", nil]
4750
end
4851
end
4952

50-
def agent_was_helpful(helpful)
53+
def agent_was_helpful(helpful, tracker)
54+
return if tracker.nil?
55+
5156
kind = helpful ? :positive : :negative
52-
ai_config.tracker.track_feedback(kind: kind)
57+
tracker.track_feedback(kind: kind)
5358
end
5459

5560
def map_converse_arguments(model_id, messages)
@@ -89,7 +94,7 @@ def map_converse_arguments(model_id, messages)
8994

9095
# Pass a default for improved resiliency when the flag is unavailable or LaunchDarkly is unreachable; omit for a disabled default.
9196
# Example:
92-
# default = LaunchDarkly::Server::AI::AIConfig.new(
97+
# default = LaunchDarkly::Server::AI::AIConfigDefault.new(
9398
# enabled: true,
9499
# model: LaunchDarkly::Server::AI::ModelConfig.new(name: 'my-model'),
95100
# provider: LaunchDarkly::Server::AI::ProviderConfig.new(name: 'my-provider'),
@@ -105,18 +110,27 @@ def map_converse_arguments(model_id, messages)
105110

106111
chatbot = BedrockChatbot.new(ai_config, bedrock_client)
107112

113+
last_resumption_token = nil
114+
108115
loop do
109116
print "Ask a question (or type 'exit'): "
110117
question = gets&.chomp
111118
break if question.nil? || question.strip.downcase == 'exit'
112119

113-
response = chatbot.ask_agent(question)
120+
response, last_resumption_token = chatbot.ask_agent(question)
114121
puts "AI Response: #{response}"
115122
end
116123

117124
print "Was the chat helpful? [yes/no]: "
118125
feedback = gets&.chomp
119126

120-
chatbot.agent_was_helpful(feedback == 'yes') unless feedback.nil?
127+
unless feedback.nil? || last_resumption_token.nil?
128+
tracker = LaunchDarkly::Server::AI::AIConfigTracker.from_resumption_token(
129+
token: last_resumption_token,
130+
ld_client:,
131+
context:
132+
)
133+
chatbot.agent_was_helpful(feedback == 'yes', tracker)
134+
end
121135

122136
ld_client.close

examples/chatbot/openai/hello_openai.rb

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,31 @@ def initialize(ai_config, openai_client)
3535
@openai_client = openai_client
3636
end
3737

38+
# Returns [response_content, resumption_token]. The resumption token can be
39+
# used to reconstruct a tracker for deferred operations like feedback.
3840
def ask_agent(question)
3941
@messages << LaunchDarkly::Server::AI::Message.new('user', question)
42+
tracker = @ai_config.create_tracker
4043
begin
41-
completion = ai_config.tracker.track_openai_metrics do
44+
completion = tracker.track_openai_metrics do
4245
@openai_client.chat.completions.create(
4346
model: ai_config.model.name,
4447
messages: @messages.map(&:to_h)
4548
)
4649
end
4750
response_content = completion[:choices][0][:message][:content]
4851
@messages << LaunchDarkly::Server::AI::Message.new('assistant', response_content)
49-
response_content
52+
[response_content, tracker.resumption_token]
5053
rescue StandardError => e
51-
"An error occurred: #{e.message}"
54+
["An error occurred: #{e.message}", nil]
5255
end
5356
end
5457

55-
def agent_was_helpful(helpful)
58+
def agent_was_helpful(helpful, tracker)
59+
return if tracker.nil?
60+
5661
kind = helpful ? :positive : :negative
57-
ai_config.tracker.track_feedback(kind: kind)
62+
tracker.track_feedback(kind: kind)
5863
end
5964
end
6065

@@ -78,7 +83,7 @@ def agent_was_helpful(helpful)
7883

7984
# Pass a default for improved resiliency when the flag is unavailable or LaunchDarkly is unreachable; omit for a disabled default.
8085
# Example:
81-
# default = LaunchDarkly::Server::AI::AIConfig.new(
86+
# default = LaunchDarkly::Server::AI::AIConfigDefault.new(
8287
# enabled: true,
8388
# model: LaunchDarkly::Server::AI::ModelConfig.new(name: 'my-model'),
8489
# provider: LaunchDarkly::Server::AI::ProviderConfig.new(name: 'my-provider'),
@@ -94,18 +99,27 @@ def agent_was_helpful(helpful)
9499

95100
chatbot = Chatbot.new(ai_config, OpenAI::Client.new(api_key: openai_api_key))
96101

102+
last_resumption_token = nil
103+
97104
loop do
98105
print "Ask a question (or type 'exit'): "
99106
question = gets&.chomp
100107
break if question.nil? || question.strip.downcase == 'exit'
101108

102-
response = chatbot.ask_agent(question)
109+
response, last_resumption_token = chatbot.ask_agent(question)
103110
puts "AI Response: #{response}"
104111
end
105112

106113
print "Was the chat helpful? [yes/no]: "
107114
feedback = gets&.chomp
108115

109-
chatbot.agent_was_helpful(feedback == 'yes') unless feedback.nil?
116+
unless feedback.nil? || last_resumption_token.nil?
117+
tracker = LaunchDarkly::Server::AI::AIConfigTracker.from_resumption_token(
118+
token: last_resumption_token,
119+
ld_client:,
120+
context:
121+
)
122+
chatbot.agent_was_helpful(feedback == 'yes', tracker)
123+
end
110124

111125
ld_client.close

launchdarkly-server-sdk-ai.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Gem::Specification.new do |spec|
1919
spec.require_paths = ['lib']
2020
spec.required_ruby_version = '>= 3.1.0'
2121

22+
spec.add_dependency 'base64'
2223
spec.add_dependency 'launchdarkly-server-sdk', '~> 8.5'
2324
spec.add_dependency 'logger'
2425
spec.add_dependency 'mustache', '~> 1.1'

lib/server/ai/ai_config_tracker.rb

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'base64'
4+
require 'json'
35
require 'ldclient-rb'
46

57
module 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

Comments
 (0)