langfuse-rb 0.9.0 → 0.10.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.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Public metadata returned by prompt fetch operations.
5
+ class PromptFetchResult
6
+ # @return [Object] Prompt data or prompt client returned by the fetch
7
+ attr_reader :prompt
8
+
9
+ # @return [String] Stable logical cache identity
10
+ attr_reader :logical_key
11
+
12
+ # @return [String] Generated backend key for the current cache generation
13
+ attr_reader :storage_key
14
+
15
+ # @return [Symbol] Cache status (:hit, :miss, :stale, :refresh, :bypass, :disabled)
16
+ attr_reader :cache_status
17
+
18
+ # @return [Symbol] Source of the returned prompt (:cache, :api, :fallback)
19
+ attr_reader :source
20
+
21
+ # @return [String] Prompt name
22
+ attr_reader :name
23
+
24
+ # @return [Integer, nil] Prompt version
25
+ attr_reader :version
26
+
27
+ # @return [String, nil] Prompt label
28
+ attr_reader :label
29
+
30
+ # @param prompt [Object] Prompt data or prompt client
31
+ # @param logical_key [String] Stable logical cache identity
32
+ # @param storage_key [String] Generated backend key
33
+ # @param cache_status [Symbol] Cache status
34
+ # @param source [Symbol] Prompt source
35
+ # @param name [String] Prompt name
36
+ # @param version [Integer, nil] Prompt version
37
+ # @param label [String, nil] Prompt label
38
+ # @return [PromptFetchResult]
39
+ # rubocop:disable Metrics/ParameterLists
40
+ def initialize(prompt:, logical_key:, storage_key:, cache_status:, source:, name:, version: nil, label: nil)
41
+ @prompt = prompt
42
+ @logical_key = logical_key
43
+ @storage_key = storage_key
44
+ @cache_status = cache_status
45
+ @source = source
46
+ @name = name
47
+ @version = version
48
+ @label = label
49
+ end
50
+ # rubocop:enable Metrics/ParameterLists
51
+
52
+ # @return [Boolean] Whether this result used caller-provided fallback content
53
+ def fallback?
54
+ source == CacheSource::FALLBACK
55
+ end
56
+
57
+ # @return [Hash] Result metadata as a hash
58
+ def to_h
59
+ {
60
+ logical_key: logical_key,
61
+ storage_key: storage_key,
62
+ cache_status: cache_status,
63
+ source: source,
64
+ name: name,
65
+ version: version,
66
+ label: label,
67
+ fallback: fallback?
68
+ }
69
+ end
70
+ end
71
+
72
+ # Public key inspection result for prompt cache operations.
73
+ class PromptCacheKey
74
+ # @return [String] Prompt name
75
+ attr_reader :name
76
+
77
+ # @return [Integer, nil] Prompt version
78
+ attr_reader :version
79
+
80
+ # @return [String, nil] Prompt label
81
+ attr_reader :label
82
+
83
+ # @return [String] Stable logical cache identity
84
+ attr_reader :logical_key
85
+
86
+ # @return [String] Generated backend key for the current cache generation
87
+ attr_reader :storage_key
88
+
89
+ # @param name [String] Prompt name
90
+ # @param logical_key [String] Stable logical cache identity
91
+ # @param storage_key [String] Generated backend key
92
+ # @param version [Integer, nil] Prompt version
93
+ # @param label [String, nil] Prompt label
94
+ # @return [PromptCacheKey]
95
+ def initialize(name:, logical_key:, storage_key:, version: nil, label: nil)
96
+ @name = name
97
+ @version = version
98
+ @label = label
99
+ @logical_key = logical_key
100
+ @storage_key = storage_key
101
+ end
102
+
103
+ # Resolve the effective label, defaulting to "production" when neither
104
+ # an explicit label nor a version was specified.
105
+ #
106
+ # @return [String, nil] Effective label
107
+ def resolved_label
108
+ label || (version ? nil : "production")
109
+ end
110
+
111
+ # @return [Hash] Cache key data as a hash
112
+ def to_h
113
+ {
114
+ name: name,
115
+ version: version,
116
+ label: label,
117
+ logical_key: logical_key,
118
+ storage_key: storage_key
119
+ }
120
+ end
121
+ end
122
+ end
@@ -14,9 +14,12 @@ module Langfuse
14
14
  # adapter.set("greeting:1", prompt_data)
