langfuse-rb 0.8.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require "base64"
4
5
  require_relative "stale_while_revalidate"
5
6
 
6
7
  module Langfuse
@@ -14,9 +15,15 @@ module Langfuse
14
15
  # cache.set("greeting:1", prompt_data)
15
16
  # cache.get("greeting:1") # => prompt_data
16
17
  #
18
+ # rubocop:disable Metrics/ClassLength
17
19
  class PromptCache
18
20
  include StaleWhileRevalidate
19
21
 
22
+ # Caps the per-name generation map. Without a cap, long-lived processes
23
+ # that invalidate across many distinct prompts grow it unboundedly; LRU
24
+ # eviction keeps the working set live and lets cold names go.
25
+ MAX_NAME_GENERATIONS = 1024
26
+
20
27
  # Cache entry with data and expiration time
21
28
  #
22
29
  # Supports stale-while-revalidate pattern:
@@ -79,6 +86,9 @@ module Langfuse
79
86
  @stale_ttl = stale_ttl
80
87
  @logger = logger
81
88
  @cache = {}
89
+ @global_generation = 0
90
+ @name_generations = {}
91
+ @name_generation_counter = 0
82
92
  @monitor = Monitor.new
83
93
  @locks = {} # Track locks for in-memory locking
84
94
  initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
@@ -98,24 +108,45 @@ module Langfuse
98
108
  end
99
109
  end
100
110
 
111
+ # Read a raw cache entry, including stale entries.
112
+ #
113
+ # @param key [String] Cache key
114
+ # @return [CacheEntry, nil] Raw cache entry
115
+ def entry(key)
116
+ @monitor.synchronize do
117
+ @cache[key]
118
+ end
119
+ end
120
+
101
121
  # Set a value in the cache
102
122
  #
103
123
  # @param key [String] Cache key
104
124
  # @param value [Object] Value to cache
105
125
  # @return [Object] The cached value
106
- def set(key, value)
126
+ def set(key, value, ttl: nil, stale_ttl: nil)
107
127
  @monitor.synchronize do
108
128
  # Evict oldest entry if at max size
109
129
  evict_oldest if @cache.size >= max_size
110
-
111
- now = Time.now
112
- fresh_until = now + ttl
113
- stale_until = fresh_until + stale_ttl
114
- @cache[key] = CacheEntry.new(value, fresh_until, stale_until)
130
+ # TTL math is inlined (not extracted to a helper) to keep this hot path
131
+ # allocation-free apart from the CacheEntry below.
132
+ effective_ttl = ttl.nil? ? self.ttl : ttl
133
+ effective_stale_ttl = stale_ttl.nil? ? self.stale_ttl : stale_ttl
134
+ fresh_until = Time.now + effective_ttl
135
+ @cache[key] = CacheEntry.new(value, fresh_until, fresh_until + effective_stale_ttl)
115
136
  value
116
137
  end
117
138
  end
118
139
 
140
+ # Delete one generated storage key.
141
+ #
142
+ # @param key [String] Generated storage key
143
+ # @return [Boolean] true if an entry was removed
144
+ def delete(key)
145
+ @monitor.synchronize do
146
+ !@cache.delete(key).nil?
147
+ end
148
+ end
149
+
119
150
  # Clear the entire cache
120
151
  #
121
152
  # @return [void]
@@ -125,6 +156,65 @@ module Langfuse
125
156
  end
126
157
  end
127
158
 
159
+ # Logically invalidate every generated storage key.
160
+ #
161
+ # @return [Integer] New global generation
162
+ def clear_logically
163
+ @monitor.synchronize do
164
+ @global_generation += 1
165
+ end
166
+ end
167
+
168
+ # Logically invalidate every cache variant for one prompt name.
169
+ #
170
+ # Generations come from a monotonic global counter, not a per-name counter,
171
+ # so an evicted name re-entering the map can't reuse a generation value
172
+ # that's still embedded in a stale @cache entry.
173
+ #
174
+ # @param name [String] Prompt name
175
+ # @return [Integer] New name generation
176
+ def invalidate_name(name)
177
+ @monitor.synchronize do
178
+ name_str = name.to_s
179
+ @name_generations.delete(name_str)
180
+ @name_generations.shift if @name_generations.size >= MAX_NAME_GENERATIONS
181
+ @name_generation_counter += 1
182
+ @name_generations[name_str] = @name_generation_counter
183
+ end
184
+ end
185
+
186
+ # Build a generated storage key for the current cache generation.
187
+ #
188
+ # @param logical_key [String] Stable logical cache identity
189
+ # @param name [String] Prompt name
190
+ # @return [String] Generated storage key
191
+ def storage_key(logical_key, name:)
192
+ @monitor.synchronize do
193
+ self.class.storage_key(
194
+ logical_key,
195
+ name: name,
196
+ global_generation: @global_generation,
197
+ name_generation: @name_generations.fetch(name.to_s, 0)
198
+ )
199
+ end
200
+ end
201
+
202
+ # @return [Hash] Prompt cache statistics
203
+ def stats
204
+ @monitor.synchronize do
205
+ counts = count_entries_by_generation
206
+ {
207
+ backend: CacheBackend::MEMORY,
208
+ enabled: true,
209
+ current_generation_entries: counts.fetch(:current),
210
+ orphaned_entries: counts.fetch(:orphaned),
211
+ total_entries: @cache.size,
212
+ global_generation: @global_generation,
213
+ unsupported_counts: []
214
+ }
215
+ end
216
+ end
217
+
128
218
  # Remove expired entries from cache
