Skip to content

Commit fc11d5d

Browse files
jsonbaileyclaude
andcommitted
feat!: Add per-execution runId, at-most-once tracking, and cross-process tracker resumption
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 33f4975 commit fc11d5d

5 files changed

Lines changed: 254 additions & 19 deletions

File tree

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: 47 additions & 2 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
require 'securerandom'
57

@@ -56,7 +58,7 @@ class AIConfigTracker
5658
# @param provider_name [String] The name of the AI provider
5759
# @param context [LDContext] The context used for the flag evaluation
5860
#
59-
def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, provider_name:, context:)
61+
def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, provider_name:, context:, run_id: nil)
6062
@ld_client = ld_client
6163
@variation_key = variation_key
6264
@config_key = config_key
@@ -65,7 +67,7 @@ def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, p
6567
@provider_name = provider_name
6668
@context = context
6769
@summary = MetricSummary.new
68-
@run_id = SecureRandom.uuid
70+
@run_id = run_id || SecureRandom.uuid
6971
@tracked_duration = false
7072
@tracked_time_to_first_token = false
7173
@tracked_tokens = false
@@ -74,6 +76,49 @@ def initialize(ld_client:, variation_key:, config_key:, version:, model_name:, p
7476
@logger = LaunchDarkly::Server::AI.default_logger
7577
end
7678

79+
#
80+
# Returns a URL-safe Base64-encoded JSON token that can be used to reconstruct
81+
# a tracker in a different process (e.g. for deferred feedback).
82+
#
83+
# The token contains: runId, configKey, variationKey, version.
84+
# modelName and providerName are NOT included.
85+
#
86+
# @return [String] the resumption token
87+
#
88+
def resumption_token
89+
payload = {
90+
runId: @run_id,
91+
configKey: @config_key,
92+
variationKey: @variation_key,
93+
version: @version,
94+
}
95+
Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
96+
end
97+
98+
#
99+
# Reconstructs a tracker from a resumption token.
100+
#
101+
# @param token [String] A URL-safe Base64-encoded JSON resumption token
102+
# @param ld_client [LDClient] The LaunchDarkly client instance
103+
# @param context [LDContext] The context for track events
104+
# @return [AIConfigTracker] A new tracker instance
105+
#
106+
def self.from_resumption_token(token:, ld_client:, context:)
107+
json = Base64.urlsafe_decode64(token)
108+
payload = JSON.parse(json)
109+
110+
new(
111+
ld_client: ld_client,
112+
run_id: payload['runId'],
113+
config_key: payload['configKey'],
114+
variation_key: payload.fetch('variationKey', ''),
115+
version: payload['version'],
116+
model_name: '',
117+
provider_name: '',
118+
context: context
119+
)
120+
end
121+
77122
#
78123
# Track the duration of an AI operation
79124
#

lib/server/ai/client.rb

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,26 @@ def to_h
103103
# The AIConfig class represents an AI configuration.
104104
#
105105
class AIConfig
106-
attr_reader :enabled, :messages, :tracker, :model, :provider
106+
attr_reader :enabled, :messages, :model, :provider
107107

108-
def initialize(enabled: nil, model: nil, messages: nil, tracker: nil, provider: nil)
108+
def initialize(enabled: nil, model: nil, messages: nil, tracker_factory: nil, provider: nil)
109109
@enabled = enabled
110110
@messages = messages
111-
@tracker = tracker
111+
@tracker_factory = tracker_factory
112112
@model = model
113113
@provider = provider
114114
end
115115

116+
#
117+
# Creates a new tracker with a fresh runId for tracking a single AI execution.
118+
# Returns nil when the config has no tracker factory (e.g. a static disabled config).
119+
#
120+
# @return [AIConfigTracker, nil] a new tracker instance, or nil
121+
#
122+
def create_tracker
123+
@tracker_factory&.call
124+
end
125+
116126
#
117127
# Returns a new disabled AIConfig instance.
118128
#
@@ -182,6 +192,18 @@ def completion_config(key:, context:, default: nil, variables: nil)
182192
_completion_config(key:, context:, default: default || AIConfig.disabled, variables:)
183193
end
184194

195+
#
196+
# Reconstructs a tracker from a resumption token, allowing deferred tracking
197+
# (e.g. feedback from a different process).
198+
#
199+
# @param token [String] A resumption token obtained from AIConfigTracker#resumption_token
200+
# @param context [LDContext] The context for track events
201+
# @return [AIConfigTracker] A new tracker instance
202+
#
203+
def create_tracker(token:, context:)
204+
AIConfigTracker.from_resumption_token(token: token, ld_client: @ld_client, context: context)
205+
end
206+
185207
# @deprecated Use {#completion_config} instead.
186208
def config(key:, context:, default: nil, variables: nil)
187209
warn '[DEPRECATION] `config` is deprecated. Use `completion_config` instead.'
@@ -226,20 +248,27 @@ def _completion_config(key:, context:, default:, variables: nil)
226248
)
227249
end
228250