15
15
  # adapter.get("greeting:1") # => prompt_data
16
16
  #
17
+ # rubocop:disable Metrics/ClassLength
17
18
  class RailsCacheAdapter
18
19
  include StaleWhileRevalidate
19
20
 
21
+ GENERATION_MEMO_TTL_SECONDS = 1.0
22
+
20
23
  # @return [Integer] Time-to-live in seconds
21
24
  attr_reader :ttl
22
25
 
@@ -51,9 +54,12 @@ module Langfuse
51
54
 
52
55
  @ttl = ttl
53
56
  @namespace = namespace
57
+ @namespace_prefix = "#{namespace}:"
54
58
  @lock_timeout = lock_timeout
55
59
  @stale_ttl = stale_ttl
56
60
  @logger = logger
61
+ @generation_memo = {}
62
+ @generation_memo_mutex = Mutex.new
57
63
  initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
58
64
  end
59
65
 
@@ -65,18 +71,37 @@ module Langfuse
65
71
  Rails.cache.read(namespaced_key(key))
66
72
  end
67
73
 
74
+ # Read a raw cache entry, including stale entries.
75
+ #
76
+ # @param key [String] Cache key
77
+ # @return [Object, nil] Raw cache entry
78
+ def entry(key)
79
+ Rails.cache.read(namespaced_key(key))
80
+ end
81
+
68
82
  # Set a value in the cache
69
83
  #
70
84
  # @param key [String] Cache key
71
85
  # @param value [Object] Value to cache
72
86
  # @return [Object] The cached value
73
- def set(key, value)
74
- # Calculate expiration: use total_ttl if SWR enabled, otherwise just ttl
75
- expires_in = swr_enabled? ? total_ttl : ttl
87
+ def set(key, value, ttl: nil, stale_ttl: nil)
88
+ # Total ttl when SWR is enabled, otherwise just ttl. Inlined (not pushed
89
+ # to a shared helper) to keep this hot write path allocation-free.
90
+ effective_ttl = ttl.nil? ? self.ttl : ttl
91
+ effective_stale_ttl = stale_ttl.nil? ? self.stale_ttl : stale_ttl
92
+ expires_in = swr_enabled? ? effective_ttl + effective_stale_ttl : effective_ttl
76
93
  Rails.cache.write(namespaced_key(key), value, expires_in:)
77
94
  value
78
95
  end
79
96
 
97
+ # Delete one generated storage key.
98
+ #
99
+ # @param key [String] Cache key
100
+ # @return [Boolean] true if an entry was removed
101
+ def delete(key)
102
+ Rails.cache.delete(namespaced_key(key))
103
+ end
104
+
80
105
  # Clear the entire Langfuse cache namespace
81
106
  #
82
107
  # Note: This uses delete_matched which may not be available on all cache stores.
@@ -86,6 +111,37 @@ module Langfuse
86
111
  def clear
87
112
  # Delete all keys matching the namespace pattern
88
113
  Rails.cache.delete_matched("#{namespace}:*")
114
+ clear_generation_memo
115
+ end
116
+
117
+ # Logically invalidate every generated storage key.
118
+ #
119
+ # @return [Integer] New global generation
120
+ def clear_logically
121
+ bump_generation(global_generation_key)
122
+ end
123
+
124
+ # Logically invalidate every cache variant for one prompt name.
125
+ #
126
+ # @param name [String] Prompt name
127
+ # @return [Integer] New name generation
128
+ def invalidate_name(name)
129
+ bump_generation(name_generation_key(name))
130
+ end
131
+
132
+ # Build a generated storage key for the current cache generation.
133
+ #
134
+ # @param logical_key [String] Stable logical cache identity
135
+ # @param name [String] Prompt name
136
+ # @return [String] Generated storage key
137
+ def storage_key(logical_key, name:)
138
+ generated = PromptCache.storage_key(
139
+ logical_key,
140
+ name: name,
141
+ global_generation: generation_value(global_generation_key),
142
+ name_generation: generation_value(name_generation_key(name))
143
+ )
144
+ namespaced_key(generated)
89
145
  end