129
219
  #
130
220
  # @return [Integer] Number of entries removed
@@ -154,6 +244,15 @@ module Langfuse
154
244
  end
155
245
  end
156
246
 
247
+ # Validate that the memory cache backend is usable.
248
+ #
249
+ # @return [Boolean]
250
+ # rubocop:disable Naming/PredicateMethod
251
+ def validate!
252
+ true
253
+ end
254
+ # rubocop:enable Naming/PredicateMethod
255
+
157
256
  # Build a cache key from prompt name and options
158
257
  #
159
258
  # @param name [String] Prompt name
@@ -168,6 +267,18 @@ module Langfuse
168
267
  key
169
268
  end
170
269
 
270
+ # Build a generated storage key from generation metadata.
271
+ #
272
+ # @param logical_key [String] Stable logical cache identity
273
+ # @param name [String] Prompt name
274
+ # @param global_generation [Integer] Global cache generation
275
+ # @param name_generation [Integer] Prompt-name cache generation
276
+ # @return [String] Generated storage key
277
+ def self.storage_key(logical_key, name:, global_generation:, name_generation:)
278
+ encoded_name = Base64.urlsafe_encode64(name.to_s, padding: false)
279
+ "g#{global_generation}:n#{encoded_name}:#{name_generation}:#{logical_key}"
280
+ end
281
+
171
282
  private
172
283
 
173
284
  # Implementation of StaleWhileRevalidate abstract methods
@@ -187,7 +298,7 @@ module Langfuse
187
298
  # @param key [String] Cache key
188
299
  # @param value [PromptCache::CacheEntry] Value to cache
189
300
  # @return [PromptCache::CacheEntry] The cached value
190
- def cache_set(key, value)
301
+ def cache_set(key, value, **_options)
191
302
  @monitor.synchronize do
192
303
  # Evict oldest entry if at max size
193
304
  evict_oldest if @cache.size >= max_size
@@ -230,6 +341,29 @@ module Langfuse
230
341
  end
231
342
  end
232
343
 
344
+ def count_entries_by_generation
345
+ @cache.each_key.with_object({ current: 0, orphaned: 0 }) do |key, counts|
346
+ if current_generation_key?(key)
347
+ counts[:current] += 1
348
+ else
349
+ counts[:orphaned] += 1
350
+ end
351
+ end
352
+ end
353
+
354
+ def current_generation_key?(key)
355
+ parts = key.split(":", 4)
356
+ return false unless parts.size == 4
357
+ return false unless parts[0].start_with?("g") && parts[1].start_with?("n")
358
+
359
+ global = Integer(parts[0][1..])
360
+ name = Base64.urlsafe_decode64(parts[1][1..])
361
+ name_generation = Integer(parts[2])
362
+ global == @global_generation && name_generation == @name_generations.fetch(name, 0)
363
+ rescue ArgumentError
364
+ false
365
+ end
366
+
233
367
  # In-memory cache helper methods
234
368
 
235
369
  # Evict the oldest entry from cache
@@ -250,4 +384,5 @@ module Langfuse
250
384
  Logger.new($stdout, level: Logger::WARN)
251
385
  end
252
386
  end
