langfuse-rb 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ module Langfuse
17
17
  # prompt = client.get_prompt("greeting")
18
18
  # compiled = prompt.compile(name: "Alice")
19
19
  #
20
+ # rubocop:disable Metrics/ClassLength
20
21
  class Client
21
22
  attr_reader :config, :api_client
22
23
 
@@ -139,6 +140,93 @@ module Langfuse
139
140
  prompt.compile(**variables)
140
141
  end
141
142
 
143
+ # Create a new prompt (or new version if name already exists)
144
+ #
145
+ # Creates a new prompt in Langfuse. If a prompt with the same name already
146
+ # exists, this creates a new version of that prompt.
147
+ #
148
+ # @param name [String] The prompt name (required)
149
+ # @param prompt [String, Array<Hash>] The prompt content (required)
150
+ # - For text prompts: a string with {{variable}} placeholders
151
+ # - For chat prompts: array of message hashes with role and content
152
+ # @param type [Symbol] Prompt type (:text or :chat) (required)
153
+ # @param config [Hash] Optional configuration (model parameters, tools, etc.)
154
+ # @param labels [Array<String>] Optional labels (e.g., ["production"])
155
+ # @param tags [Array<String>] Optional tags for categorization
156
+ # @param commit_message [String, nil] Optional commit message
157
+ # @return [TextPromptClient, ChatPromptClient] The created prompt client
158
+ # @raise [ArgumentError] if required parameters are missing or invalid
159
+ # @raise [UnauthorizedError] if authentication fails
160
+ # @raise [ApiError] for other API errors
161
+ #
162
+ # @example Create a text prompt
163
+ # prompt = client.create_prompt(
164
+ # name: "greeting",
165
+ # prompt: "Hello {{name}}!",
166
+ # type: :text,
167
+ # labels: ["production"],
168
+ # config: { model: "gpt-4o", temperature: 0.7 }
169
+ # )
170
+ #
171
+ # @example Create a chat prompt
172
+ # prompt = client.create_prompt(
173
+ # name: "support-bot",
174
+ # prompt: [
175
+ # { role: "system", content: "You are a {{role}} assistant" },
176
+ # { role: "user", content: "{{question}}" }
177
+ # ],
178
+ # type: :chat,
179
+ # labels: ["staging"]
180
+ # )
181
+ # rubocop:disable Metrics/ParameterLists
182
+ def create_prompt(name:, prompt:, type:, config: {}, labels: [], tags: [], commit_message: nil)
183
+ validate_prompt_type!(type)
184
+ validate_prompt_content!(prompt, type)
185
+
186
+ prompt_data = api_client.create_prompt(
187
+ name: name,
188
+ prompt: normalize_prompt_content(prompt, type),
189
+ type: type.to_s,
190
+ config: config,
191
+ labels: labels,
192
+ tags: tags,
193
+ commit_message: commit_message
194
+ )
195
+
196
+ build_prompt_client(prompt_data)
197
+ end
198
+ # rubocop:enable Metrics/ParameterLists
199
+
200
+ # Update an existing prompt version's metadata
201
+ #
202
+ # Updates the labels of an existing prompt version.
203
+ # Note: The prompt content itself cannot be changed after creation.
204
+ #
205
+ # @param name [String] The prompt name (required)
206
+ # @param version [Integer] The version number to update (required)
207
+ # @param labels [Array<String>] New labels (replaces existing). Required.
208
+ # @return [TextPromptClient, ChatPromptClient] The updated prompt client
209
+ # @raise [ArgumentError] if labels is not an array
210
+ # @raise [NotFoundError] if the prompt is not found
211
+ # @raise [UnauthorizedError] if authentication fails
212
+ # @raise [ApiError] for other API errors
213
+ #
214
+ # @example Update labels to promote to production
215
+ # prompt = client.update_prompt(
216
+ # name: "greeting",
217
+ # version: 2,
218
+ # labels: ["production"]
219
+ # )
220
+ def update_prompt(name:, version:, labels:)
221
+ prompt_data = api_client.update_prompt(
222
+ name: name,
223
+ version: version,
224
+ labels: labels
225
+ )
226
+
227
+ build_prompt_client(prompt_data)
228
+ end
229
+
142
230
  # Generate URL for viewing a trace in Langfuse UI
