langfuse-rb 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae64454a24bf090bcaedf13432b8c2c7ecc0c1d986bc5ffdec8ad3f5ada1b2d5
4
- data.tar.gz: 05dcbaaa1aa3aa90fcb75d42086a526c60914b3f88ce2fa0049b77d7cafc33cb
3
+ metadata.gz: 60270020fc35460c5e29381351bbca35f1cdfe8b7dfe05eca43a0fb20dc06b7b
4
+ data.tar.gz: 4951c9b1546de4c9d00bb3b3be4c5325c8edf4d5bf6f5ecab7d9520ab052451b
5
5
  SHA512:
6
- metadata.gz: 1b8b91ba6180d4450fef21f59bcca7c06b21c2a5d336e70bf233ca3d2b4d325387f78950ac3ee2c41c4655a0199a4fbd03fdf07164cf224dba7bc2734af1f95c
7
- data.tar.gz: 00d0a265e3f41cf6690f740b63c7d61853733b9701e1d1ea0630fe19a6d8b8022d8a1cd26b9eceb62133fe169fde093544d738ba3f258f765cd9ced0e2aecea9
6
+ metadata.gz: 84fa1fc6ea91bda9ddcaa32dd43cb457439e0e4cdee06e3f3df88aae9c54c5b10abd16cb120f234da28733ba6497c466d0ece7888410d7b2711815e13f3956ef
7
+ data.tar.gz: 785bd5801a8c6b0ecd7c94f43083bdd6f12463bb918fa5f4a6faee32924a87a5058cf9700b20984fa9e8964cb3070975235771ddfddabca115cb33538020b065
data/CHANGELOG.md CHANGED
@@ -7,15 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-01-23
11
+
10
12
  ### Added
