langfuse-rb 0.1.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 +4 -4
- data/CHANGELOG.md +37 -51
- data/README.md +33 -20
- data/lib/langfuse/api_client.rb +142 -24
- data/lib/langfuse/client.rb +169 -10
- data/lib/langfuse/config.rb +69 -0
- data/lib/langfuse/prompt_cache.rb +119 -8
- data/lib/langfuse/propagation.rb +5 -16
- data/lib/langfuse/rails_cache_adapter.rb +115 -55
- data/lib/langfuse/stale_while_revalidate.rb +262 -0
- data/lib/langfuse/version.rb +1 -1
- metadata +37 -10
data/lib/langfuse/client.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Langfuse
|
|
|
17
17
|
# prompt = client.get_prompt("greeting")
|
|
18
18
|
# compiled = prompt.compile(name: "Alice")
|
|
19
19
|
#
|
|
20
|
+
# rubocop:disable Metrics/ClassLength
|
|
20
21
|
class Client
|
|
21
22
|
attr_reader :config, :api_client
|
|
22
23
|
|
|
@@ -139,6 +140,93 @@ module Langfuse
|
|
|
139
140
|
prompt.compile(**variables)
|
|
140
141
|
end
|
|
141
142
|
|
|
143
|
+
# Create a new prompt (or new version if name already exists)
|
|
144
|
+
#
|
|
145
|
+
# Creates a new prompt in Langfuse. If a prompt with the same name already
|
|
146
|
+
# exists, this creates a new version of that prompt.
|
|
147
|
+
#
|
|
148
|
+
# @param name [String] The prompt name (required)
|
|
149
|
+
# @param prompt [String, Array<Hash>] The prompt content (required)
|
|
150
|
+
# - For text prompts: a string with {{variable}} placeholders
|
|
151
|
+
# - For chat prompts: array of message hashes with role and content
|
|
152
|
+
# @param type [Symbol] Prompt type (:text or :chat) (required)
|
|
153
|
+
# @param config [Hash] Optional configuration (model parameters, tools, etc.)
|
|
154
|
+
# @param labels [Array<String>] Optional labels (e.g., ["production"])
|
|
155
|
+
# @param tags [Array<String>] Optional tags for categorization
|
|
156
|
+
# @param commit_message [String, nil] Optional commit message
|
|
157
|
+
# @return [TextPromptClient, ChatPromptClient] The created prompt client
|
|
158
|
+
# @raise [ArgumentError] if required parameters are missing or invalid
|
|
159
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
160
|
+
# @raise [ApiError] for other API errors
|
|
161
|
+
#
|
|
162
|
+
# @example Create a text prompt
|
|
163
|
+
# prompt = client.create_prompt(
|
|
164
|
+
# name: "greeting",
|
|
165
|
+
# prompt: "Hello {{name}}!",
|
|
166
|
+
# type: :text,
|
|
167
|
+
# labels: ["production"],
|
|
168
|
+
# config: { model: "gpt-4o", temperature: 0.7 }
|
|
169
|
+
# )
|
|
170
|
+
#
|
|
171
|
+
# @example Create a chat prompt
|
|
172
|
+
# prompt = client.create_prompt(
|
|
173
|
+
# name: "support-bot",
|
|
174
|
+
# prompt: [
|
|
175
|
+
# { role: "system", content: "You are a {{role}} assistant" },
|
|
176
|
+
# { role: "user", content: "{{question}}" }
|
|
177
|
+
# ],
|
|
178
|
+
# type: :chat,
|
|
179
|
+
# labels: ["staging"]
|
|
180
|
+
# )
|
|
181
|
+
# rubocop:disable Metrics/ParameterLists
|
|
182
|
+
def create_prompt(name:, prompt:, type:, config: {}, labels: [], tags: [], commit_message: nil)
|
|
183
|
+
validate_prompt_type!(type)
|
|
184
|
+
validate_prompt_content!(prompt, type)
|
|
185
|
+
|
|
186
|
+
prompt_data = api_client.create_prompt(
|
|
187
|
+
name: name,
|
|
188
|
+
prompt: normalize_prompt_content(prompt, type),
|
|
189
|
+
type: type.to_s,
|
|
190
|
+
config: config,
|
|
191
|
+
labels: labels,
|
|
192
|
+
tags: tags,
|
|
193
|
+
commit_message: commit_message
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
build_prompt_client(prompt_data)
|
|
197
|
+
end
|
|
198
|
+
# rubocop:enable Metrics/ParameterLists
|
|
199
|
+
|
|
200
|
+
# Update an existing prompt version's metadata
|
|
201
|
+
#
|
|
202
|
+
# Updates the labels of an existing prompt version.
|
|
203
|
+
# Note: The prompt content itself cannot be changed after creation.
|
|
204
|
+
#
|
|
205
|
+
# @param name [String] The prompt name (required)
|
|
206
|
+
# @param version [Integer] The version number to update (required)
|
|
207
|
+
# @param labels [Array<String>] New labels (replaces existing). Required.
|
|
208
|
+
# @return [TextPromptClient, ChatPromptClient] The updated prompt client
|
|
209
|
+
# @raise [ArgumentError] if labels is not an array
|
|
210
|
+
# @raise [NotFoundError] if the prompt is not found
|
|
211
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
212
|
+
# @raise [ApiError] for other API errors
|
|
213
|
+
#
|
|
214
|
+
# @example Update labels to promote to production
|
|
215
|
+
# prompt = client.update_prompt(
|
|
216
|
+
# name: "greeting",
|
|
217
|
+
# version: 2,
|
|
218
|
+
# labels: ["production"]
|
|
219
|
+
# )
|
|
220
|
+
def update_prompt(name:, version:, labels:)
|
|
221
|
+
prompt_data = api_client.update_prompt(
|
|
222
|
+
name: name,
|
|
223
|
+
version: version,
|
|
224
|
+
labels: labels
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
build_prompt_client(prompt_data)
|
|
228
|
+
end
|
|
229
|
+
|
|
142
230
|
# Generate URL for viewing a trace in Langfuse UI
|
|
143
231
|
#
|
|
144
232
|
# @param trace_id [String] The trace ID (hex-encoded, 32 characters)
|
|
@@ -252,9 +340,12 @@ module Langfuse
|
|
|
252
340
|
|
|
253
341
|
# Shutdown the client and flush any pending scores
|
|
254
342
|
#
|
|
343
|
+
# Also shuts down the cache if it supports shutdown (e.g., SWR thread pool).
|
|
344
|
+
#
|
|
255
345
|
# @return [void]
|
|
256
346
|
def shutdown
|
|
257
347
|
@score_client.shutdown
|
|
348
|
+
@api_client.shutdown
|
|
258
349
|
end
|
|
259
350
|
|
|
260
351
|
private
|
|
@@ -274,20 +365,37 @@ module Langfuse
|
|
|
274
365
|
def create_cache
|
|
275
366
|
case config.cache_backend
|
|
276
367
|
when :memory
|
|
277
|
-
|
|
278
|
-
ttl: config.cache_ttl,
|
|
279
|
-
max_size: config.cache_max_size
|
|
280
|
-
)
|
|
368
|
+
create_memory_cache
|
|
281
369
|
when :rails
|
|
282
|
-
|
|
283
|
-
ttl: config.cache_ttl,
|
|
284
|
-
lock_timeout: config.cache_lock_timeout
|
|
285
|
-
)
|
|
370
|
+
create_rails_cache_adapter
|
|
286
371
|
else
|
|
287
372
|
raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
|
|
288
373
|
end
|
|
289
374
|
end
|
|
290
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
|
+
|
|
291
399
|
# Build the appropriate prompt client based on prompt type
|
|
292
400
|
#
|
|
293
401
|
# @param prompt_data [Hash] The prompt data from API
|
|
@@ -314,6 +422,8 @@ module Langfuse
|
|
|
314
422
|
# @return [TextPromptClient, ChatPromptClient]
|
|
315
423
|
# @raise [ArgumentError] if type is invalid
|
|
316
424
|
def build_fallback_prompt_client(name, fallback, type)
|
|
425
|
+
validate_prompt_type!(type)
|
|
426
|
+
|
|
317
427
|
# Create minimal prompt data structure
|
|
318
428
|
prompt_data = {
|
|
319
429
|
"name" => name,
|
|
@@ -330,9 +440,58 @@ module Langfuse
|
|
|
330
440
|
TextPromptClient.new(prompt_data)
|
|
331
441
|
when :chat
|
|
332
442
|
ChatPromptClient.new(prompt_data)
|
|
333
|
-
|
|
334
|
-
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Validate prompt type parameter
|
|
447
|
+
#
|
|
448
|
+
# @param type [Symbol] The type to validate
|
|
449
|
+
# @raise [ArgumentError] if type is invalid
|
|
450
|
+
def validate_prompt_type!(type)
|
|
451
|
+
valid_types = %i[text chat]
|
|
452
|
+
return if valid_types.include?(type)
|
|
453
|
+
|
|
454
|
+
raise ArgumentError, "Invalid type: #{type}. Must be :text or :chat"
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Validate prompt content matches the declared type
|
|
458
|
+
#
|
|
459
|
+
# @param prompt [String, Array] The prompt content
|
|
460
|
+
# @param type [Symbol] The declared type
|
|
461
|
+
# @raise [ArgumentError] if content doesn't match type
|
|
462
|
+
def validate_prompt_content!(prompt, type)
|
|
463
|
+
case type
|
|
464
|
+
when :text
|
|
465
|
+
raise ArgumentError, "Text prompt must be a String" unless prompt.is_a?(String)
|
|
466
|
+
when :chat
|
|
467
|
+
raise ArgumentError, "Chat prompt must be an Array" unless prompt.is_a?(Array)
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Normalize prompt content for API request
|
|
472
|
+
#
|
|
473
|
+
# Converts Ruby symbol keys to string keys for chat messages
|
|
474
|
+
#
|
|
475
|
+
# @param prompt [String, Array] The prompt content
|
|
476
|
+
# @param type [Symbol] The prompt type
|
|
477
|
+
# @return [String, Array] Normalized content
|
|
478
|
+
def normalize_prompt_content(prompt, type)
|
|
479
|
+
return prompt if type == :text
|
|
480
|
+
|
|
481
|
+
# Normalize chat messages to use string keys
|
|
482
|
+
prompt.map do |message|
|
|
483
|
+
# Convert all keys to symbols first, then extract
|
|
484
|
+
normalized = message.transform_keys do |k|
|
|
485
|
+
k.to_sym
|
|
486
|
+
rescue StandardError
|
|
487
|
+
k
|
|
488
|
+
end
|
|
489
|
+
{
|
|
490
|
+
"role" => normalized[:role]&.to_s,
|
|
491
|
+
"content" => normalized[:content]
|
|
492
|
+
}
|
|
335
493
|
end
|
|
336
494
|
end
|
|
337
495
|
end
|
|
496
|
+
# rubocop:enable Metrics/ClassLength
|
|
338
497
|
end
|
data/lib/langfuse/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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.
|
|
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
|
data/lib/langfuse/propagation.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
448
|
+
context = OpenTelemetry::Baggage.set_value(baggage_key, value.join(","), context: context)
|
|
460
449
|
else
|
|
461
|
-
context = OpenTelemetry::Baggage.set_value(
|
|
450
|
+
context = OpenTelemetry::Baggage.set_value(baggage_key, value.to_s, context: context)
|
|
462
451
|
end
|
|
463
452
|
context
|
|
464
453
|
rescue StandardError => e
|