143
231
  #
144
232
  # @param trace_id [String] The trace ID (hex-encoded, 32 characters)
@@ -252,9 +340,12 @@ module Langfuse
252
340
 
253
341
  # Shutdown the client and flush any pending scores
254
342
  #
343
+ # Also shuts down the cache if it supports shutdown (e.g., SWR thread pool).
344
+ #
255
345
  # @return [void]
256
346
  def shutdown
257
347
  @score_client.shutdown
348
+ @api_client.shutdown
258
349
  end
259
350
 
260
351
  private
@@ -274,20 +365,37 @@ module Langfuse
274
365
  def create_cache
275
366
  case config.cache_backend
276
367
  when :memory
277
- PromptCache.new(
278
- ttl: config.cache_ttl,
279
- max_size: config.cache_max_size
280
- )
368
+ create_memory_cache
281
369
  when :rails
282
- RailsCacheAdapter.new(
283
- ttl: config.cache_ttl,
284
- lock_timeout: config.cache_lock_timeout
285
- )
370
+ create_rails_cache_adapter
286
371
  else
287
372
  raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
288
373
  end
289
374
  end
290
375
 
376
+ # Create in-memory cache with SWR support if enabled
377
+ #
378
+ # @return [PromptCache]
379
+ def create_memory_cache
380
+ PromptCache.new(
381
+ ttl: config.cache_ttl,
382
+ max_size: config.cache_max_size,
383
+ stale_ttl: config.normalized_stale_ttl,
384
+ refresh_threads: config.cache_refresh_threads,
385
+ logger: config.logger
386
+ )
387
+ end
388
+
389
+ def create_rails_cache_adapter
390
+ RailsCacheAdapter.new(
391
+ ttl: config.cache_ttl,
392
+ lock_timeout: config.cache_lock_timeout,
393
+ stale_ttl: config.normalized_stale_ttl,
394
+ refresh_threads: config.cache_refresh_threads,
395
+ logger: config.logger
396
+ )
397
+ end
398
+
291
399
  # Build the appropriate prompt client based on prompt type
292
400
  #
293
401
  # @param prompt_data [Hash] The prompt data from API
@@ -314,6 +422,8 @@ module Langfuse
314
422
  # @return [TextPromptClient, ChatPromptClient]
315
423
  # @raise [ArgumentError] if type is invalid
316
424
  def build_fallback_prompt_client(name, fallback, type)
425
+ validate_prompt_type!(type)
426
+
317
427
  # Create minimal prompt data structure