229-
tracker = LaunchDarkly::Server::AI::AIConfigTracker.new(
230-
ld_client: @ld_client,
231-
variation_key: variation.dig(:_ldMeta, :variationKey) || '',
232-
config_key: key,
233-
version: variation.dig(:_ldMeta, :version) || 1,
234-
model_name: model&.name || '',
235-
provider_name: provider_config&.name || '',
236-
context:
237-
)
251+
variation_key = variation.dig(:_ldMeta, :variationKey) || ''
252+
version = variation.dig(:_ldMeta, :version) || 1
253+
model_name = model&.name || ''
254+
provider_name = provider_config&.name || ''
255+
256+
tracker_factory = lambda {
257+
LaunchDarkly::Server::AI::AIConfigTracker.new(
258+
ld_client: @ld_client,
259+
variation_key: variation_key,
260+
config_key: key,
261+
version: version,
262+
model_name: model_name,
263+
provider_name: provider_name,
264+
context: context
265+
)
266+
}
238267

239268
AIConfig.new(
240269
enabled: variation.dig(:_ldMeta, :enabled) || false,
241270
messages:,
242-
tracker:,
271+
tracker_factory:,
243272
model:,
244273
provider: provider_config
245274
)

spec/server/ai/client_spec.rb

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,9 @@
227227

228228
expect(config.provider).not_to be_nil
229229
expect(config.provider.name).to eq('fakeProvider')
230-
expect(config.tracker).not_to be_nil
231-
expect(config.tracker.send(:flag_data)).to include(
230+
tracker = config.create_tracker
231+
expect(tracker).not_to be_nil
232+
expect(tracker.send(:flag_data)).to include(
232233
modelName: 'fakeModel',
233234
providerName: 'fakeProvider'
234235
)
@@ -353,8 +354,9 @@
353354
expect(config.model).to be_nil
354355
expect(config.messages).to be_nil
355356
expect(config.provider).to be_nil
356-
expect(config.tracker).not_to be_nil
357-
expect(config.tracker.send(:flag_data)).to include(
357+
tracker = config.create_tracker
358+
expect(tracker).not_to be_nil
359+
expect(tracker.send(:flag_data)).to include(
358360
modelName: '',
359361
providerName: ''
360362
)
@@ -375,6 +377,74 @@
375377

376378
expect(config.enabled).to be false
377379
end
380+
381+
it 'create_tracker returns a new tracker with a fresh runId each time' do
382+
context = LaunchDarkly::LDContext.create({ key: 'user-key', kind: 'user' })
383+
config = ai_client.completion_config(key: 'model-config', context:, variables: { 'name' => 'World' })
384+
385+
tracker1 = config.create_tracker
386+
tracker2 = config.create_tracker
387+
388+
expect(tracker1).to be_a(LaunchDarkly::Server::AI::AIConfigTracker)
389+
expect(tracker2).to be_a(LaunchDarkly::Server::AI::AIConfigTracker)
390+
expect(tracker1).not_to equal(tracker2)
391+
392+
run_id1 = tracker1.send(:flag_data)[:runId]
393+
run_id2 = tracker2.send(:flag_data)[:runId]
394+
expect(run_id1).not_to eq(run_id2)
395+
expect(run_id1).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)
396+
expect(run_id2).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)
397+
end
398+
399+
it 'create_tracker preserves flag metadata across calls' do
400+
context = LaunchDarkly::LDContext.create({ key: 'user-key', kind: 'user' })
401+
config = ai_client.completion_config(key: 'model-config', context:, variables: { 'name' => 'World' })
402+
403+
tracker = config.create_tracker
404+
flag_data = tracker.send(:flag_data)
405+
406+
expect(flag_data[:configKey]).to eq('model-config')
407+
expect(flag_data[:variationKey]).to eq('abcd')
408+
expect(flag_data[:version]).to eq(1)
409+
expect(flag_data[:modelName]).to eq('fakeModel')
410+
expect(flag_data[:providerName]).to eq('fakeProvider')
411+
end
412+
413+
it 'create_tracker returns nil for static disabled config' do
414+
config = LaunchDarkly::Server::AI::AIConfig.disabled
415+
expect(config.create_tracker).to be_nil
416+
end
417+
418+
it 'round-trips a tracker through a resumption token via client create_tracker' do
419+
context = LaunchDarkly::LDContext.create({ key: 'user-key', kind: 'user' })
420+
config = ai_client.completion_config(key: 'model-config', context:, variables: { 'name' => 'World' })
421+
422+
tracker = config.create_tracker
423+
token = tracker.resumption_token
424+
original_run_id = tracker.send(:flag_data)[:runId]
425+
426+
restored = ai_client.create_tracker(token: token, context: context)
427+
428+
expect(restored).to be_a(LaunchDarkly::Server::AI::AIConfigTracker)
429+
expect(restored.send(:flag_data)[:runId]).to eq(original_run_id)
430+
expect(restored.send(:flag_data)[:configKey]).to eq('model-config')
431+
expect(restored.send(:flag_data)[:modelName]).to eq('')
432+
expect(restored.send(:flag_data)[:providerName]).to eq('')
433+
end
434+
435+
it 'each tracker has independent at-most-once tracking' do
436+
context = LaunchDarkly::LDContext.create({ key: 'user-key', kind: 'user' })
437+
config = ai_client.completion_config(key: 'model-config', context:, variables: { 'name' => 'World' })
438+
439+
tracker1 = config.create_tracker
440+
tracker2 = config.create_tracker
441+
442+
tracker1.track_duration(100)
443+
tracker2.track_duration(200)
444+
445+
expect(tracker1.summary.duration).to eq(100)
446+
expect(tracker2.summary.duration).to eq(200)
447+
end
378448
end
379449

