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.
@@ -46,7 +46,8 @@ module Langfuse
46
46
  base_url: config.base_url,
47
47
  timeout: config.timeout,
48
48
  logger: config.logger,
49
- cache: cache
49
+ cache: cache,
50
+ cache_observer: config.prompt_cache_observer
50
51
  )
51
52
 
52
53
  @project_id = nil
@@ -68,6 +69,7 @@ module Langfuse
68
69
  # @param label [String, nil] Optional label (e.g., "production", "latest")
69
70
  # @param fallback [String, Array, nil] Optional fallback prompt to use on error
70
71
  # @param type [Symbol, nil] Required when fallback is provided (:text or :chat)
72
+ # @param cache_ttl [Integer, nil] Optional TTL override for this fetch
71
73
  # @return [TextPromptClient, ChatPromptClient] The prompt client
72
74
  # @raise [ArgumentError] if both version and label are provided
73
75
  # @raise [ArgumentError] if fallback is provided without type
@@ -77,24 +79,114 @@ module Langfuse
77
79
  #
78
80
  # @example With fallback for graceful degradation
79
81
  # prompt = client.get_prompt("greeting", fallback: "Hello {{name}}!", type: :text)
80
- def get_prompt(name, version: nil, label: nil, fallback: nil, type: nil)
81
- # Validate fallback usage
82
- if fallback && !type
83
- raise ArgumentError, "type parameter is required when fallback is provided (use :text or :chat)"
84
- end
82
+ def get_prompt(name, version: nil, label: nil, fallback: nil, type: nil, cache_ttl: nil)
83
+ get_prompt_result(
84
+ name,
85
+ version: version,
86
+ label: label,
87
+ fallback: fallback,
88
+ type: type,
89
+ cache_ttl: cache_ttl
90
+ ).prompt
91
+ end
85
92
 
86
- # Try to fetch from API
87
- prompt_data = api_client.get_prompt(name, version: version, label: label)
88
- build_prompt_client(prompt_data)
93
+ # Fetch a prompt and return cache metadata.
94
+ #
95
+ # @param name [String] The name of the prompt
96
+ # @param version [Integer, nil] Optional specific version number
97
+ # @param label [String, nil] Optional label (e.g., "production", "latest")
98
+ # @param fallback [String, Array, nil] Optional fallback prompt to use on error
99
+ # @param type [Symbol, nil] Required when fallback is provided (:text or :chat)
100
+ # @param cache_ttl [Integer, nil] Optional TTL override for this fetch
101
+ # @return [PromptFetchResult] Prompt client plus cache metadata
102
+ # @raise [ArgumentError] if fallback is provided without type
103
+ # @raise [NotFoundError] if the prompt is not found and no fallback provided
104
+ # @raise [UnauthorizedError] if authentication fails and no fallback provided
105
+ # @raise [ApiError] for other API errors and no fallback provided
106
+ def get_prompt_result(name, version: nil, label: nil, fallback: nil, type: nil, cache_ttl: nil)
107
+ validate_fallback_usage!(fallback, type)
108
+
109
+ api_result = api_client.get_prompt_result(name, version: version, label: label, cache_ttl: cache_ttl)
110
+ build_client_fetch_result(api_result, build_prompt_client(api_result.prompt))
89
111
  rescue ApiError, NotFoundError, UnauthorizedError => e
90
112
  # If no fallback, re-raise the error
91
113
  raise e unless fallback
92
114
 
93
115
  # Log warning and return fallback
94
116
  config.logger.warn("Langfuse API error for prompt '#{name}': #{e.message}. Using fallback.")
95
- build_fallback_prompt_client(name, fallback, type)
117
+ key = api_client.prompt_cache_key(name, version: version, label: label)
118
+ build_fallback_prompt_result(key, fallback: fallback, type: type, cache_ttl: cache_ttl, error: e)
119
+ end
120
+
121
+ # Refresh a prompt from the API, optionally writing through to cache.
122
+ #
123
+ # @param name [String] The name of the prompt
124
+ # @param version [Integer, nil] Optional specific version number
125
+ # @param label [String, nil] Optional label (e.g., "production", "latest")
126
+ # @param cache_ttl [Integer, nil] Optional TTL override for this refresh
127
+ # @return [PromptFetchResult] Prompt client plus cache metadata
128
+ # @raise [ArgumentError] if both version and label are provided
129
+ # @raise [NotFoundError] if the prompt is not found
130
+ # @raise [UnauthorizedError] if authentication fails
131
+ # @raise [ApiError] for other API errors
132
+ def refresh_prompt(name, version: nil, label: nil, cache_ttl: nil)
133
+ api_result = api_client.refresh_prompt(name, version: version, label: label, cache_ttl: cache_ttl)
134
+ build_client_fetch_result(api_result, build_prompt_client(api_result.prompt))
135
+ end
136
+
137
+ # Invalidate one exact logical prompt cache key.
138
+ #
139
+ # @param name [String] The prompt name
140
+ # @param version [Integer, nil] Optional specific version number
141
+ # @param label [String, nil] Optional label
142
+ # @return [PromptCacheKey] The invalidated key
143
+ def invalidate_prompt_cache(name, version: nil, label: nil)
144
+ api_client.invalidate_prompt_cache(name, version: version, label: label)
145
+ end
146
+
147
+ # Invalidate all cached variants for one prompt name.
148
+ #
149
+ # @param name [String] The prompt name
150
+ # @return [Integer, nil] New generation, or nil when cache is disabled
151
+ def invalidate_prompt_cache_by_name(name)
152
+ api_client.invalidate_prompt_cache_by_name(name)
96
153
  end