90
146
 
91
147
  # Get current cache size
@@ -98,6 +154,19 @@ module Langfuse
98
154
  nil
99
155
  end
100
156
 
157
+ # @return [Hash] Prompt cache statistics
158
+ def stats
159
+ {
160
+ backend: CacheBackend::RAILS,
161
+ enabled: true,
162
+ current_generation_entries: nil,
163
+ orphaned_entries: nil,
164
+ total_entries: nil,
165
+ global_generation: generation_value(global_generation_key),
166
+ unsupported_counts: CacheBackend::UNSUPPORTED_COUNT_KEYS
167
+ }
168
+ end
169
+
101
170
  # Check if cache is empty
102
171
  #
103
172
  # Note: Rails.cache doesn't provide an efficient way to check if empty,
@@ -108,6 +177,17 @@ module Langfuse
108
177
  false
109
178
  end
110
179
 
180
+ # Validate that Rails.cache is available for prompt caching.
181
+ #
182
+ # @return [Boolean]
183
+ # @raise [ConfigurationError] if Rails.cache is not available
184
+ # rubocop:disable Naming/PredicateMethod
185
+ def validate!
186
+ validate_rails_cache!
187
+ true
188
+ end
189
+ # rubocop:enable Naming/PredicateMethod
190
+
111
191
  # Build a cache key from prompt name and options
112
192
  #
113
193
  # @param name [String] Prompt name
@@ -135,7 +215,7 @@ module Langfuse
135
215
  # cache.fetch_with_lock("greeting:v1") do
136
216
  # api_client.get_prompt("greeting")
137
217
  # end
138
- def fetch_with_lock(key)
218
+ def fetch_with_lock(key, ttl: nil)
139
219
  # 1. Check cache first (fast path - no lock needed)
140
220
  cached = get(key)
141
221
  return cached if cached
@@ -147,7 +227,7 @@ module Langfuse
147
227
  begin
148
228
  # We got the lock - fetch from source and populate cache
149
229
  value = yield
150
- set(key, value)
230
+ set(key, value, ttl: ttl)
151
231
  value
152
232
  ensure
153
233
  # Always release lock, even if block raises
@@ -173,7 +253,7 @@ module Langfuse
173
253
  # @param key [String] Cache key
174
254
  # @return [Object, nil] Cached value
175
255
  def cache_get(key)
176
- get(key)
256
+ entry(key)
177
257
  end
178
258
 
179
259
  # Set value in cache (SWR interface)
@@ -181,8 +261,9 @@ module Langfuse
181
261
  # @param key [String] Cache key
182
262
  # @param value [Object] Value to cache (expects CacheEntry)
183
263
  # @return [Object] The cached value
184
- def cache_set(key, value)
185
- set(key, value)
264
+ def cache_set(key, value, ttl: nil)
265
+ Rails.cache.write(namespaced_key(key), value, expires_in: ttl || total_ttl)
266
+ value
186
267
  end
187
268
 
188
269
  # Build lock key with namespace
@@ -248,7 +329,77 @@ module Langfuse
248
329
  # @param key [String] Original cache key
249
330
  # @return [String] Namespaced cache key
250
331
  def namespaced_key(key)