11
- - Create and update methods for prompts (#36)
13
+ - Stale-while-revalidate (SWR) cache strategy for improved performance (#35)
14
+
15
+ ### Fixed
16
+ - OpenTelemetry Baggage API method signatures for context propagation (#39)
12
17
 
18
+ ### Changed
19
+ - Relaxed Faraday version constraint for better compatibility with older projects (#37)
13
20
 
14
21
  ## [0.2.0] - 2025-12-19
15
22
 
16
23
  ### Added
17
- - Prompt creation and update methods (`create_prompt`, `update_prompt`)
18
- - Extended prompt management documentation with create/update examples
24
+ - Prompt creation and update methods (`create_prompt`, `update_prompt`) (#36)
19
25
 
20
26
  ## [0.1.0] - 2025-12-01
21
27
 
@@ -34,5 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
34
40
  - Migrated from legacy ingestion API to OTLP endpoint
35
41
  - Removed `tracing_enabled` configuration flag (#2)
36
42
 
37
- [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.1.0...HEAD
43
+ [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...HEAD
44
+ [0.3.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.2.0...v0.3.0
45
+ [0.2.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.1.0...v0.2.0
38
46
  [0.1.0]: https://github.com/simplepractice/langfuse-rb/releases/tag/v0.1.0
data/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  - 🎯 **Prompt Management** - Centralized prompt versioning with Mustache templating
16
16
  - 📊 **LLM Tracing** - Zero-boilerplate observability built on OpenTelemetry
17
- - ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection
17
+ - ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection, both supporting stale-while-revalidate cache strategy
18
18
  - 💬 **Chat & Text Prompts** - First-class support for both formats
19
19
  - 🔄 **Automatic Retries** - Built-in exponential backoff for resilient API calls
20
20
  - 🛡️ **Fallback Support** - Graceful degradation when API unavailable
@@ -43,6 +43,10 @@ Langfuse.configure do |config|
43
43
  config.secret_key = ENV['LANGFUSE_SECRET_KEY']
44
44
  # Optional: for self-hosted instances
45
45
  config.base_url = ENV.fetch('LANGFUSE_BASE_URL', 'https://cloud.langfuse.com')
46
+
47
+ # Optional: Enable stale-while-revalidate for best performance
48
+ config.cache_backend = :rails # or :memory
49
+ config.cache_stale_while_revalidate = true
46
50
  end
47
51
  ```
48
52
 
@@ -110,3 +114,6 @@ We welcome contributions! Please:
110
114
  - **[Langfuse Documentation](https://langfuse.com/docs)** - Platform documentation
111
115
  - **[API Reference](https://api.reference.langfuse.com)** - REST API reference
112
116
 
117
+ ## License
118
+
119
+ [MIT](LICENSE)
@@ -21,8 +21,7 @@ module Langfuse
21
21
  # logger: Logger.new($stdout)
22
22
  # )
23
23
  #
24
- # rubocop:disable Metrics/ClassLength
25
- class ApiClient
24
+ class ApiClient # rubocop:disable Metrics/ClassLength
26
25
  attr_reader :public_key, :secret_key, :base_url, :timeout, :logger, :cache
27
26
 
28
27
  # Initialize a new API client
@@ -107,26 +106,10 @@ module Langfuse
107
106
  # @raise [ApiError] for other API errors
108
107
  def get_prompt(name, version: nil, label: nil)
109
108
  raise ArgumentError, "Cannot specify both version and label" if version && label
109
+ return fetch_prompt_from_api(name, version: version, label: label) if cache.nil?
110
110
 
111
111
  cache_key = PromptCache.build_key(name, version: version, label: label)
112
-
113
- # Use distributed lock if cache supports it (Rails.cache backend)
114
- if cache.respond_to?(:fetch_with_lock)
115
- cache.fetch_with_lock(cache_key) do
116
- fetch_prompt_from_api(name, version: version, label: label)
117
- end
118
- elsif cache
119
- # In-memory cache - use simple get/set pattern
120
- cached_data = cache.get(cache_key)
121
- return cached_data if cached_data
122
-
123
- prompt_data = fetch_prompt_from_api(name, version: version, label: label)
124
- cache.set(cache_key, prompt_data)
125
- prompt_data
126
- else
127
- # No cache - fetch directly
128
- fetch_prompt_from_api(name, version: version, label: label)
129
- end
112
+ fetch_with_appropriate_caching_strategy(cache_key, name, version, label)
130
113
  end
131
114
 
132
115
  # Create a new prompt (or new version if prompt with same name exists)
@@ -246,8 +229,63 @@ module Langfuse
246
229
  raise ApiError, "Batch send failed: #{e.message}"
247
230
  end
248
231
 
232
+ def shutdown
233
+ cache.shutdown if cache.respond_to?(:shutdown)
234
+ end
235
+
249
236
  private
250
237
 
238
+ # Fetch prompt using the most appropriate caching strategy available
239
+ #
240
+ # @param cache_key [String] The cache key for this prompt
241
+ # @param name [String] The name of the prompt
242
+ # @param version [Integer, nil] Optional specific version number
243
+ # @param label [String, nil] Optional label
244
+ # @return [Hash] The prompt data
245
+ def fetch_with_appropriate_caching_strategy(cache_key, name, version, label)
246
+ if swr_cache_available?
247
+ fetch_with_swr_cache(cache_key, name, version, label)
248
+ elsif distributed_cache_available?
249
+ fetch_with_distributed_cache(cache_key, name, version, label)
250
+ else
251
+ fetch_with_simple_cache(cache_key, name, version, label)
252
+ end
253
+ end
254
+
255
+ # Check if SWR cache is available
256
+ def swr_cache_available?
257
+ cache.respond_to?(:swr_enabled?) && cache.swr_enabled?
258
+ end
259
+
260
+ # Check if distributed cache is available
261
+ def distributed_cache_available?
262
+ cache.respond_to?(:fetch_with_lock)
263
+ end
264
+
265
+ # Fetch with SWR cache
266
+ def fetch_with_swr_cache(cache_key, name, version, label)
267
+ cache.fetch_with_stale_while_revalidate(cache_key) do
268
+ fetch_prompt_from_api(name, version: version, label: label)
269
+ end
270
+ end
271
+
272
+ # Fetch with distributed cache (Rails.cache with stampede protection)
273
+ def fetch_with_distributed_cache(cache_key, name, version, label)
274
+ cache.fetch_with_lock(cache_key) do
275
+ fetch_prompt_from_api(name, version: version, label: label)
276
+ end
277
+ end
278
+
279
+ # Fetch with simple cache (in-memory cache)
280
+ def fetch_with_simple_cache(cache_key, name, version, label)
281
+ cached_data = cache.get(cache_key)
282
+ return cached_data if cached_data
283
+
284
+ prompt_data = fetch_prompt_from_api(name, version: version, label: label)
285
+ cache.set(cache_key, prompt_data)
286
+ prompt_data
287
+ end
288
+
251
289
  # Fetch a prompt from the API (without caching)
252
290
  #
253
291
  # @param name [String] The name of the prompt
@@ -408,4 +446,3 @@ module Langfuse
408
446
  end
409
447
  end
410
448
  end
411
- # rubocop:enable Metrics/ClassLength
@@ -340,9 +340,12 @@ module Langfuse
340
340
 
341
341
  # Shutdown the client and flush any pending scores
342
342
  #
343
+ # Also shuts down the cache if it supports shutdown (e.g., SWR thread pool).
344
+ #
343
345
  # @return [void]
344
346
  def shutdown
345
347
  @score_client.shutdown
348
+ @api_client.shutdown
346
349
  end
347
350
 
348
351
  private
@@ -362,20 +365,37 @@ module Langfuse
362
365
  def create_cache
363
366
  case config.cache_backend
364
367
  when :memory
365
- PromptCache.new(
366
- ttl: config.cache_ttl,
367
- max_size: config.cache_max_size
368
- )
368
+ create_memory_cache
369
369
  when :rails
370
- RailsCacheAdapter.new(
371
- ttl: config.cache_ttl,
372
- lock_timeout: config.cache_lock_timeout
373
- )
370
+ create_rails_cache_adapter
374
371
  else
375
372
  raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
376
373
  end
377
374
  end
378
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
+
379
399
  # Build the appropriate prompt client based on prompt type
380
400
  #
381
401
  # @param prompt_data [Hash] The prompt data from API
@@ -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
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "prompt_cache"
4
+ require_relative "stale_while_revalidate"
5
+
3
6
  module Langfuse
4
7
  # Rails.cache adapter for distributed caching with Redis
5
8
  #
@@ -12,20 +15,30 @@ module Langfuse
12
15
  # adapter.get("greeting:1") # => prompt_data
13
16
  #
14
17
  class RailsCacheAdapter
15
- attr_reader :ttl, :namespace, :lock_timeout
18
+ include StaleWhileRevalidate
19
+
20
+ attr_reader :ttl, :namespace, :lock_timeout, :stale_ttl, :thread_pool, :logger
16
21
 
17
22
  # Initialize a new Rails.cache adapter
18
23
  #
19
24
  # @param ttl [Integer] Time-to-live in seconds (default: 60)
20
25
  # @param namespace [String] Cache key namespace (default: "langfuse")
21
26
  # @param lock_timeout [Integer] Lock timeout in seconds for stampede protection (default: 10)
27
+ # @param stale_ttl [Integer] Stale TTL for SWR in seconds (default: 0, SWR disabled).
28
+ # Note: :indefinite is normalized to 1000 years by Config before being passed here.
29
+ # @param refresh_threads [Integer] Number of background refresh threads (default: 5)
30
+ # @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
22
31
  # @raise [ConfigurationError] if Rails.cache is not available
23
- def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10)
32
+ def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10, stale_ttl: 0, refresh_threads: 5,
33
+ logger: default_logger)
24
34
  validate_rails_cache!
25
35
 
26
36
  @ttl = ttl
27
37
  @namespace = namespace
28
38
  @lock_timeout = lock_timeout
39
+ @stale_ttl = stale_ttl
40
+ @logger = logger
41
+ initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
29
42
  end
30
43
 
31
44
  # Get a value from the cache
@@ -42,14 +55,57 @@ module Langfuse
42
55
  # @param value [Object] Value to cache
43
56
  # @return [Object] The cached value
44
57
  def set(key, value)
45
- Rails.cache.write(namespaced_key(key), value, expires_in: ttl)
58
+ # Calculate expiration: use total_ttl if SWR enabled, otherwise just ttl
59
+ expires_in = swr_enabled? ? total_ttl : ttl
60
+ Rails.cache.write(namespaced_key(key), value, expires_in:)
46
61
  value
47
62
  end
48
63
 
49
- # Fetch a value from cache with distributed lock for stampede protection
64
+ # Clear the entire Langfuse cache namespace
65
+ #
66
+ # Note: This uses delete_matched which may not be available on all cache stores.
67
+ # Works with Redis, Memcached, and memory stores. File store support varies.
68
+ #
69
+ # @return [void]
70
+ def clear
71
+ # Delete all keys matching the namespace pattern
72
+ Rails.cache.delete_matched("#{namespace}:*")
73
+ end
74
+
75
+ # Get current cache size
76
+ #
77
+ # Note: Rails.cache doesn't provide a size method, so we return nil
78
+ # to indicate this operation is not supported.
79
+ #
80
+ # @return [nil]
81
+ def size
82
+ nil
83
+ end
84
+
85
+ # Check if cache is empty
86
+ #
87
+ # Note: Rails.cache doesn't provide an efficient way to check if empty,
88
+ # so we return false to indicate this operation is not supported.
89
+ #
90
+ # @return [Boolean] Always returns false (unsupported operation)
91
+ def empty?
92
+ false
93
+ end
94
+
95
+ # Build a cache key from prompt name and options
96
+ #
97
+ # @param name [String] Prompt name
98
+ # @param version [Integer, nil] Optional version
99
+ # @param label [String, nil] Optional label
100
+ # @return [String] Cache key
101
+ def self.build_key(name, version: nil, label: nil)
102
+ PromptCache.build_key(name, version: version, label: label)
103
+ end
104
+
105
+ # Fetch a value from cache with lock for stampede protection
50
106
  #
51
107
  # This method prevents cache stampedes (thundering herd) by ensuring only one
52
- # process fetches from the source when the cache is empty. Other processes wait
108
+ # process/thread fetches from the source when the cache is empty. Others wait
53
109
  # for the first one to populate the cache.
54
110
  #
55
111
  # Uses exponential backoff: 50ms, 100ms, 200ms (3 retries max, ~350ms total).
@@ -60,7 +116,7 @@ module Langfuse
60
116
  # @return [Object] Cached or freshly fetched value
61
117
  #
62
118
  # @example
63
- # adapter.fetch_with_lock("greeting:v1") do
119
+ # cache.fetch_with_lock("greeting:v1") do
64
120
  # api_client.get_prompt("greeting")
65
121
  # end
66
122
  def fetch_with_lock(key)
@@ -68,8 +124,8 @@ module Langfuse
68
124
  cached = get(key)
69
125
  return cached if cached
70
126
 
71
- # 2. Cache miss - try to acquire distributed lock
72
- lock_key = "#{namespaced_key(key)}:lock"
127
+ # 2. Cache miss - try to acquire lock
128
+ lock_key = build_lock_key(key)
73
129
 
74
130
  if acquire_lock(lock_key)
75
131
  begin
@@ -92,74 +148,57 @@ module Langfuse
92
148
  end
93
149
  end
94
150
 
95
- # Clear the entire Langfuse cache namespace
96
- #
97
- # Note: This uses delete_matched which may not be available on all cache stores.
98
- # Works with Redis, Memcached, and memory stores. File store support varies.
99
- #
100
- # @return [void]
101
- def clear
102
- # Delete all keys matching the namespace pattern
103
- Rails.cache.delete_matched("#{namespace}:*")
104
- end
151
+ private
105
152
 
106
- # Get current cache size
107
- #
108
- # Note: Rails.cache doesn't provide a size method, so we return nil
109
- # to indicate this operation is not supported.
110
- #
111
- # @return [nil]
112
- def size
113
- nil
114
- end
153
+ # Implementation of StaleWhileRevalidate abstract methods
115
154
 
116
- # Check if cache is empty
117
- #
118
- # Note: Rails.cache doesn't provide an efficient way to check if empty,
119
- # so we return false to indicate this operation is not supported.
155
+ # Get value from cache (SWR interface)
120
156
  #
121
- # @return [Boolean] Always returns false (unsupported operation)
122
- def empty?
123
- false
157
+ # @param key [String] Cache key
158
+ # @return [Object, nil] Cached value
159
+ def cache_get(key)
160
+ get(key)
124
161
  end
125
162
 
126
- # Build a cache key from prompt name and options
163
+ # Set value in cache (SWR interface)
127
164
  #
128
- # @param name [String] Prompt name
129
- # @param version [Integer, nil] Optional version
130
- # @param label [String, nil] Optional label
131
- # @return [String] Cache key
132
- def self.build_key(name, version: nil, label: nil)
133
- PromptCache.build_key(name, version: version, label: label)
165
+ # @param key [String] Cache key
166
+ # @param value [Object] Value to cache (expects CacheEntry)
167
+ # @return [Object] The cached value
168
+ def cache_set(key, value)
169
+ set(key, value)
134
170
  end
135
171
 
136
- private
137
-
138
- # Add namespace prefix to cache key
172
+ # Build lock key with namespace
139
173
  #
140
- # @param key [String] Original cache key
141
- # @return [String] Namespaced cache key
142
- def namespaced_key(key)
143
- "#{namespace}:#{key}"
174
+ # Used for both fetch operations (stampede protection) and refresh operations
175
+ # (preventing duplicate background refreshes).
176
+ #
177
+ # @param key [String] Cache key
178
+ # @return [String] Namespaced lock key
179
+ def build_lock_key(key)
180
+ "#{namespaced_key(key)}:lock"
144
181
  end
145
182
 
146
- # Acquire a distributed lock using Rails.cache
183
+ # Acquire a lock using Rails.cache
147
184
  #
148
- # Uses atomic "write if not exists" operation to ensure only one process
149
- # can acquire the lock.
185
+ # Used for both fetch operations and refresh operations.
186
+ # Uses the configured lock_timeout for all locking scenarios.
150
187
  #
151
188
  # @param lock_key [String] Full lock key (already namespaced)
152
- # @return [Boolean] true if lock was acquired, false if already held by another process
189
+ # @return [Boolean] true if lock was acquired, false if already held
153
190
  def acquire_lock(lock_key)
154
191
  Rails.cache.write(
155
192
  lock_key,
156
193
  true,
157
194
  unless_exist: true, # Atomic: only write if key doesn't exist
158
- expires_in: lock_timeout # Auto-expire to prevent deadlocks
195
+ expires_in: lock_timeout # Use configured lock timeout
159
196
  )
160
197
  end
161
198
 
162
- # Release a distributed lock
199
+ # Release a lock
200
+ #
201
+ # Used for both fetch and refresh operations.
163
202
  #
164
203
  # @param lock_key [String] Full lock key (already namespaced)
165
204
  # @return [void]
@@ -172,7 +211,7 @@ module Langfuse
172
211
  # Uses exponential backoff: 50ms, 100ms, 200ms (3 retries, ~350ms total).
173
212
  # This gives the lock holder time to fetch and populate the cache.
174
213
  #
175
- # @param key [String] Cache key (not namespaced)
214
+ # @param key [String] Cache key
176
215
  # @return [Object, nil] Cached value if found, nil if still empty after waiting
177
216
  def wait_for_cache(key)
178
217
  intervals = [0.05, 0.1, 0.2] # 50ms, 100ms, 200ms (exponential backoff)
@@ -186,6 +225,16 @@ module Langfuse
186
225
  nil # Cache still empty after all retries
187
226
  end
188
227
 
228
+ # Rails.cache-specific helper methods
229
+
230
+ # Add namespace prefix to cache key
231
+ #
232
+ # @param key [String] Original cache key
233
+ # @return [String] Namespaced cache key
234
+ def namespaced_key(key)
235
+ "#{namespace}:#{key}"
236
+ end
237
+
189
238
  # Validate that Rails.cache is available
190
239
  #
191
240
  # @raise [ConfigurationError] if Rails.cache is not available
@@ -196,5 +245,16 @@ module Langfuse
196
245
  raise ConfigurationError,
197
246
  "Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
198
247
  end
248
+
249
+ # Create a default logger
250
+ #
251
+ # @return [Logger]
252
+ def default_logger
253
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
254
+ Rails.logger
255
+ else
256
+ Logger.new($stdout, level: Logger::WARN)
257
+ end
258
+ end
199
259
  end
200
260
  end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Langfuse
6
+ # Stale-While-Revalidate caching pattern module
7
+ #
8
+ # Provides SWR functionality for cache implementations. When included,
9
+ # allows serving stale data immediately while refreshing in the background.
10
+ #
11
+ # Including classes must implement:
12
+ # - cache_get(key) - Read from cache
13
+ # - cache_set(key, value) - Write to cache
14
+ # - acquire_lock(lock_key) - Acquire lock for background refresh
15
+ # - release_lock(lock_key) - Release refresh lock
16
+ #
17
+ # @example
18
+ # class MyCache
19
+ # include Langfuse::StaleWhileRevalidate
20
+ #
21
+ # def initialize(ttl: 60, stale_ttl: 0)
22
+ # @ttl = ttl
23
+ # @stale_ttl = stale_ttl
24
+ # @logger = Logger.new($stdout)
25
+ # initialize_swr if stale_ttl.positive?
26
+ # end
27
+ #
28
+ # def cache_get(key)
29
+ # @storage[key]
30
+ # end
31
+ #
32
+ # def cache_set(key, value)
33
+ # @storage[key] = value
34
+ # end
35
+ #
36
+ # def acquire_lock(lock_key)
37
+ # # Implementation-specific lock acquisition
38
+ # end
39
+ #
40
+ # def release_lock(lock_key)
41
+ # # Implementation-specific lock release
42
+ # end
43
+ # end
44
+ module StaleWhileRevalidate
45
+ # Initialize SWR infrastructure
46
+ #
47
+ # Must be called by including class after setting @stale_ttl, @ttl, and @logger.
48
+ # Typically called in the class's initialize method when stale_ttl is provided.
49
+ #
50
+ # @param refresh_threads [Integer] Number of background refresh threads (default: 5)
51
+ # @return [void]
52
+ def initialize_swr(refresh_threads: 5)
53
+ @thread_pool = initialize_thread_pool(refresh_threads)
54
+ end
55
+
56
+ # Fetch a value from cache with Stale-While-Revalidate support
57
+ #
58
+ # This method implements SWR caching: serves stale data immediately while
59
+ # refreshing in the background. Requires SWR to be enabled (stale_ttl must be positive).
60
+ #
61
+ # Three cache states:
62
+ # - FRESH: Return immediately, no action needed
63
+ # - STALE: Return stale data + trigger background refresh
64
+ # - EXPIRED: Must fetch fresh data synchronously
65
+ #
66
+ # @param key [String] Cache key
67
+ # @yield Block to execute to fetch fresh data
68
+ # @return [Object] Cached, stale, or freshly fetched value
69
+ # @raise [ConfigurationError] if SWR is not enabled (stale_ttl is not positive)
70
+ #
71
+ # @example
72
+ # cache.fetch_with_stale_while_revalidate("greeting:v1") do
73
+ # api_client.get_prompt("greeting")
74
+ # end
75
+ def fetch_with_stale_while_revalidate(key, &)
76
+ raise ConfigurationError, "fetch_with_stale_while_revalidate requires a positive stale_ttl" unless swr_enabled?
77
+
78
+ entry = cache_get(key)
79
+
80
+ if entry&.fresh?
81
+ # FRESH - return immediately
82
+ logger.debug("CACHE HIT!")
83
+ entry.data
84
+ elsif entry&.stale?
85
+ # REVALIDATE - return stale + refresh in background
86
+ logger.debug("CACHE STALE!")
87
+ schedule_refresh(key, &)
88
+ entry.data # Instant response!
89
+ else
90
+ # MISS - must fetch synchronously
91
+ logger.debug("CACHE MISS!")
92
+ fetch_and_cache(key, &)
93
+ end
94
+ end
95
+
96
+ # Check if SWR is enabled
97
+ #
98
+ # SWR is enabled when stale_ttl is positive, meaning there's a grace period
99
+ # where stale data can be served while revalidating in the background.
100
+ #
101
+ # @return [Boolean] true if stale_ttl is positive
102
+ def swr_enabled?
103
+ stale_ttl.positive?
104
+ end
105
+
106
+ # Shutdown the cache refresh thread pool gracefully
107
+ #
108
+ # @return [void]
109
+ def shutdown
110
+ return unless @thread_pool
111
+
112
+ @thread_pool.shutdown
113
+ @thread_pool.wait_for_termination(5) # Wait up to 5 seconds
114
+ end
115
+
116
+ private
117
+
118
+ # Initialize thread pool for background refresh operations
119
+ #
120
+ # @param refresh_threads [Integer] Maximum number of refresh threads
121
+ # @return [Concurrent::CachedThreadPool]
122
+ def initialize_thread_pool(refresh_threads)
123
+ Concurrent::CachedThreadPool.new(
124
+ max_threads: refresh_threads,
125
+ min_threads: 0,
126
+ max_queue: 50,
127
+ fallback_policy: :discard
128
+ )
129
+ end
130
+
131
+ # Schedule a background refresh for a cache key
132
+ #
133
+ # Prevents duplicate refreshes by using a fetch lock. If another process/thread
134
+ # is already refreshing this key, this method returns immediately.
135
+ #
136
+ # Errors during refresh are caught and logged to prevent thread crashes.
137
+ #
138
+ # @param key [String] Cache key
139
+ # @yield Block to execute to fetch fresh data
140
+ # @return [void]
141
+ def schedule_refresh(key, &block)
142
+ # Prevent duplicate refreshes
143
+ lock_key = build_lock_key(key)
144
+ return unless acquire_lock(lock_key)
145
+
146
+ @thread_pool.post do
147
+ value = yield block
148
+ set_cache_entry(key, value)
149
+ rescue StandardError => e
150
+ logger.error("Langfuse cache refresh failed for key '#{key}': #{e.class} - #{e.message}")
151
+ ensure
152
+ release_lock(lock_key)
153
+ end
154
+ end
155
+
156
+ # Fetch data and cache it with SWR metadata
157
+ #
158
+ # @param key [String] Cache key
159
+ # @yield Block to execute to fetch fresh data
160
+ # @return [Object] Freshly fetched value
161
+ def fetch_and_cache(key, &block)
162
+ value = yield block
163
+ set_cache_entry(key, value)
164
+ end
165
+
166
+ # Set value in cache with SWR metadata (CacheEntry)
167
+ #
168
+ # @param key [String] Cache key
169
+ # @param value [Object] Value to cache
170
+ # @return [Object] The cached value
171
+ def set_cache_entry(key, value)
172
+ now = Time.now
173
+ fresh_until = now + ttl
174
+ stale_until = fresh_until + stale_ttl
175
+ entry = PromptCache::CacheEntry.new(value, fresh_until, stale_until)
176
+
177
+ cache_set(key, entry)
178
+
179
+ value
180
+ end
181
+
182
+ # Build a lock key for fetch operations
183
+ #
184
+ # Can be overridden by including class if custom key format is needed.
185
+ #
186
+ # @param key [String] Cache key
187
+ # @return [String] Lock key
188
+ def build_lock_key(key)
189
+ "#{key}:lock"
190
+ end
191
+
192
+ # Calculate total TTL (fresh + stale)
193
+ #
194
+ # @return [Integer] Total TTL in seconds
195
+ def total_ttl
196
+ ttl + stale_ttl
197
+ end
198
+
199
+ # Abstract methods that must be implemented by including class
200
+
201
+ # Get a value from cache
202
+ #
203
+ # @param key [String] Cache key
204
+ # @return [Object, nil] Cached value or nil
205
+ # @raise [NotImplementedError] if not implemented by including class
206
+ def cache_get(_key)
207
+ raise NotImplementedError, "#{self.class} must implement #cache_get"
208
+ end
209
+
210
+ # Set a value in cache
211
+ #
212
+ # @param key [String] Cache key
213
+ # @param value [Object] Value to cache
214
+ # @return [Object] The cached value
215
+ # @raise [NotImplementedError] if not implemented by including class
216
+ def cache_set(_key, _value)
217
+ raise NotImplementedError, "#{self.class} must implement #cache_set"
218
+ end
219
+
220
+ # Acquire a lock
221
+ #
222
+ # @param lock_key [String] Lock key
223
+ # @return [Boolean] true if lock was acquired
224
+ # @raise [NotImplementedError] if not implemented by including class
225
+ def acquire_lock(_lock_key)
226
+ raise NotImplementedError, "#{self.class} must implement #acquire_lock"
227
+ end
228
+
229
+ # Release a lock
230
+ #
231
+ # @param lock_key [String] Lock key
232
+ # @return [void]
233
+ # @raise [NotImplementedError] if not implemented by including class
234
+ def release_lock(_lock_key)
235
+ raise NotImplementedError, "#{self.class} must implement #release_lock"
236
+ end
237
+
238
+ # Get TTL value
239
+ #
240
+ # @return [Integer] TTL in seconds
241
+ # @raise [NotImplementedError] if not implemented by including class
242
+ def ttl
243
+ @ttl || raise(NotImplementedError, "#{self.class} must provide @ttl")
244
+ end
245
+
246
+ # Get stale TTL value
247
+ #
248
+ # @return [Integer] Stale TTL in seconds
249
+ # @raise [NotImplementedError] if not implemented by including class
250
+ def stale_ttl
251
+ @stale_ttl || raise(NotImplementedError, "#{self.class} must provide @stale_ttl")
252
+ end
253
+
254
+ # Get logger instance
255
+ #
256
+ # @return [Logger] Logger instance
257
+ # @raise [NotImplementedError] if not implemented by including class
258
+ def logger
259
+ @logger || raise(NotImplementedError, "#{self.class} must provide @logger")
260
+ end
261
+ end
262
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: langfuse-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SimplePractice
@@ -13,30 +13,42 @@ dependencies:
13
13
  name: faraday
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ - - "<"
17
20
  - !ruby/object:Gem::Version
18
- version: '2.0'
21
+ version: '3'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
- version: '2.0'
28
+ version: '1.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: faraday-retry
28
34
  requirement: !ruby/object:Gem::Requirement
29
35
  requirements:
30
- - - "~>"
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ - - "<"
31
40
  - !ruby/object:Gem::Version
32
- version: '2.0'
41
+ version: '3.0'
33
42
  type: :runtime
34
43
  prerelease: false
35
44
  version_requirements: !ruby/object:Gem::Requirement
36
45
  requirements:
37
- - - "~>"
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.0'
49
+ - - "<"
38
50
  - !ruby/object:Gem::Version
39
- version: '2.0'
51
+ version: '3.0'
40
52
  - !ruby/object:Gem::Dependency
41
53
  name: mustache
42
54
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +63,20 @@ dependencies:
51
63
  - - "~>"
52
64
  - !ruby/object:Gem::Version
53
65
  version: '1.1'
66
+ - !ruby/object:Gem::Dependency
67
+ name: concurrent-ruby
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '1.2'
73
+ type: :runtime
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '1.2'
54
80
  - !ruby/object:Gem::Dependency
55
81
  name: opentelemetry-api
56
82
  requirement: !ruby/object:Gem::Requirement
@@ -146,6 +172,7 @@ files:
146
172
  - lib/langfuse/rails_cache_adapter.rb
147
173
  - lib/langfuse/score_client.rb
148
174
  - lib/langfuse/span_processor.rb
175
+ - lib/langfuse/stale_while_revalidate.rb
149
176
  - lib/langfuse/text_prompt_client.rb
150
177
  - lib/langfuse/types.rb
151
178
  - lib/langfuse/version.rb
@@ -171,7 +198,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
171
198
  - !ruby/object:Gem::Version
172
199
  version: '0'
173
200
  requirements: []
174
- rubygems_version: 4.0.2
201
+ rubygems_version: 4.0.3
175
202
  specification_version: 4
176
203
  summary: Ruby SDK for Langfuse - LLM observability and prompt management
177
204
  test_files: []