97
154
 
155
+ # Logically clear the whole Langfuse prompt cache namespace.
156
+ #
157
+ # @return [Integer, nil] New global generation, or nil when cache is disabled
158
+ def clear_prompt_cache
159
+ api_client.clear_prompt_cache
160
+ end
161
+
162
+ # Return prompt cache statistics.
163
+ #
164
+ # @return [Hash] Cache statistics
165
+ def prompt_cache_stats
166
+ api_client.prompt_cache_stats
167
+ end
168
+
169
+ # Inspect the logical and generated cache keys for a prompt.
170
+ #
171
+ # @param name [String] The prompt name
172
+ # @param version [Integer, nil] Optional specific version number
173
+ # @param label [String, nil] Optional label
174
+ # @return [PromptCacheKey] Logical and generated cache keys
175
+ def prompt_cache_key(name, version: nil, label: nil)
176
+ api_client.prompt_cache_key(name, version: version, label: label)
177
+ end
178
+
179
+ # Validate the configured prompt cache backend before first prompt fetch.
180
+ #
181
+ # @return [Boolean] true when the configured backend is usable
182
+ # @raise [ConfigurationError] if the backend is invalid
183
+ # rubocop:disable Naming/PredicateMethod
184
+ def validate_prompt_cache_backend!
185
+ api_client.cache&.validate! if api_client.cache.respond_to?(:validate!)
186
+ true
187
+ end
188
+ # rubocop:enable Naming/PredicateMethod
189
+
98
190
  # List all prompts in the Langfuse project
99
191
  #
100
192
  # Fetches a list of all prompt names available in your project.
@@ -126,6 +218,7 @@ module Langfuse
126
218
  # @param label [String, nil] Optional label (e.g., "production", "latest")
127
219
  # @param fallback [String, Array, nil] Optional fallback prompt to use on error
128
220
  # @param type [Symbol, nil] Required when fallback is provided (:text or :chat)
221
+ # @param cache_ttl [Integer, nil] Optional TTL override for this fetch
129
222
  # @return [String, Array<Hash>] Compiled prompt (String for text, Array for chat)
130
223
  # @raise [ArgumentError] if both version and label are provided
131
224
  # @raise [ArgumentError] if fallback is provided without type
@@ -148,10 +241,19 @@ module Langfuse
148
241
  # fallback: "Hello {{name}}!",
149
242
  # type: :text
150
243
  # )
151
- def compile_prompt(name, variables: {}, version: nil, label: nil, fallback: nil, type: nil)
152
- prompt = get_prompt(name, version: version, label: label, fallback: fallback, type: type)
244
+ # rubocop:disable Metrics/ParameterLists
245
+ def compile_prompt(name, variables: {}, version: nil, label: nil, fallback: nil, type: nil, cache_ttl: nil)
246
+ prompt = get_prompt(
247
+ name,
248
+ version: version,
249
+ label: label,
250
+ fallback: fallback,
251
+ type: type,
252
+ cache_ttl: cache_ttl
253
+ )
153
254
  prompt.compile(**variables)
154
255
  end
256
+ # rubocop:enable Metrics/ParameterLists
155
257
 
156
258
  # Create a new prompt (or new version if name already exists)
157
259
  #
@@ -738,6 +840,48 @@ module Langfuse
738
840
  list_dataset_items(dataset_name: dataset_name)
739
841
  end
740
842
 