380450
describe LaunchDarkly::Server::AI::AIConfig do

spec/server/ai/config_tracker_spec.rb

Lines changed: 90 additions & 0 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 'launchdarkly-server-sdk'
46
require 'launchdarkly-server-sdk-ai'
57

@@ -463,6 +465,94 @@
463465
end
464466
end
465467

468+
describe '#resumption_token' do
469+
it 'returns a URL-safe base64-encoded JSON string' do
470+
token = tracker.resumption_token
471+
decoded = JSON.parse(Base64.urlsafe_decode64(token))
472+
473+
expect(decoded['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)
474+
expect(decoded['configKey']).to eq('test-config')
475+
expect(decoded['variationKey']).to eq('test-variation')
476+
expect(decoded['version']).to eq(1)
477+
end
478+
479+
it 'does not include modelName or providerName' do
480+
token = tracker.resumption_token
481+
decoded = JSON.parse(Base64.urlsafe_decode64(token))
482+
483+
expect(decoded).not_to have_key('modelName')
484+
expect(decoded).not_to have_key('providerName')
485+
end
486+
487+
it 'contains the same runId as the tracker flag data' do
488+
flag_data = tracker.send(:flag_data)
489+
token = tracker.resumption_token
490+
decoded = JSON.parse(Base64.urlsafe_decode64(token))
491+
492+
expect(decoded['runId']).to eq(flag_data[:runId])
493+
end
494+
end
495+
496+
describe '.from_resumption_token' do
497+
it 'reconstructs a tracker that uses the same runId' do
498+
original_token = tracker.resumption_token
499+
original_run_id = tracker.send(:flag_data)[:runId]
500+
501+
restored = described_class.from_resumption_token(
502+
token: original_token,
503+
ld_client: ld_client,
504+
context: context
505+
)
506+
507+
expect(restored.send(:flag_data)[:runId]).to eq(original_run_id)
508+
expect(restored.send(:flag_data)[:configKey]).to eq('test-config')
509+
expect(restored.send(:flag_data)[:variationKey]).to eq('test-variation')
510+
expect(restored.send(:flag_data)[:version]).to eq(1)
511+
end
512+
513+
it 'sets modelName and providerName to empty strings' do
514+
token = tracker.resumption_token
515+
516+
restored = described_class.from_resumption_token(
517+
token: token,
518+
ld_client: ld_client,
519+
context: context
520+
)
521+
522+
expect(restored.send(:flag_data)[:modelName]).to eq('')
523+
expect(restored.send(:flag_data)[:providerName]).to eq('')
524+
end
525+
526+
it 'can track events with the restored tracker' do
527+
token = tracker.resumption_token
528+
original_run_id = tracker.send(:flag_data)[:runId]
529+
530+
restored = described_class.from_resumption_token(
531+
token: token,
532+
ld_client: ld_client,
533+
context: context
534+
)
535+
536+
expected_data = {
537+
runId: original_run_id,
538+
variationKey: 'test-variation',
539+
configKey: 'test-config',
540+
version: 1,
541+
modelName: '',
542+
providerName: '',
543+
}
544+
545+
expect(ld_client).to receive(:track).with(
546+
'$ld:ai:feedback:user:positive',
547+
context,
548+
expected_data,
549+
1
550+
)
551+
552+
restored.track_feedback(kind: :positive)
553+
end
554+
end
555+
466556
describe 'completion_config method tracking' do
467557
it 'calls track with correct parameters when completion_config is called' do
468558
allow(ld_client).to receive(:track)

0 commit comments

Comments
 (0)