251
- "#{namespace}:#{key}"
332
+ key.start_with?(@namespace_prefix) ? key : "#{@namespace_prefix}#{key}"
333
+ end
334
+
335
+ def global_generation_key
336
+ namespaced_key("__prompt_cache_generation__:global")
337
+ end
338
+
339
+ def name_generation_key(name)
340
+ encoded_name = Base64.urlsafe_encode64(name.to_s, padding: false)
341
+ namespaced_key("__prompt_cache_generation__:name:#{encoded_name}")
342
+ end
343
+
344
+ def generation_value(key)
345
+ now = monotonic_time
346
+ memoized = memoized_generation_value(key, now)
347
+ return memoized unless memoized.nil?
348
+
349
+ Rails.cache.read(key).to_i.tap do |value|
350
+ memoize_generation_value(key, value, now)
351
+ end
352
+ end
353
+
354
+ def bump_generation(key)
355
+ incremented = increment_generation(key)
356
+ if incremented
357
+ memoize_generation_value(key, incremented.to_i)
358
+ return incremented
359
+ end
360
+
361
+ new_value = generation_value(key) + 1
362
+ Rails.cache.write(key, new_value)
363
+ memoize_generation_value(key, new_value)
364
+ new_value
365
+ end
366
+
367
+ def increment_generation(key)
368
+ return unless Rails.cache.respond_to?(:increment)
369
+
370
+ Rails.cache.write(key, 0, unless_exist: true)
371
+ Rails.cache.increment(key, 1)
372
+ rescue StandardError => e
373
+ logger.warn("Langfuse prompt cache generation increment failed for key '#{key}': #{e.class} - #{e.message}")
374
+ nil
375
+ end
376
+
377
+ def memoized_generation_value(key, now)
378
+ @generation_memo_mutex.synchronize do
379
+ entry = @generation_memo[key]
380
+ return nil unless entry
381
+
382
+ return entry.fetch(:value) if now < entry.fetch(:expires_at)
383
+
384
+ @generation_memo.delete(key)
385
+ nil
386
+ end
387
+ end
388
+
389
+ def memoize_generation_value(key, value, now = monotonic_time)
390
+ @generation_memo_mutex.synchronize do
391
+ @generation_memo[key] = { value: value, expires_at: now + GENERATION_MEMO_TTL_SECONDS }
392
+ end
393
+ end
394
+
395
+ def clear_generation_memo
396
+ @generation_memo_mutex.synchronize do
397
+ @generation_memo.clear
398
+ end
399
+ end
400
+
401
+ def monotonic_time
402
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
403
  end
253
404
 
254
405
  # Validate that Rails.cache is available
@@ -273,4 +424,5 @@ module Langfuse
273
424
  end
274
425
  end
275
426
  end
427
+ # rubocop:enable Metrics/ClassLength
276
428
  end
@@ -41,6 +41,7 @@ module Langfuse
41
41
  # # Implementation-specific lock release
42
42
  # end
43
43
  # end
44
+ # rubocop:disable Metrics/ModuleLength
44
45
  module StaleWhileRevalidate
45
46
  # Initialize SWR infrastructure
46
47
  #
@@ -72,7 +73,7 @@ module Langfuse
72
73
  # cache.fetch_with_stale_while_revalidate("greeting:v1") do
73
74
  # api_client.get_prompt("greeting")
74
75
  # end
75
- def fetch_with_stale_while_revalidate(key, &)
76
+ def fetch_with_stale_while_revalidate(key, ttl: nil, stale_ttl: nil, &)
76
77
  raise ConfigurationError, "fetch_with_stale_while_revalidate requires a positive stale_ttl" unless swr_enabled?
77
78
 
78
79
  entry = cache_get(key)
@@ -84,15 +85,50 @@ module Langfuse
84
85
  elsif entry&.stale?
85
86
  # REVALIDATE - return stale + refresh in background
86
87
  logger.debug("CACHE STALE!")
87
- schedule_refresh(key, &)
88
+ schedule_refresh(key, ttl: ttl, stale_ttl: stale_ttl, &)
88
89
  entry.data # Instant response!
89
90
  else
90
91
  # MISS - must fetch synchronously
91
92
  logger.debug("CACHE MISS!")