318
428
  prompt_data = {
319
429
  "name" => name,
@@ -330,9 +440,58 @@ module Langfuse
330
440
  TextPromptClient.new(prompt_data)
331
441
  when :chat
332
442
  ChatPromptClient.new(prompt_data)
333
- else
334
- raise ArgumentError, "Invalid type: #{type}. Must be :text or :chat"
443
+ end
444
+ end
445
+
446
+ # Validate prompt type parameter
447
+ #
448
+ # @param type [Symbol] The type to validate
449
+ # @raise [ArgumentError] if type is invalid
450
+ def validate_prompt_type!(type)
451
+ valid_types = %i[text chat]
452
+ return if valid_types.include?(type)
453
+
454
+ raise ArgumentError, "Invalid type: #{type}. Must be :text or :chat"
455
+ end
456
+
457
+ # Validate prompt content matches the declared type
458
+ #
459
+ # @param prompt [String, Array] The prompt content
460
+ # @param type [Symbol] The declared type
461
+ # @raise [ArgumentError] if content doesn't match type
462
+ def validate_prompt_content!(prompt, type)
463
+ case type
464
+ when :text
465
+ raise ArgumentError, "Text prompt must be a String" unless prompt.is_a?(String)
466
+ when :chat
467
+ raise ArgumentError, "Chat prompt must be an Array" unless prompt.is_a?(Array)
468
+ end
469
+ end
470
+
471
+ # Normalize prompt content for API request
472
+ #
473
+ # Converts Ruby symbol keys to string keys for chat messages
474
+ #
475
+ # @param prompt [String, Array] The prompt content
476
+ # @param type [Symbol] The prompt type
477
+ # @return [String, Array] Normalized content
478
+ def normalize_prompt_content(prompt, type)
479
+ return prompt if type == :text
480
+
481
+ # Normalize chat messages to use string keys
482
+ prompt.map do |message|
483
+ # Convert all keys to symbols first, then extract
484
+ normalized = message.transform_keys do |k|
485
+ k.to_sym
486
+ rescue StandardError
487
+ k
488
+ end
489
+ {
490
+ "role" => normalized[:role]&.to_s,
491
+ "content" => normalized[:content]
492
+ }
335
493
  end
336
494
  end
337
495
  end
496
+ # rubocop:enable Metrics/ClassLength
338
497
  end
@@ -46,6 +46,16 @@ module Langfuse
46
46
  # @return [Integer] Lock timeout in seconds for distributed cache stampede protection
47
47
  attr_accessor :cache_lock_timeout
48
48
 
49
+ # @return [Boolean] Enable stale-while-revalidate caching (when true, sets cache_stale_ttl to cache_ttl if not customized)
50
+ attr_accessor :cache_stale_while_revalidate
51
+
52
+ # @return [Integer, Symbol] Stale TTL in seconds (grace period for serving stale data, default: 0 when SWR disabled, cache_ttl when SWR enabled)
53
+ # Accepts :indefinite which is automatically normalized to 1000 years (31,536,000,000 seconds) for practical "never expire" behavior.
54
+ attr_accessor :cache_stale_ttl
55
+
56
+ # @return [Integer] Number of background threads for cache refresh
57
+ attr_accessor :cache_refresh_threads
58
+
49
59
  # @return [Boolean] Use async processing for traces (requires ActiveJob)
50
60
  attr_accessor :tracing_async
51
61
 
@@ -65,11 +75,16 @@ module Langfuse
65
75
  DEFAULT_CACHE_MAX_SIZE = 1000
66
76
  DEFAULT_CACHE_BACKEND = :memory
67
77
  DEFAULT_CACHE_LOCK_TIMEOUT = 10
78
+ DEFAULT_CACHE_STALE_WHILE_REVALIDATE = false
79
+ DEFAULT_CACHE_REFRESH_THREADS = 5
68
80
  DEFAULT_TRACING_ASYNC = true
69
81
  DEFAULT_BATCH_SIZE = 50
70
82
  DEFAULT_FLUSH_INTERVAL = 10
71
83
  DEFAULT_JOB_QUEUE = :default
72
84
 
85
+ # Number of seconds representing indefinite cache duration (~1000 years)
86
+ INDEFINITE_SECONDS = 1000 * 365 * 24 * 60 * 60
87
+
73
88
  # Initialize a new Config object
74
89
  #
75
90
  # @yield [config] Optional block for configuration
@@ -83,6 +98,9 @@ module Langfuse
83
98
  @cache_max_size = DEFAULT_CACHE_MAX_SIZE
84
99
  @cache_backend = DEFAULT_CACHE_BACKEND
85
100
  @cache_lock_timeout = DEFAULT_CACHE_LOCK_TIMEOUT
101
+ @cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
102
+ @cache_stale_ttl = 0 # Default to 0 (SWR disabled, entries expire immediately after TTL)
103
+ @cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
86
104
  @tracing_async = DEFAULT_TRACING_ASYNC
87
105
  @batch_size = DEFAULT_BATCH_SIZE
88
106
  @flush_interval = DEFAULT_FLUSH_INTERVAL
@@ -110,10 +128,29 @@ module Langfuse
110
128
  "cache_lock_timeout must be positive"
111
129
  end
112
130
 
131
+ validate_swr_config!
132
+
113
133
  validate_cache_backend!
114
134
  end
115
135
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
116
136
 
137
+ # Normalize stale_ttl value
138
+ #
139
+ # Converts :indefinite to 1000 years in seconds for practical "never expire"
140
+ # behavior while keeping the value finite for calculations.
141
+ #
142
+ # @return [Integer] Normalized stale TTL in seconds
143
+ #
144
+ # @example
145
+ # config.cache_stale_ttl = 300
146
+ # config.normalized_stale_ttl # => 300
147
+ #
148
+ # config.cache_stale_ttl = :indefinite
149
+ # config.normalized_stale_ttl # => 31536000000
150
+ def normalized_stale_ttl
151
+ cache_stale_ttl == :indefinite ? INDEFINITE_SECONDS : cache_stale_ttl
152
+ end
153
+
117
154
  private
118
155
 
119
156
  def default_logger
@@ -131,5 +168,37 @@ module Langfuse
131
168
  raise ConfigurationError,
132
169
  "cache_backend must be one of #{valid_backends.inspect}, got #{cache_backend.inspect}"
133
170
  end
171
+
172
+ def validate_swr_config!
173
+ validate_swr_stale_ttl!
174
+ validate_refresh_threads!
175
+ end
176
+
177
+ def validate_swr_stale_ttl!
178
+ # Check if SWR is enabled but stale_ttl is nil
179
+ if cache_stale_while_revalidate && cache_stale_ttl.nil?
180
+ raise ConfigurationError,
181
+ "cache_stale_ttl cannot be nil when cache_stale_while_revalidate is enabled. " \
182
+ "Set it to cache_ttl for a logical default, or use :indefinite for never-expiring cache."
183
+ end
184
+
185
+ # Validate that cache_stale_ttl is not nil (unless already caught by SWR check)
186
+ if cache_stale_ttl.nil?
187
+ raise ConfigurationError,
188
+ "cache_stale_ttl must be non-negative or :indefinite"
189
+ end
190
+
191
+ # Validate numeric values are non-negative
192
+ return unless cache_stale_ttl.is_a?(Integer) && cache_stale_ttl.negative?
193
+
194
+ raise ConfigurationError,
195
+ "cache_stale_ttl must be non-negative or :indefinite"
196
+ end
197
+
198
+ def validate_refresh_threads!
199
+ return unless cache_refresh_threads.nil? || cache_refresh_threads <= 0
200
+
201
+ raise ConfigurationError, "cache_refresh_threads must be positive"
202
+ end
134
203
  end
135
204
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "stale_while_revalidate"
4
5
 
5
6
  module Langfuse
6
7
  # Simple in-memory cache for prompt data with TTL
@@ -14,24 +15,63 @@ module Langfuse
14
15
  # cache.get("greeting:1") # => prompt_data
15
16
  #
16
17
  class PromptCache
18
+ include StaleWhileRevalidate
19
+
17
20
  # Cache entry with data and expiration time
18
- CacheEntry = Struct.new(:data, :expires_at) do
21
+ #
22
+ # Supports stale-while-revalidate pattern:
23
+ # - fresh_until: Time until entry is considered fresh (can be served immediately)
24
+ # - stale_until: Time until entry is considered stale (serve while revalidating in background)
25
+ # - After stale_until: Entry is expired (must revalidate synchronously)
26
+ CacheEntry = Struct.new(:data, :fresh_until, :stale_until) do
27
+ # Check if the cache entry is still fresh
28
+ #
29
+ # @return [Boolean] true if current time is before fresh_until
30
+ def fresh?
31
+ Time.now < fresh_until
32
+ end
33
+
34
+ # Check if the cache entry is stale but not expired
35
+ #
36
+ # Stale entries can be served immediately while a background
37
+ # revalidation occurs (stale-while-revalidate pattern)
38
+ #
39
+ # @return [Boolean] true if current time is between fresh_until and stale_until
40
+ def stale?
41
+ now = Time.now
42
+ now >= fresh_until && now < stale_until
43
+ end
44
+
45
+ # Check if the cache entry has expired
46
+ #
47
+ # Expired entries should not be served and must be revalidated
48
+ # synchronously before use.
49
+ #
50
+ # @return [Boolean] true if current time is at or after stale_until
19
51
  def expired?
20
- Time.now > expires_at
52
+ Time.now >= stale_until
21
53
  end
22
54
  end
23
55
 
24
- attr_reader :ttl, :max_size
56
+ attr_reader :ttl, :max_size, :stale_ttl, :logger
25
57
 
26
58
  # Initialize a new cache
27
59
  #
28
60
  # @param ttl [Integer] Time-to-live in seconds (default: 60)
29
61
  # @param max_size [Integer] Maximum cache size (default: 1000)
30
- def initialize(ttl: 60, max_size: 1000)
62
+ # @param stale_ttl [Integer] Stale TTL for SWR in seconds (default: 0, SWR disabled).
63
+ # Note: :indefinite is normalized to 1000 years by Config before being passed here.
64
+ # @param refresh_threads [Integer] Number of background refresh threads (default: 5)
65
+ # @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
66
+ def initialize(ttl: 60, max_size: 1000, stale_ttl: 0, refresh_threads: 5, logger: default_logger)
31
67
  @ttl = ttl
32
68
  @max_size = max_size
69
+ @stale_ttl = stale_ttl
70
+ @logger = logger
33
71
  @cache = {}
34
72
  @monitor = Monitor.new
73
+ @locks = {} # Track locks for in-memory locking
74
+ initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
35
75
  end
36
76
 
37
77
  # Get a value from the cache
@@ -58,8 +98,10 @@ module Langfuse
58
98
  # Evict oldest entry if at max size
59
99
  evict_oldest if @cache.size >= max_size
60
100
 
61
- expires_at = Time.now + ttl
62
- @cache[key] = CacheEntry.new(value, expires_at)
101
+ now = Time.now
102
+ fresh_until = now + ttl
103
+ stale_until = fresh_until + stale_ttl
104
+ @cache[key] = CacheEntry.new(value, fresh_until, stale_until)
63
105
  value
64
106
  end
65
107
  end
@@ -117,15 +159,84 @@ module Langfuse
117
159
 
118
160
  private
119
161
 
162
+ # Implementation of StaleWhileRevalidate abstract methods
163
+
164
+ # Get value from cache (SWR interface)
165
+ #
166
+ # @param key [String] Cache key
167
+ # @return [PromptCache::CacheEntry, nil] Cached value
168
+ def cache_get(key)
169
+ @monitor.synchronize do
170
+ @cache[key]
171
+ end
172
+ end
173
+
174
+ # Set value in cache (SWR interface)
175
+ #
176
+ # @param key [String] Cache key
177
+ # @param value [PromptCache::CacheEntry] Value to cache
178
+ # @return [PromptCache::CacheEntry] The cached value
179
+ def cache_set(key, value)
180
+ @monitor.synchronize do
181
+ # Evict oldest entry if at max size
182
+ evict_oldest if @cache.size >= max_size
183
+
184
+ @cache[key] = value
185
+ value
186
+ end
187
+ end
188
+
189
+ # Acquire a lock using in-memory locking
190
+ #
191
+ # Prevents duplicate background refreshes from different threads within
192
+ # the same process. This is NOT distributed locking - it only works
193
+ # within a single process. For distributed locking, use RailsCacheAdapter.
194
+ #
195
+ # **MEMORY LEAK WARNING**: Locks are stored in a hash and only deleted on
196
+ # release_lock. If a refresh thread crashes or is killed externally (e.g., Thread#kill)
197
+ # between acquire_lock and release_lock, the lock persists forever. Unlike Redis locks
198
+ # which have TTL expiration, in-memory locks have no timeout. For production use with
199
+ # SWR, prefer RailsCacheAdapter to avoid lock accumulation and potential memory exhaustion.
200
+ #
201
+ # @param lock_key [String] Lock key
202
+ # @return [Boolean] true if lock was acquired, false if already held
203
+ def acquire_lock(lock_key)
204
+ @monitor.synchronize do
205
+ return false if @locks[lock_key]
206
+
207
+ @locks[lock_key] = true
208
+ true
209
+ end
210
+ end
211
+
212
+ # Release a lock
213
+ #
214
+ # @param lock_key [String] Lock key
215
+ # @return [void]
216
+ def release_lock(lock_key)
217
+ @monitor.synchronize do
218
+ @locks.delete(lock_key)
219
+ end
220
+ end
221
+
222
+ # In-memory cache helper methods
223
+
120
224
  # Evict the oldest entry from cache
121
225
  #
122
226
  # @return [void]
123
227
  def evict_oldest
124
228
  return if @cache.empty?
125
229
 
126
- # Find entry with earliest expiration
127
- oldest_key = @cache.min_by { |_key, entry| entry.expires_at }&.first
230
+ # Find entry with earliest expiration (using stale_until as expiration time)
231
+ oldest_key = @cache.min_by { |_key, entry| entry.stale_until }&.first
128
232
  @cache.delete(oldest_key) if oldest_key
129
233
  end
234
+
235
+ # Create a default logger
236
+ #
237
+ # @return [Logger]
238
+ def default_logger
239
+ Logger.new($stdout, level: Logger::WARN)
240
+ end
130
241
  end
131
242
  end
@@ -364,7 +364,6 @@ module Langfuse
364
364
  def self._get_span_key_from_baggage_key(baggage_key)
365
365
  return nil unless baggage_key.start_with?(BAGGAGE_PREFIX)
366
366
 
367
- # Remove prefix
368
367
  suffix = baggage_key[BAGGAGE_PREFIX.length..]
369
368
 
370
369
  # Handle metadata keys (format: langfuse_metadata_{key_name})
@@ -373,17 +372,7 @@ module Langfuse
373
372
  return "#{OtelAttributes::TRACE_METADATA}.#{metadata_key}"
374
373
  end
375
374
 
376
- # Map standard keys
377
- case suffix
378
- when "user_id"
379
- _get_propagated_span_key("user_id")
380
- when "session_id"
381
- _get_propagated_span_key("session_id")
382
- when "version"
383
- _get_propagated_span_key("version")
384
- when "tags"
385
- _get_propagated_span_key("tags")
386
- end
375
+ SPAN_KEY_MAP[suffix]
387
376
  end
388
377
 
389
378
  # Check if baggage API is available
@@ -404,7 +393,7 @@ module Langfuse
404
393
  def self._extract_baggage_attributes(context)
405
394
  return {} unless baggage_available?
406
395
 
407
- baggage = OpenTelemetry::Baggage.value(context: context)
396
+ baggage = OpenTelemetry::Baggage.values(context: context)
408
397
  return {} unless baggage.is_a?(Hash)
409
398
 
410
399
  attributes = {}
@@ -453,12 +442,12 @@ module Langfuse
453
442
  if key == "metadata" && value.is_a?(Hash)
454
443
  value.each do |k, v|
455
444
  entry_key = "#{baggage_key}_#{k}"
456
- context = OpenTelemetry::Baggage.set_value(context: context, key: entry_key, value: v.to_s)
445
+ context = OpenTelemetry::Baggage.set_value(entry_key, v.to_s, context: context)
457
446
  end
458
447
  elsif key == "tags" && value.is_a?(Array)
459
- context = OpenTelemetry::Baggage.set_value(context: context, key: baggage_key, value: value.join(","))
448
+ context = OpenTelemetry::Baggage.set_value(baggage_key, value.join(","), context: context)
460
449
  else
461
- context = OpenTelemetry::Baggage.set_value(context: context, key: baggage_key, value: value.to_s)
450
+ context = OpenTelemetry::Baggage.set_value(baggage_key, value.to_s, context: context)
462
451
  end
463
452
  context
464
453
  rescue StandardError => e