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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -1
- data/README.md +8 -65
- data/lib/langfuse/api_client.rb +344 -30
- data/lib/langfuse/cache_constants.rb +32 -0
- data/lib/langfuse/client.rb +162 -12
- data/lib/langfuse/config.rb +13 -2
- data/lib/langfuse/prompt_cache.rb +142 -7
- data/lib/langfuse/prompt_cache_events.rb +110 -0
- data/lib/langfuse/prompt_fetch_result.rb +122 -0
- data/lib/langfuse/rails_cache_adapter.rb +161 -9
- data/lib/langfuse/stale_while_revalidate.rb +62 -19
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +3 -0
- metadata +9 -3
data/lib/langfuse/client.rb
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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]
|
data/lib/langfuse/config.rb
CHANGED
|
@@ -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 :
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|