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.
@@ -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
- attr_accessor :logger
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 tracing environment applied to new traces/observations
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 an ended span should export to Langfuse.
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
- @timeout = DEFAULT_TIMEOUT
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
- @logger = default_logger
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
- raise ConfigurationError, "public_key is required" if public_key.nil? || public_key.empty?
182
- raise ConfigurationError, "secret_key is required" if secret_key.nil? || secret_key.empty?
183
- raise ConfigurationError, "base_url cannot be empty" if base_url.nil? || base_url.empty?
184
- raise ConfigurationError, "timeout must be positive" if timeout.nil? || timeout <= 0
185
- raise ConfigurationError, "cache_ttl must be non-negative" if cache_ttl.nil? || cache_ttl.negative?
186
- raise ConfigurationError, "cache_max_size must be positive" if cache_max_size.nil? || cache_max_size <= 0
187
-
188
- if cache_lock_timeout.nil? || cache_lock_timeout <= 0
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
- validate_swr_config!
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
- validate_cache_backend!
196
- validate_prompt_cache_observer!
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
- validate_should_export_span!
199
- validate_mask!
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
- Rails.logger
234
- else
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
- return if valid_backends.include?(cache_backend)
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
- "cache_backend must be one of #{valid_backends.inspect}, got #{cache_backend.inspect}"
455
+ "Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
253
456
  end
254
457
 
255
- def validate_prompt_cache_observer!
256
- return if prompt_cache_observer.nil? || prompt_cache_observer.respond_to?(:call)
458
+ def validate_callable!(value, name)
459
+ return if value.nil? || value.respond_to?(:call)
257
460
 
258
- raise ConfigurationError, "prompt_cache_observer must respond to #call"
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
- # Validate numeric values are non-negative
281
- return unless cache_stale_ttl.is_a?(Integer) && cache_stale_ttl.negative?
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
- return unless cache_refresh_threads.nil? || cache_refresh_threads <= 0
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
@@ -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 a valid score data type
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::SCORE_DATA_TYPES.key?(data_type)
40
+ unless Types::EXPERIMENT_SCORE_DATA_TYPES.include?(data_type)
38
41
  raise ArgumentError,
39
- "Invalid data_type: #{data_type}. Valid types: #{Types::VALID_SCORE_DATA_TYPES.join(', ')}"
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