92
- fetch_and_cache(key, &)
93
+ fetch_and_cache(key, ttl: ttl, stale_ttl: stale_ttl, &)
93
94
  end
94
95
  end
95
96
 
97
+ # Schedule a cache refresh without performing a read.
98
+ #
99
+ # @param key [String] Cache key
100
+ # @param ttl [Integer, nil] Optional fresh TTL override
101
+ # @param stale_ttl [Integer, nil] Optional stale TTL override
102
+ # @param on_success [#call, nil] Callback invoked after a successful write
103
+ # @param on_failure [#call, nil] Callback invoked when refresh raises
104
+ # @yield Block to execute to fetch fresh data
105
+ # @return [Boolean] true if a refresh was scheduled
106
+ def refresh_async(key, ttl: nil, stale_ttl: nil, on_success: nil, on_failure: nil, &)
107
+ raise ConfigurationError, "refresh_async requires a positive stale_ttl" unless swr_enabled?
108
+
109
+ schedule_refresh(
110
+ key,
111
+ ttl: ttl,
112
+ stale_ttl: stale_ttl,
113
+ on_success: on_success,
114
+ on_failure: on_failure,
115
+ &
116
+ )
117
+ end
118
+
119
+ # Write a value with stale-while-revalidate metadata.
120
+ #
121
+ # @param key [String] Cache key
122
+ # @param value [Object] Value to cache
123
+ # @param ttl [Integer, nil] Optional fresh TTL override
124
+ # @param stale_ttl [Integer, nil] Optional stale TTL override
125
+ # @return [Object] The cached value
126
+ def write_with_stale_while_revalidate(key, value, ttl: nil, stale_ttl: nil)
127
+ raise ConfigurationError, "write_with_stale_while_revalidate requires a positive stale_ttl" unless swr_enabled?
128
+
129
+ set_cache_entry(key, value, ttl: ttl, stale_ttl: stale_ttl)
130
+ end
131
+
96
132
  # Check if SWR is enabled
97
133
  #
98
134
  # SWR is enabled when stale_ttl is positive, meaning there's a grace period
@@ -138,29 +174,35 @@ module Langfuse
138
174
  # @param key [String] Cache key
139
175
  # @yield Block to execute to fetch fresh data
140
176
  # @return [void]
141
- def schedule_refresh(key, &block)
177
+ # rubocop:disable Naming/PredicateMethod
178
+ def schedule_refresh(key, ttl: nil, stale_ttl: nil, on_success: nil, on_failure: nil, &block)
142
179
  # Prevent duplicate refreshes
143
180
  lock_key = build_lock_key(key)
144
- return unless acquire_lock(lock_key)
181
+ return false unless acquire_lock(lock_key)
145
182
 
146
183
  @thread_pool.post do
147
- value = yield block
148
- set_cache_entry(key, value)
184
+ value = block.call
185
+ set_cache_entry(key, value, ttl: ttl, stale_ttl: stale_ttl)
186
+ on_success&.call(value)
149
187
  rescue StandardError => e
188
+ on_failure&.call(e)
150
189
  logger.error("Langfuse cache refresh failed for key '#{key}': #{e.class} - #{e.message}")
151
190
  ensure
152
191
  release_lock(lock_key)
153
192
  end
193
+
194
+ true
154
195
  end
196
+ # rubocop:enable Naming/PredicateMethod
155
197
 
156
198
  # Fetch data and cache it with SWR metadata
157
199
  #
158
200
  # @param key [String] Cache key
159
201
  # @yield Block to execute to fetch fresh data
160
202
  # @return [Object] Freshly fetched value
161
- def fetch_and_cache(key, &block)
162
- value = yield block
163
- set_cache_entry(key, value)
203
+ def fetch_and_cache(key, ttl: nil, stale_ttl: nil, &block)
204
+ value = block.call
205
+ set_cache_entry(key, value, ttl: ttl, stale_ttl: stale_ttl)
164
206
  end
165
207
 
166
208
  # Set value in cache with SWR metadata (CacheEntry)