387
+ # rubocop:enable Metrics/ClassLength
253
388
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Prompt cache event emission for ApiClient.
5
+ #
6
+ # Includers must expose:
7
+ # - `cache_backend_name` — used in {#event_payload} to tag the cache backend
8
+ # - `logger` — used to warn on observer/notifier failures
9
+ module PromptCacheEvents
10
+ # ActiveSupport::Notifications event name used for prompt cache events.
11
+ PROMPT_CACHE_NOTIFICATION = "prompt_cache.langfuse"
12
+
13
+ # Configure prompt cache event dispatch. Wraps the observer once into a
14
+ # 1-arg callable so the per-event hot path never re-checks arity.
15
+ #
16
+ # @param cache_observer [#call, nil] Optional observer
17
+ # @return [void]
18
+ def setup_prompt_cache_events(cache_observer:)
19
+ @cache_observer_callable = wrap_cache_observer(cache_observer)
20
+ @active_support_notifications = defined?(ActiveSupport::Notifications) ? ActiveSupport::Notifications : nil
21
+ end
22
+
23
+ # Emit a prompt cache event to configured hooks. Accepts an eager payload
24
+ # hash or a block that builds one. The block is only evaluated when at
25
+ # least one listener is active, avoiding hash allocations on the hot path.
26
+ #
27
+ # @param event [Symbol] Event name
28
+ # @param payload [Hash, nil] Event payload (omit when passing a block)
29
+ # @yieldreturn [Hash] Lazily constructed payload
30
+ # @return [void]
31
+ def emit_prompt_cache_event(event, payload = nil)
32
+ observer_callable = @cache_observer_callable
33
+ as_listening = active_support_listening?
34
+ return if observer_callable.nil? && !as_listening
35
+
36
+ payload ||= block_given? ? yield : {}
37
+ normalized_payload = payload.merge(event: event.to_sym)
38
+ notify_cache_observer(normalized_payload) if observer_callable
39
+ notify_active_support(normalized_payload) if as_listening
40
+ end
41
+
42
+ # Emit a fallback event for a prompt fetch that fell back to caller-provided content.
43
+ #
44
+ # @param key [PromptCacheKey] Logical and storage cache key
45
+ # @param cache_status [Symbol] Cache status to report
46
+ # @param error [StandardError] The error that triggered the fallback
47
+ # @return [void]
48
+ def emit_prompt_fallback_event(key, cache_status:, error:)
49
+ emit_prompt_cache_event(:fallback) do
50
+ event_payload(key, cache_status, CacheSource::FALLBACK,
51
+ error_class: error.class.name, error_message: error.message)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # @api private
58
+ def event_payload(key, cache_status, source, extra = {})
59
+ {
60
+ name: key.name,
61
+ version: key.version,
62
+ label: key.resolved_label,
63
+ logical_key: key.logical_key,
64
+ storage_key: key.storage_key,
65
+ backend: cache_backend_name,
66
+ cache_status: cache_status,
67
+ source: source
68
+ }.merge(extra)
69
+ end
70
+
71
+ # @api private
72
+ def notify_cache_observer(payload)
73
+ @cache_observer_callable.call(payload)
74
+ rescue StandardError => e
75
+ logger.warn("Langfuse prompt cache observer failed: #{e.class} - #{e.message}")
76
+ end
77
+
78
+ # @api private
79
+ def active_support_listening?
80
+ return false unless @active_support_notifications
81
+
82
+ notifier = @active_support_notifications.notifier
83
+ # Defensive: notifier stand-ins (test fakes, AS::Notifications forks,
84
+ # very old AS versions) may not implement listening?. Assume they're
85
+ # listening so we still attempt to instrument; notify_active_support
86
+ # rescues failures.
87
+ return true unless notifier.respond_to?(:listening?)
88
+
89
+ notifier.listening?(PROMPT_CACHE_NOTIFICATION)
90
+ end
91
+
92
+ # @api private
93
+ def notify_active_support(payload)
94
+ @active_support_notifications.instrument(PROMPT_CACHE_NOTIFICATION, payload)
95
+ rescue StandardError => e
96
+ logger.warn("Langfuse ActiveSupport cache notification failed: #{e.class} - #{e.message}")
97
+ end
98
+
99
+ # @api private
100
+ def wrap_cache_observer(observer)
101
+ return nil if observer.nil?
102
+
103
+ if observer.method(:call).arity == 1
104
+ ->(payload) { observer.call(payload) }
105
+ else
106
+ ->(payload) { observer.call(payload[:event], payload) }
107
+ end
108
+ end
109
+ end
110
+ end
@@ -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
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mustache"
4
+
5
+ module Langfuse
6
+ # Renders prompt templates with Langfuse SDK-compatible variable semantics.
7
+ #
8
+ # @api private
9
+ class PromptRenderer < Mustache
10
+ # Langfuse variables are model input, not browser output; JS/Python SDKs substitute raw values.
11
+ #
12
+ # @param value [Object] Value to insert into the prompt
13
+ # @return [String] Raw string representation
14
+ def escape(value)
15
+ value.to_s
16
+ end
17
+ end
18
+ end