843
+ def validate_fallback_usage!(fallback, type)
844
+ return unless fallback && !type
845
+
846
+ raise ArgumentError, "type parameter is required when fallback is provided (use :text or :chat)"
847
+ end
848
+
849
+ def build_client_fetch_result(api_result, prompt_client)
850
+ PromptFetchResult.new(
851
+ prompt: prompt_client,
852
+ logical_key: api_result.logical_key,
853
+ storage_key: api_result.storage_key,
854
+ cache_status: api_result.cache_status,
855
+ source: api_result.source,
856
+ name: prompt_client.name,
857
+ version: prompt_client.version,
858
+ label: api_result.label
859
+ )
860
+ end
861
+
862
+ def build_fallback_prompt_result(key, fallback:, type:, cache_ttl:, error:)
863
+ prompt_client = build_fallback_prompt_client(key.name, fallback, type)
864
+ cache_status = fallback_cache_status(cache_ttl)
865
+ api_client.emit_prompt_fallback_event(key, cache_status: cache_status, error: error)
866
+ PromptFetchResult.new(
867
+ prompt: prompt_client,
868
+ logical_key: key.logical_key,
869
+ storage_key: key.storage_key,
870
+ cache_status: cache_status,
871
+ source: CacheSource::FALLBACK,
872
+ name: key.name,
873
+ version: key.version || prompt_client.version,
874
+ label: key.resolved_label
875
+ )
876
+ end
877
+
878
+ def fallback_cache_status(cache_ttl)
879
+ return CacheStatus::BYPASS if cache_ttl&.zero?
880
+ return CacheStatus::DISABLED unless api_client.cache
881
+
882
+ CacheStatus::MISS
883
+ end
884
+
741
885
  # Check if caching is enabled in configuration
742
886
  #
743
887
  # @return [Boolean]
@@ -754,11 +898,17 @@ module Langfuse
754
898
  create_memory_cache
755
899
  when :rails
756
900
  create_rails_cache_adapter
901
+ when :auto
902
+ rails_cache_available? ? create_rails_cache_adapter : create_memory_cache
757
903
  else
758
904
  raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
759
905
  end
760
906
  end
761
907
 
908
+ def rails_cache_available?
909
+ defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
910
+ end
911
+
762
912
  # Create in-memory cache with SWR support if enabled
763
913
  #
764
914
  # @return [PromptCache]
@@ -41,7 +41,7 @@ module Langfuse
41
41
  # @return [Integer] Maximum number of cached items
42
42
  attr_accessor :cache_max_size
43
43
 
44
- # @return [Symbol] Cache backend (:memory or :rails)
44
+ # @return [Symbol] Cache backend (:memory, :rails, or :auto)
45
45
  attr_accessor :cache_backend
46
46
 
47
47
  # @return [Integer] Lock timeout in seconds for distributed cache stampede protection
@@ -57,6 +57,9 @@ module Langfuse
57
57
  # @return [Integer] Number of background threads for cache refresh
58
58
  attr_accessor :cache_refresh_threads
59
59
 
60
+ # @return [#call, nil] Observer called for prompt cache events
61
+ attr_accessor :prompt_cache_observer
62
+
60
63
  # @return [Boolean] Use async processing for traces (requires ActiveJob)
61
64
  attr_accessor :tracing_async
62
65
 
@@ -158,6 +161,7 @@ module Langfuse
158
161
  @cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
159
162
  @cache_stale_ttl = 0 # Default to 0 (SWR disabled, entries expire immediately after TTL)
160
163
  @cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
164
+ @prompt_cache_observer = nil
161
165
  @tracing_async = DEFAULT_TRACING_ASYNC
162
166
  @batch_size = DEFAULT_BATCH_SIZE
163
167
  @flush_interval = DEFAULT_FLUSH_INTERVAL
@@ -189,6 +193,7 @@ module Langfuse
189
193
  validate_swr_config!
190
194
 
191
195
  validate_cache_backend!
196
+ validate_prompt_cache_observer!
192
197
  validate_sample_rate!
193
198
  validate_should_export_span!
194
199
  validate_mask!
@@ -240,13 +245,19 @@ module Langfuse
240
245
  end
241
246
 
242
247
  def validate_cache_backend!
243
- valid_backends = %i[memory rails]
248
+ valid_backends = %i[memory rails auto]
244
249
  return if valid_backends.include?(cache_backend)
245
250
 
246
251
  raise ConfigurationError,
247
252
  "cache_backend must be one of #{valid_backends.inspect}, got #{cache_backend.inspect}"
248
253
  end
249
254
 
255
+ def validate_prompt_cache_observer!
256
+ return if prompt_cache_observer.nil? || prompt_cache_observer.respond_to?(:call)
257
+
258
+ raise ConfigurationError, "prompt_cache_observer must respond to #call"
259
+ end
260
+
250
261
  def validate_swr_config!
251
262
  validate_swr_stale_ttl!
252
263
  validate_refresh_threads!
@@ -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