langfuse-rb 0.10.1 → 0.12.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 +47 -1
- data/lib/langfuse/api_client.rb +75 -16
- data/lib/langfuse/app_root_tracking.rb +164 -0
- data/lib/langfuse/chat_prompt_client.rb +18 -1
- data/lib/langfuse/client.rb +114 -32
- data/lib/langfuse/config.rb +306 -63
- data/lib/langfuse/evaluation.rb +8 -4
- data/lib/langfuse/exit_hook.rb +77 -0
- data/lib/langfuse/fork_safety.rb +71 -0
- data/lib/langfuse/masking_exporter.rb +98 -0
- data/lib/langfuse/observations.rb +2 -1
- data/lib/langfuse/otel_attributes.rb +1 -0
- data/lib/langfuse/otel_setup.rb +32 -44
- data/lib/langfuse/otel_span_batch.rb +113 -0
- data/lib/langfuse/otel_span_masking.rb +89 -0
- data/lib/langfuse/otel_span_patch_applier.rb +97 -0
- data/lib/langfuse/pending_score_queue.rb +62 -0
- data/lib/langfuse/prompt_cache.rb +8 -0
- data/lib/langfuse/prompt_variables.rb +54 -0
- data/lib/langfuse/propagation.rb +101 -34
- data/lib/langfuse/rails_cache_adapter.rb +17 -1
- data/lib/langfuse/read_api.rb +242 -0
- data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
- data/lib/langfuse/score_client.rb +201 -80
- data/lib/langfuse/score_value.rb +58 -0
- data/lib/langfuse/span_processor.rb +53 -4
- data/lib/langfuse/stale_while_revalidate.rb +3 -4
- data/lib/langfuse/text_prompt_client.rb +13 -1
- data/lib/langfuse/trace_export_guard.rb +47 -0
- data/lib/langfuse/traced_execution.rb +18 -12
- data/lib/langfuse/types.rb +15 -1
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +156 -44
- metadata +50 -8
data/lib/langfuse/config.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module Langfuse
|
|
6
7
|
# Configuration object for Langfuse client
|
|
@@ -33,7 +34,7 @@ module Langfuse
|
|
|
33
34
|
attr_accessor :timeout
|
|
34
35
|
|
|
35
36
|
# @return [Logger] Logger instance for debugging
|
|
36
|
-
|
|
37
|
+
attr_reader :logger
|
|
37
38
|
|
|
38
39
|
# @return [Integer] Cache TTL in seconds
|
|
39
40
|
attr_accessor :cache_ttl
|
|
@@ -63,16 +64,22 @@ module Langfuse
|
|
|
63
64
|
# @return [Boolean] Use OpenTelemetry batch scheduling for trace export
|
|
64
65
|
attr_accessor :tracing_async
|
|
65
66
|
|
|
67
|
+
# @return [Boolean] Enable Langfuse tracing and scoring
|
|
68
|
+
attr_accessor :tracing_enabled
|
|
69
|
+
|
|
66
70
|
# @return [Integer] Number of events to batch before sending
|
|
67
71
|
attr_accessor :batch_size
|
|
68
72
|
|
|
69
73
|
# @return [Integer] Interval in seconds to flush buffered events
|
|
70
74
|
attr_accessor :flush_interval
|
|
71
75
|
|
|
76
|
+
# @return [Integer] Maximum number of asynchronous scores held in memory
|
|
77
|
+
attr_accessor :score_queue_capacity
|
|
78
|
+
|
|
72
79
|
# @return [Symbol] Reserved no-op queue name for future async job integration
|
|
73
80
|
attr_accessor :job_queue
|
|
74
81
|
|
|
75
|
-
# @return [String, nil] Default
|
|
82
|
+
# @return [String, nil] Default environment applied to traces, observations, and scores
|
|
76
83
|
attr_accessor :environment
|
|
77
84
|
|
|
78
85
|
# @return [String, nil] Default release identifier applied to new traces/observations
|
|
@@ -81,13 +88,40 @@ module Langfuse
|
|
|
81
88
|
# @return [Float] Trace sampling rate from 0.0 to 1.0
|
|
82
89
|
attr_reader :sample_rate
|
|
83
90
|
|
|
84
|
-
# @return [#call, nil] Callback that decides whether
|
|
91
|
+
# @return [#call, nil] Callback that decides whether a span should export to Langfuse.
|
|
92
|
+
# The span processor calls it once after each span finishes.
|
|
85
93
|
attr_accessor :should_export_span
|
|
86
94
|
|
|
87
95
|
# @return [#call, nil] Mask callable applied to input, output, and metadata before serialization.
|
|
88
96
|
# Receives `data:` keyword argument. nil disables masking.
|
|
97
|
+
# This is a creation-time hook for Langfuse-owned attributes; it never sees
|
|
98
|
+
# raw third-party span attributes. See {#mask_otel_spans} for those.
|
|
89
99
|
attr_accessor :mask
|
|
90
100
|
|
|
101
|
+
# @return [#call, nil] Export-stage masking hook for spans exported to Langfuse.
|
|
102
|
+
# Receives a +params:+ keyword argument containing {MaskOtelSpansParams}.
|
|
103
|
+
# Its frozen +spans+ Hash maps {OtelSpanIdentifier} keys to {OtelSpanData}
|
|
104
|
+
# snapshots for one export batch, including third-party spans. Returns nil
|
|
105
|
+
# to export the batch unchanged or {MaskOtelSpansResult} with sparse
|
|
106
|
+
# {OtelSpanPatch} values. Deletes run before sets.
|
|
107
|
+
# Only the copy exported to Langfuse is transformed — any other
|
|
108
|
+
# OpenTelemetry exporter receives the original, unmasked spans, so
|
|
109
|
+
# Langfuse masking does not protect other telemetry backends.
|
|
110
|
+
# The hook is synchronous and must not rely on request context, the
|
|
111
|
+
# current span, async work, or network calls. Exceptions and invalid
|
|
112
|
+
# results fail closed by dropping the Langfuse export batch.
|
|
113
|
+
attr_accessor :mask_otel_spans
|
|
114
|
+
|
|
115
|
+
# @return [#add_to_counter, #record_value, #observe_value, nil] Reporter for
|
|
116
|
+
# OpenTelemetry batch span processor metrics. The reporter must be fast,
|
|
117
|
+
# thread-safe, and nonblocking. The application owns its lifecycle.
|
|
118
|
+
attr_accessor :metrics_reporter
|
|
119
|
+
|
|
120
|
+
# @return [#export, #force_flush, #shutdown, nil] Span exporter used by
|
|
121
|
+
# Langfuse's internal tracer provider. The provider owns the exporter
|
|
122
|
+
# lifecycle after tracing starts. nil selects the default OTLP exporter.
|
|
123
|
+
attr_accessor :span_exporter
|
|
124
|
+
|
|
91
125
|
# @return [String] Default Langfuse API base URL
|
|
92
126
|
DEFAULT_BASE_URL = "https://cloud.langfuse.com"
|
|
93
127
|
|
|
@@ -115,18 +149,35 @@ module Langfuse
|
|
|
115
149
|
# @return [Boolean] Default async processing setting
|
|
116
150
|
DEFAULT_TRACING_ASYNC = true
|
|
117
151
|
|
|
152
|
+
# @return [Boolean] Default telemetry setting
|
|
153
|
+
DEFAULT_TRACING_ENABLED = true
|
|
154
|
+
|
|
118
155
|
# @return [Integer] Default number of events to batch before sending
|
|
119
156
|
DEFAULT_BATCH_SIZE = 50
|
|
120
157
|
|
|
121
158
|
# @return [Integer] Default flush interval in seconds
|
|
122
159
|
DEFAULT_FLUSH_INTERVAL = 10
|
|
123
160
|
|
|
161
|
+
# @return [Integer] Default maximum number of queued asynchronous scores
|
|
162
|
+
DEFAULT_SCORE_QUEUE_CAPACITY = 100_000
|
|
163
|
+
|
|
124
164
|
# @return [Symbol] Default ActiveJob queue name
|
|
125
165
|
DEFAULT_JOB_QUEUE = :default
|
|
126
166
|
|
|
127
167
|
# @return [Float] Default trace sampling rate (sample all traces)
|
|
128
168
|
DEFAULT_SAMPLE_RATE = 1.0
|
|
129
169
|
|
|
170
|
+
# @return [Array<Symbol>] Methods required from a custom logger
|
|
171
|
+
LOGGER_METHODS = %i[debug info warn error].freeze
|
|
172
|
+
|
|
173
|
+
# Methods defined by OpenTelemetry's metrics reporter contract.
|
|
174
|
+
METRICS_REPORTER_METHODS = %i[add_to_counter record_value observe_value].freeze
|
|
175
|
+
private_constant :METRICS_REPORTER_METHODS
|
|
176
|
+
|
|
177
|
+
# Methods required by OpenTelemetry's span exporter contract.
|
|
178
|
+
SPAN_EXPORTER_METHODS = %i[export force_flush shutdown].freeze
|
|
179
|
+
private_constant :SPAN_EXPORTER_METHODS
|
|
180
|
+
|
|
130
181
|
# @return [Integer] Number of seconds representing indefinite cache duration (~1000 years)
|
|
131
182
|
INDEFINITE_SECONDS = 1000 * 365 * 24 * 60 * 60
|
|
132
183
|
|
|
@@ -153,52 +204,88 @@ module Langfuse
|
|
|
153
204
|
@public_key = ENV.fetch("LANGFUSE_PUBLIC_KEY", nil)
|
|
154
205
|
@secret_key = ENV.fetch("LANGFUSE_SECRET_KEY", nil)
|
|
155
206
|
@base_url = ENV.fetch("LANGFUSE_BASE_URL", DEFAULT_BASE_URL)
|
|
156
|
-
|
|
157
|
-
@cache_ttl = DEFAULT_CACHE_TTL
|
|
158
|
-
@cache_max_size = DEFAULT_CACHE_MAX_SIZE
|
|
159
|
-
@cache_backend = DEFAULT_CACHE_BACKEND
|
|
160
|
-
@cache_lock_timeout = DEFAULT_CACHE_LOCK_TIMEOUT
|
|
161
|
-
@cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
|
|
162
|
-
@cache_stale_ttl = 0 # Default to 0 (SWR disabled, entries expire immediately after TTL)
|
|
163
|
-
@cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
|
|
164
|
-
@prompt_cache_observer = nil
|
|
165
|
-
@tracing_async = DEFAULT_TRACING_ASYNC
|
|
166
|
-
@batch_size = DEFAULT_BATCH_SIZE
|
|
167
|
-
@flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
168
|
-
@job_queue = DEFAULT_JOB_QUEUE
|
|
207
|
+
initialize_client_defaults
|
|
169
208
|
initialize_tracing_defaults
|
|
170
|
-
|
|
209
|
+
initialize_logger
|
|
171
210
|
|
|
172
211
|
yield(self) if block_given?
|
|
173
212
|
end
|
|
174
213
|
|
|
214
|
+
# Set the logger. A nil value disables output while preserving the logger contract.
|
|
215
|
+
#
|
|
216
|
+
# @param value [Logger, nil] Logger instance, or nil to disable logging
|
|
217
|
+
# @return [Logger] The normalized logger
|
|
218
|
+
def logger=(value)
|
|
219
|
+
@logger = value || Logger.new(IO::NULL)
|
|
220
|
+
end
|
|
221
|
+
|
|
175
222
|
# Validate the configuration
|
|
176
223
|
#
|
|
177
224
|
# @raise [ConfigurationError] if configuration is invalid
|
|
178
225
|
# @return [void]
|
|
179
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
180
226
|
def validate!
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
raise ConfigurationError,
|
|
190
|
-
"cache_lock_timeout must be positive"
|
|
191
|
-
end
|
|
227
|
+
validate_tracing_enabled!
|
|
228
|
+
validate_connection_settings!
|
|
229
|
+
validate_batching_settings!
|
|
230
|
+
validate_sample_rate!
|
|
231
|
+
validate_client_settings!
|
|
232
|
+
validate_callable!(prompt_cache_observer, "prompt_cache_observer")
|
|
233
|
+
validate_logger!
|
|
234
|
+
end
|
|
192
235
|
|
|
193
|
-
|
|
236
|
+
# Check whether the configuration can construct a client.
|
|
237
|
+
#
|
|
238
|
+
# This check is local. It does not validate credentials or network access.
|
|
239
|
+
#
|
|
240
|
+
# @return [Boolean] true when {#validate!} succeeds
|
|
241
|
+
def valid?
|
|
242
|
+
validate!
|
|
243
|
+
true
|
|
244
|
+
rescue ConfigurationError
|
|
245
|
+
false
|
|
246
|
+
end
|
|
194
247
|
|
|
195
|
-
|
|
196
|
-
|
|
248
|
+
# Validate only settings consumed by tracing setup and export.
|
|
249
|
+
#
|
|
250
|
+
# @api private
|
|
251
|
+
# @raise [ConfigurationError] if tracing configuration is invalid
|
|
252
|
+
# @return [void]
|
|
253
|
+
def validate_tracing!
|
|
254
|
+
validate_tracing_enabled!
|
|
255
|
+
validate_connection_settings!
|
|
256
|
+
validate_batching_settings!
|
|
197
257
|
validate_sample_rate!
|
|
198
|
-
|
|
199
|
-
|
|
258
|
+
validate_callable!(should_export_span, "should_export_span")
|
|
259
|
+
validate_callable!(mask, "mask")
|
|
260
|
+
validate_callable!(mask_otel_spans, "mask_otel_spans")
|
|
261
|
+
validate_metrics_reporter!
|
|
262
|
+
validate_span_exporter!
|
|
263
|
+
validate_logger!
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Validate settings needed while telemetry is disabled.
|
|
267
|
+
#
|
|
268
|
+
# @api private
|
|
269
|
+
# @raise [ConfigurationError] if disabled-client configuration is invalid
|
|
270
|
+
# @return [void]
|
|
271
|
+
def validate_telemetry_disabled!
|
|
272
|
+
validate_tracing_enabled!
|
|
273
|
+
validate_logger!
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Check whether Langfuse tracing and scoring are enabled.
|
|
277
|
+
#
|
|
278
|
+
# @return [Boolean] true when Langfuse telemetry is enabled
|
|
279
|
+
def telemetry_enabled?
|
|
280
|
+
tracing_enabled == true
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Check whether OpenTelemetry trace export is enabled.
|
|
284
|
+
#
|
|
285
|
+
# @return [Boolean] true when Langfuse tracing is enabled and the OpenTelemetry SDK is active
|
|
286
|
+
def trace_export_enabled?
|
|
287
|
+
telemetry_enabled? && !@otel_sdk_disabled
|
|
200
288
|
end
|
|
201
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
202
289
|
|
|
203
290
|
# Normalize stale_ttl value
|
|
204
291
|
#
|
|
@@ -228,34 +315,182 @@ module Langfuse
|
|
|
228
315
|
|
|
229
316
|
private
|
|
230
317
|
|
|
318
|
+
def initialize_client_defaults
|
|
319
|
+
@timeout = env_integer("LANGFUSE_TIMEOUT") || DEFAULT_TIMEOUT
|
|
320
|
+
@cache_ttl = DEFAULT_CACHE_TTL
|
|
321
|
+
@cache_max_size = DEFAULT_CACHE_MAX_SIZE
|
|
322
|
+
@cache_backend = DEFAULT_CACHE_BACKEND
|
|
323
|
+
@cache_lock_timeout = DEFAULT_CACHE_LOCK_TIMEOUT
|
|
324
|
+
@cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
|
|
325
|
+
@cache_stale_ttl = 0 # Default to 0 (SWR disabled, entries expire immediately after TTL)
|
|
326
|
+
@cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
|
|
327
|
+
@prompt_cache_observer = nil
|
|
328
|
+
@tracing_async = DEFAULT_TRACING_ASYNC
|
|
329
|
+
@batch_size = env_integer("LANGFUSE_FLUSH_AT") || DEFAULT_BATCH_SIZE
|
|
330
|
+
@flush_interval = env_float("LANGFUSE_FLUSH_INTERVAL") || DEFAULT_FLUSH_INTERVAL
|
|
331
|
+
@score_queue_capacity = DEFAULT_SCORE_QUEUE_CAPACITY
|
|
332
|
+
@job_queue = DEFAULT_JOB_QUEUE
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def initialize_logger
|
|
336
|
+
self.logger = if env_true?("LANGFUSE_DEBUG")
|
|
337
|
+
Logger.new($stdout, level: Logger::DEBUG)
|
|
338
|
+
else
|
|
339
|
+
default_logger
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
231
343
|
def default_logger
|
|
232
|
-
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
Logger.new($stdout, level: Logger::WARN)
|
|
236
|
-
end
|
|
344
|
+
return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
345
|
+
|
|
346
|
+
Logger.new($stdout, level: Logger::WARN)
|
|
237
347
|
end
|
|
238
348
|
|
|
239
349
|
def initialize_tracing_defaults
|
|
350
|
+
@tracing_enabled = boolean_env("LANGFUSE_TRACING_ENABLED", default: DEFAULT_TRACING_ENABLED)
|
|
351
|
+
@otel_sdk_disabled = otel_sdk_disabled?
|
|
240
352
|
@environment = env_value("LANGFUSE_TRACING_ENVIRONMENT")
|
|
241
353
|
@release = env_value("LANGFUSE_RELEASE") || detect_release_from_ci_env
|
|
242
354
|
self.sample_rate = env_value("LANGFUSE_SAMPLE_RATE") || DEFAULT_SAMPLE_RATE
|
|
243
355
|
@should_export_span = nil
|
|
244
356
|
@mask = nil
|
|
357
|
+
@mask_otel_spans = nil
|
|
358
|
+
@metrics_reporter = nil
|
|
359
|
+
@span_exporter = nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def validate_connection_settings!
|
|
363
|
+
validate_required_string!("public_key", public_key)
|
|
364
|
+
validate_required_string!("secret_key", secret_key)
|
|
365
|
+
validate_base_url!
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def validate_tracing_enabled!
|
|
369
|
+
return if [true, false].include?(tracing_enabled)
|
|
370
|
+
|
|
371
|
+
raise ConfigurationError, "tracing_enabled must be true or false"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def boolean_env(key, default:)
|
|
375
|
+
value = env_value(key)
|
|
376
|
+
return default if value.nil?
|
|
377
|
+
return true if value.casecmp("true").zero?
|
|
378
|
+
return false if value.casecmp("false").zero?
|
|
379
|
+
|
|
380
|
+
raise ConfigurationError, "#{key} must be true or false"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def otel_sdk_disabled?
|
|
384
|
+
env_value("OTEL_SDK_DISABLED")&.casecmp?("true") || false
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def validate_batching_settings!
|
|
388
|
+
unless batch_size.is_a?(Integer) && batch_size.positive?
|
|
389
|
+
raise ConfigurationError, "batch_size must be a positive Integer"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
validate_positive_number!("flush_interval", flush_interval)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def validate_client_settings!
|
|
396
|
+
validate_positive_number!("timeout", timeout)
|
|
397
|
+
validate_non_negative_number!("cache_ttl", cache_ttl)
|
|
398
|
+
validate_positive_number!("cache_max_size", cache_max_size)
|
|
399
|
+
validate_positive_number!("cache_lock_timeout", cache_lock_timeout)
|
|
400
|
+
unless score_queue_capacity.is_a?(Integer) && score_queue_capacity.positive?
|
|
401
|
+
raise ConfigurationError, "score_queue_capacity must be a positive Integer"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
validate_swr_config!
|
|
405
|
+
validate_cache_backend!
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Credentials reach the API as interpolated strings, so a value that only
|
|
409
|
+
# answers #to_str would authenticate with its #to_s output instead.
|
|
410
|
+
def validate_required_string!(name, value, empty_message: "#{name} is required")
|
|
411
|
+
raise ConfigurationError, empty_message if value.nil?
|
|
412
|
+
|
|
413
|
+
raise ConfigurationError, "#{name} must be a String" unless value.is_a?(String)
|
|
414
|
+
|
|
415
|
+
raise ConfigurationError, empty_message if value.empty?
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def validate_base_url!
|
|
419
|
+
validate_required_string!("base_url", base_url, empty_message: "base_url cannot be empty")
|
|
420
|
+
uri = URI.parse(base_url)
|
|
421
|
+
return if %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
422
|
+
|
|
423
|
+
raise ConfigurationError, "base_url must be an absolute HTTP or HTTPS URL"
|
|
424
|
+
rescue URI::InvalidURIError
|
|
425
|
+
raise ConfigurationError, "base_url must be an absolute HTTP or HTTPS URL"
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def validate_positive_number!(name, value)
|
|
429
|
+
return if finite_ordered_number?(value) && value.positive?
|
|
430
|
+
|
|
431
|
+
raise ConfigurationError, "#{name} must be positive"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def validate_non_negative_number!(name, value)
|
|
435
|
+
return if finite_ordered_number?(value) && !value.negative?
|
|
436
|
+
|
|
437
|
+
raise ConfigurationError, "#{name} must be non-negative"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def finite_ordered_number?(value)
|
|
441
|
+
value.is_a?(Numeric) && value.respond_to?(:positive?) && value.finite?
|
|
245
442
|
end
|
|
246
443
|
|
|
247
444
|
def validate_cache_backend!
|
|
248
445
|
valid_backends = %i[memory rails auto]
|
|
249
|
-
|
|
446
|
+
unless valid_backends.include?(cache_backend)
|
|
447
|
+
raise ConfigurationError,
|
|
448
|
+
"cache_backend must be one of #{valid_backends.inspect}, got #{cache_backend.inspect}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
return unless cache_backend == :rails && cache_ttl.positive?
|
|
452
|
+
return if RailsCacheAdapter.available?
|
|
250
453
|
|
|
251
454
|
raise ConfigurationError,
|
|
252
|
-
"
|
|
455
|
+
"Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
|
|
253
456
|
end
|
|
254
457
|
|
|
255
|
-
def
|
|
256
|
-
return if
|
|
458
|
+
def validate_callable!(value, name)
|
|
459
|
+
return if value.nil? || value.respond_to?(:call)
|
|
257
460
|
|
|
258
|
-
raise ConfigurationError, "
|
|
461
|
+
raise ConfigurationError, "#{name} must respond to #call"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def validate_logger!
|
|
465
|
+
missing_methods = LOGGER_METHODS.reject { |method_name| logger.respond_to?(method_name) }
|
|
466
|
+
return if missing_methods.empty?
|
|
467
|
+
|
|
468
|
+
required_methods = LOGGER_METHODS.map { |method_name| "##{method_name}" }.join(", ")
|
|
469
|
+
raise ConfigurationError, "logger must respond to #{required_methods}"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def validate_metrics_reporter!
|
|
473
|
+
return if metrics_reporter.nil?
|
|
474
|
+
|
|
475
|
+
missing_methods = METRICS_REPORTER_METHODS.reject do |method_name|
|
|
476
|
+
metrics_reporter.respond_to?(method_name)
|
|
477
|
+
end
|
|
478
|
+
return if missing_methods.empty?
|
|
479
|
+
|
|
480
|
+
required_methods = METRICS_REPORTER_METHODS.map { |method_name| "##{method_name}" }.join(", ")
|
|
481
|
+
raise ConfigurationError, "metrics_reporter must respond to #{required_methods}"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def validate_span_exporter!
|
|
485
|
+
return if span_exporter.nil?
|
|
486
|
+
|
|
487
|
+
missing_methods = SPAN_EXPORTER_METHODS.reject do |method_name|
|
|
488
|
+
span_exporter.respond_to?(method_name)
|
|
489
|
+
end
|
|
490
|
+
return if missing_methods.empty?
|
|
491
|
+
|
|
492
|
+
required_methods = SPAN_EXPORTER_METHODS.map { |method_name| "##{method_name}" }.join(", ")
|
|
493
|
+
raise ConfigurationError, "span_exporter must respond to #{required_methods}"
|
|
259
494
|
end
|
|
260
495
|
|
|
261
496
|
def validate_swr_config!
|
|
@@ -277,17 +512,15 @@ module Langfuse
|
|
|
277
512
|
"cache_stale_ttl must be non-negative or :indefinite"
|
|
278
513
|
end
|
|
279
514
|
|
|
280
|
-
|
|
281
|
-
return
|
|
515
|
+
return if cache_stale_ttl == :indefinite
|
|
516
|
+
return if finite_ordered_number?(cache_stale_ttl) && !cache_stale_ttl.negative?
|
|
282
517
|
|
|
283
518
|
raise ConfigurationError,
|
|
284
519
|
"cache_stale_ttl must be non-negative or :indefinite"
|
|
285
520
|
end
|
|
286
521
|
|
|
287
522
|
def validate_refresh_threads!
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
raise ConfigurationError, "cache_refresh_threads must be positive"
|
|
523
|
+
validate_positive_number!("cache_refresh_threads", cache_refresh_threads)
|
|
291
524
|
end
|
|
292
525
|
|
|
293
526
|
def validate_sample_rate!
|
|
@@ -296,18 +529,6 @@ module Langfuse
|
|
|
296
529
|
raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
|
|
297
530
|
end
|
|
298
531
|
|
|
299
|
-
def validate_mask!
|
|
300
|
-
return if mask.nil? || mask.respond_to?(:call)
|
|
301
|
-
|
|
302
|
-
raise ConfigurationError, "mask must respond to #call"
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def validate_should_export_span!
|
|
306
|
-
return if should_export_span.nil? || should_export_span.respond_to?(:call)
|
|
307
|
-
|
|
308
|
-
raise ConfigurationError, "should_export_span must respond to #call"
|
|
309
|
-
end
|
|
310
|
-
|
|
311
532
|
def detect_release_from_ci_env
|
|
312
533
|
COMMON_RELEASE_ENV_KEYS.each do |key|
|
|
313
534
|
value = env_value(key)
|
|
@@ -324,6 +545,28 @@ module Langfuse
|
|
|
324
545
|
value
|
|
325
546
|
end
|
|
326
547
|
|
|
548
|
+
def env_integer(key)
|
|
549
|
+
value = env_value(key)
|
|
550
|
+
return nil unless value
|
|
551
|
+
|
|
552
|
+
Integer(value, 10)
|
|
553
|
+
rescue ArgumentError, TypeError
|
|
554
|
+
raise ConfigurationError, "#{key} must be an integer"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def env_float(key)
|
|
558
|
+
value = env_value(key)
|
|
559
|
+
return nil unless value
|
|
560
|
+
|
|
561
|
+
Float(value)
|
|
562
|
+
rescue ArgumentError, TypeError
|
|
563
|
+
raise ConfigurationError, "#{key} must be numeric"
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def env_true?(key)
|
|
567
|
+
env_value(key)&.casecmp?("true") || false
|
|
568
|
+
end
|
|
569
|
+
|
|
327
570
|
def coerce_sample_rate(value)
|
|
328
571
|
numeric_value = if value.is_a?(Numeric)
|
|
329
572
|
value.to_f
|
|
@@ -336,7 +579,7 @@ module Langfuse
|
|
|
336
579
|
return numeric_value if numeric_value.between?(0.0, 1.0)
|
|
337
580
|
|
|
338
581
|
raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
|
|
339
|
-
rescue ArgumentError, TypeError
|
|
582
|
+
rescue ArgumentError, RangeError, TypeError
|
|
340
583
|
raise ConfigurationError, "sample_rate must be numeric"
|
|
341
584
|
end
|
|
342
585
|
end
|
data/lib/langfuse/evaluation.rb
CHANGED
|
@@ -26,17 +26,21 @@ module Langfuse
|
|
|
26
26
|
# @param name [String] Score name (required, must be non-empty)
|
|
27
27
|
# @param value [Numeric, Boolean, String] Score value (type depends on data_type)
|
|
28
28
|
# @param comment [String, nil] Optional comment describing the evaluation
|
|
29
|
-
# @param data_type [Symbol] One of :numeric, :boolean, or :categorical
|
|
29
|
+
# @param data_type [Symbol] One of :numeric, :boolean, or :categorical.
|
|
30
|
+
# Experiment evaluations are metrics, so the general-score :text and
|
|
31
|
+
# :correction types are rejected here; create those through
|
|
32
|
+
# {Client#create_score} instead.
|
|
30
33
|
# @param config_id [String, nil] Optional score config ID
|
|
31
34
|
# @param metadata [Hash, nil] Optional metadata hash
|
|
32
35
|
# @raise [ArgumentError] if name is nil or empty
|
|
33
|
-
# @raise [ArgumentError] if data_type is not
|
|
36
|
+
# @raise [ArgumentError] if data_type is not an experiment score data type
|
|
34
37
|
def initialize(name:, value:, comment: nil, data_type: :numeric, config_id: nil, metadata: nil)
|
|
35
38
|
raise ArgumentError, "name is required" if name.to_s.empty?
|
|
36
39
|
|
|
37
|
-
unless Types::
|
|
40
|
+
unless Types::EXPERIMENT_SCORE_DATA_TYPES.include?(data_type)
|
|
38
41
|
raise ArgumentError,
|
|
39
|
-
"Invalid data_type: #{data_type}.
|
|
42
|
+
"Invalid data_type: #{data_type}. Experiment evaluations accept: " \
|
|
43
|
+
"#{Types::EXPERIMENT_SCORE_DATA_TYPES.join(', ')}"
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
validate_value!(value, data_type)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fork_safety"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Flushes SDK telemetry during normal process exit.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module ExitHook
|
|
10
|
+
class << self
|
|
11
|
+
# Install the process callback once and enable it.
|
|
12
|
+
#
|
|
13
|
+
# @return [void]
|
|
14
|
+
def install!
|
|
15
|
+
mutex.synchronize do
|
|
16
|
+
return if @installed
|
|
17
|
+
|
|
18
|
+
Kernel.at_exit { run }
|
|
19
|
+
@installed = true
|
|
20
|
+
@active = false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Enable the installed callback for the current SDK lifecycle.
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
def enable
|
|
28
|
+
mutex.synchronize { @active = true }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Disable the callback after an explicit shutdown or reset.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
34
|
+
def disable
|
|
35
|
+
mutex.synchronize { @active = false }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run the callback once without allowing shutdown errors to escape.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
def run
|
|
42
|
+
return unless consume_active_hook
|
|
43
|
+
|
|
44
|
+
Langfuse.shutdown
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn_failure(e)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def consume_active_hook
|
|
52
|
+
mutex.synchronize do
|
|
53
|
+
active = @active
|
|
54
|
+
@active = false
|
|
55
|
+
active
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reset_after_fork
|
|
60
|
+
@mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def mutex
|
|
64
|
+
@mutex ||= Mutex.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def warn_failure(error)
|
|
68
|
+
Kernel.warn("Langfuse exit flush failed: #{error.class} - #{error.message}")
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
install!
|
|
75
|
+
ForkSafety.register(self)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Restores SDK-owned background state in a forked child process.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module ForkSafety
|
|
8
|
+
# Ruby routes Kernel#fork, Process.fork, and IO.popen("-") through
|
|
9
|
+
# Process._fork. Ruby 3.2 documents this override point for monitoring
|
|
10
|
+
# libraries that need before-fork or after-fork behavior.
|
|
11
|
+
module ProcessHook
|
|
12
|
+
def _fork
|
|
13
|
+
pid = super
|
|
14
|
+
Langfuse::ForkSafety.after_fork if pid.zero?
|
|
15
|
+
pid
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Register an SDK resource that implements a private #reset_after_fork method.
|
|
21
|
+
#
|
|
22
|
+
# @param resource [Object] Fork-sensitive SDK resource
|
|
23
|
+
# @return [void]
|
|
24
|
+
def register(resource)
|
|
25
|
+
install!
|
|
26
|
+
registry_mutex.synchronize { registry[resource] = true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reset every live registered resource in the child process.
|
|
30
|
+
#
|
|
31
|
+
# @return [void]
|
|
32
|
+
def after_fork
|
|
33
|
+
reset_inherited_mutexes
|
|
34
|
+
registry.each_key { |resource| reset_resource(resource) }
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Kernel.warn("Langfuse fork reset failed: #{e.class} - #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def install!
|
|
42
|
+
install_mutex.synchronize do
|
|
43
|
+
Process.singleton_class.prepend(ProcessHook) unless Process.singleton_class.ancestors.include?(ProcessHook)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def registry
|
|
48
|
+
@registry ||= ObjectSpace::WeakMap.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def registry_mutex
|
|
52
|
+
@registry_mutex ||= Mutex.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def install_mutex
|
|
56
|
+
@install_mutex ||= Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reset_inherited_mutexes
|
|
60
|
+
@registry_mutex = Mutex.new
|
|
61
|
+
@install_mutex = Mutex.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reset_resource(resource)
|
|
65
|
+
resource.__send__(:reset_after_fork)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
Kernel.warn("Langfuse fork reset failed for #{resource.class}: #{e.class} - #{e.message}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|