@@ -168,14 +210,14 @@ module Langfuse
168
210
  # @param key [String] Cache key
169
211
  # @param value [Object] Value to cache
170
212
  # @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
-
213
+ def set_cache_entry(key, value, ttl: nil, stale_ttl: nil)
214
+ # TTL math is inlined (not extracted to a helper) to keep this hot write
215
+ # path allocation-free apart from the CacheEntry below.
216
+ effective_ttl = ttl.nil? ? self.ttl : ttl
217
+ effective_stale_ttl = stale_ttl.nil? ? self.stale_ttl : stale_ttl
218
+ fresh_until = Time.now + effective_ttl
219
+ entry = PromptCache::CacheEntry.new(value, fresh_until, fresh_until + effective_stale_ttl)
220
+ cache_set(key, entry, ttl: effective_ttl + effective_stale_ttl)
179
221
  value
180
222
  end
181
223
 
@@ -213,7 +255,7 @@ module Langfuse
213
255
  # @param value [Object] Value to cache
214
256
  # @return [Object] The cached value
215
257
  # @raise [NotImplementedError] if not implemented by including class
216
- def cache_set(_key, _value)
258
+ def cache_set(_key, _value, ttl: nil)
217
259
  raise NotImplementedError, "#{self.class} must implement #cache_set"
218
260
  end
219
261
 
@@ -259,4 +301,5 @@ module Langfuse
259
301
  @logger || raise(NotImplementedError, "#{self.class} must provide @logger")
260
302
  end
261
303
  end
304
+ # rubocop:enable Metrics/ModuleLength
262
305
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -40,9 +40,12 @@ module Langfuse
40
40
  end
41
41
 
42
42
  require_relative "langfuse/config"
43
+ require_relative "langfuse/cache_constants"
43
44
  require_relative "langfuse/prompt_cache"
45
+ require_relative "langfuse/prompt_fetch_result"
44
46
  require_relative "langfuse/rails_cache_adapter"
45
47
  require_relative "langfuse/cache_warmer"
48
+ require_relative "langfuse/prompt_cache_events"
46
49
  require_relative "langfuse/api_client"
47
50
  require_relative "langfuse/span_filter"
48
51
  require_relative "langfuse/sampling"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: langfuse-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SimplePractice
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-05-05 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -160,6 +161,7 @@ files:
160
161
  - README.md
161
162
  - lib/langfuse.rb
162
163
  - lib/langfuse/api_client.rb
164
+ - lib/langfuse/cache_constants.rb
163
165
  - lib/langfuse/cache_warmer.rb
164
166
  - lib/langfuse/chat_prompt_client.rb
165
167
  - lib/langfuse/client.rb
@@ -176,6 +178,8 @@ files:
176
178
  - lib/langfuse/otel_attributes.rb
177
179
  - lib/langfuse/otel_setup.rb
178
180
  - lib/langfuse/prompt_cache.rb
181
+ - lib/langfuse/prompt_cache_events.rb
182
+ - lib/langfuse/prompt_fetch_result.rb
179
183
  - lib/langfuse/prompt_renderer.rb
180
184
  - lib/langfuse/propagation.rb
181
185
  - lib/langfuse/rails_cache_adapter.rb
@@ -198,6 +202,7 @@ metadata:
198
202
  source_code_uri: https://github.com/simplepractice/langfuse-rb
199
203
  changelog_uri: https://github.com/simplepractice/langfuse-rb/blob/main/CHANGELOG.md
200
204
  rubygems_mfa_required: 'true'
205
+ post_install_message:
201
206
  rdoc_options: []
202
207
  require_paths:
203
208
  - lib
@@ -212,7 +217,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
212
217
  - !ruby/object:Gem::Version
213
218
  version: '0'
214
219
  requirements: []
215
- rubygems_version: 4.0.8
220
+ rubygems_version: 3.4.1
221
+ signing_key:
216
222
  specification_version: 4
217
223
  summary: Ruby SDK for Langfuse - LLM observability and prompt management
218
224
  test_files: []