langfuse-ruby 0.2.0 → 0.2.1

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.
@@ -8,6 +8,7 @@ require 'base64'
8
8
  require 'concurrent'
9
9
  require 'logger'
10
10
  require 'digest'
11
+ require 'time'
11
12
 
12
13
  module Langfuse
13
14
  class Client
@@ -17,6 +18,21 @@ module Langfuse
17
18
  # Allowed format for the tracing environment field
18
19
  ENVIRONMENT_PATTERN = /\A(?!langfuse)[a-z0-9\-_]{1,40}\z/
19
20
 
21
+ # How long shutdown waits for the flush thread to finish its current send
22
+ FLUSH_THREAD_JOIN_TIMEOUT = 5
23
+
24
+ # Dropped-event warnings are emitted on the first drop and then every N drops
25
+ DROPPED_EVENTS_WARN_INTERVAL = 100
26
+
27
+ # Supported ingestion transports: the legacy ingestion API and OTLP (Langfuse v4)
28
+ INGESTION_MODES = %i[legacy otel].freeze
29
+
30
+ # Backoff for transient request failures. The delay is jittered so that many
31
+ # clients hitting the same rate limit do not retry in lockstep, and capped so
32
+ # a server-sent Retry-After cannot stall a flush for minutes.
33
+ RETRY_BASE_DELAY_SECONDS = 0.5
34
+ MAX_RETRY_DELAY_SECONDS = 10
35
+
20
36
  # Log device that resolves $stdout at write time so output redirection
21
37
  # (e.g. in tests) keeps working after the logger was created.
22
38
  class StdoutLogDevice
@@ -25,14 +41,21 @@ module Langfuse
25
41
  end
26
42
 
27
43
  def close; end
44
+
45
+ def flush
46
+ $stdout.flush
47
+ end
28
48
  end
29
49
 
30
50
  attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
31
- :ingestion_mode, :environment, :sample_rate, :flush_at, :mask, :logger
51
+ :ingestion_mode, :environment, :sample_rate, :flush_at, :max_queue_size, :mask, :logger
32
52
 
33
- def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: 30, retries: 3,
53
+ # timeout/retries default to nil so that Langfuse.configure values are not
54
+ # shadowed by the method defaults; the fallbacks live in config_value.
55
+ def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: nil, retries: nil,
34
56
  flush_interval: nil, auto_flush: nil, ingestion_mode: nil, environment: nil,
35
- sample_rate: nil, mask: nil, flush_at: nil, logger: nil, shutdown_on_exit: nil)
57
+ sample_rate: nil, mask: nil, flush_at: nil, max_queue_size: nil, logger: nil,
58
+ shutdown_on_exit: nil, http_adapter: nil)
36
59
  @public_key = config_value(public_key, 'LANGFUSE_PUBLIC_KEY', :public_key)
37
60
  @secret_key = config_value(secret_key, 'LANGFUSE_SECRET_KEY', :secret_key)
38
61
  @host = host || ENV['LANGFUSE_HOST'] || ENV['LANGFUSE_BASE_URL'] || Langfuse.configuration.host
@@ -41,28 +64,37 @@ module Langfuse
41
64
  @retries = config_value(retries, nil, :retries) { 3 }
42
65
  @flush_interval = config_value(flush_interval, 'LANGFUSE_FLUSH_INTERVAL', :flush_interval) { 5 }
43
66
  @flush_at = config_value(flush_at, 'LANGFUSE_FLUSH_AT', :flush_at) { 15 }
67
+ @max_queue_size = config_value(max_queue_size, 'LANGFUSE_MAX_QUEUE_SIZE', :max_queue_size) { 10_000 }
44
68
  @auto_flush = resolve_auto_flush(auto_flush)
45
- @ingestion_mode = resolve_ingestion_mode(ingestion_mode)
46
69
  @logger = logger || Langfuse.configuration.logger || build_default_logger
70
+ @ingestion_mode = resolve_ingestion_mode(ingestion_mode)
47
71
  @environment = resolve_environment(environment)
48
72
  @sample_rate = resolve_sample_rate(sample_rate)
49
73
  @mask = resolve_mask(mask)
50
74
  @shutdown_on_exit = shutdown_on_exit.nil? ? Langfuse.configuration.shutdown_on_exit : shutdown_on_exit
75
+ @http_adapter = http_adapter || Langfuse.configuration.http_adapter
51
76
  @shutdown = false
52
77
 
53
78
  raise AuthenticationError, 'Public key is required' unless @public_key
54
79
  raise AuthenticationError, 'Secret key is required' unless @secret_key
55
80
 
56
- @connection = build_connection
57
- @otel_connection = build_otel_connection if @ingestion_mode == :otel
58
- @otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug, logger: @logger) if @ingestion_mode == :otel
81
+ setup_transport
59
82
  @event_queue = Concurrent::Array.new
83
+ @queue_mutex = Mutex.new
60
84
  @flush_mutex = Mutex.new
61
85
  @flush_condition = ConditionVariable.new
62
- @flush_thread = start_flush_thread if @auto_flush
86
+ @dropped_events = 0
87
+ @prompt_cache = PromptCache.new
88
+ start_flush_thread if @auto_flush
63
89
  register_shutdown_hook if @shutdown_on_exit
64
90
  end
65
91
 
92
+ # Keep the secret key out of logs, console output and exception messages.
93
+ def inspect
94
+ "#<#{self.class.name} host=#{@host.inspect} public_key=#{@public_key.inspect} " \
95
+ "ingestion_mode=#{@ingestion_mode.inspect}>"
96
+ end
97
+
66
98
  # Generate a trace ID matching the active ingestion mode
67
99
  # (W3C 32-char hex for :otel, UUID for :legacy)
68
100
  def generate_trace_id
@@ -118,91 +150,15 @@ module Langfuse
118
150
  )
119
151
  end
120
152
 
121
- # Convenience methods for enhanced observation types
153
+ # Convenience methods for enhanced observation types: each is a span with a
154
+ # fixed as_type. (embedding keeps its own definition because it folds
155
+ # model/usage into metadata first.)
156
+ extend SpanWrappers
157
+ define_span_wrappers(evaluator_name: :evaluator_obs)
122
158
 
123
- # Create an agent observation (wrapper around span with as_type: 'agent')
124
- def agent(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
125
- metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
126
- version: nil, **kwargs)
127
- span(
128
- trace_id: trace_id,
129
- name: name,
130
- start_time: start_time,
131
- end_time: end_time,
132
- input: input,
133
- output: output,
134
- metadata: metadata,
135
- level: level,
136
- status_message: status_message,
137
- parent_observation_id: parent_observation_id,
138
- version: version,
139
- as_type: ObservationType::AGENT,
140
- **kwargs
141
- )
142
- end
143
-
144
- # Create a tool observation (wrapper around span with as_type: 'tool')
145
- def tool(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
146
- metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
147
- version: nil, **kwargs)
148
- span(
149
- trace_id: trace_id,
150
- name: name,
151
- start_time: start_time,
152
- end_time: end_time,
153
- input: input,
154
- output: output,
155
- metadata: metadata,
156
- level: level,
157
- status_message: status_message,
158
- parent_observation_id: parent_observation_id,
159
- version: version,
160
- as_type: ObservationType::TOOL,
161
- **kwargs
162
- )
163
- end
164
-
165
- # Create a chain observation (wrapper around span with as_type: 'chain')
166
- def chain(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
167
- metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
168
- version: nil, **kwargs)
169
- span(
170
- trace_id: trace_id,
171
- name: name,
172
- start_time: start_time,
173
- end_time: end_time,
174
- input: input,
175
- output: output,
176
- metadata: metadata,
177
- level: level,
178
- status_message: status_message,
179
- parent_observation_id: parent_observation_id,
180
- version: version,
181
- as_type: ObservationType::CHAIN,
182
- **kwargs
183
- )
184
- end
185
-
186
- # Create a retriever observation (wrapper around span with as_type: 'retriever')
187
- def retriever(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
188
- metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
189
- version: nil, **kwargs)
190
- span(
191
- trace_id: trace_id,
192
- name: name,
193
- start_time: start_time,
194
- end_time: end_time,
195
- input: input,
196
- output: output,
197
- metadata: metadata,
198
- level: level,
199
- status_message: status_message,
200
- parent_observation_id: parent_observation_id,
201
- version: version,
202
- as_type: ObservationType::RETRIEVER,
203
- **kwargs
204
- )
205
- end
159
+ # `evaluator` matches Trace/Span/Generation; `evaluator_obs` is kept for
160
+ # callers that adopted the older name.
161
+ alias evaluator evaluator_obs
206
162
 
207
163
  # Create an embedding observation (wrapper around span with as_type: 'embedding')
208
164
  def embedding(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
@@ -228,48 +184,6 @@ module Langfuse
228
184
  )
229
185
  end
230
186
 
231
- # Create an evaluator observation (wrapper around span with as_type: 'evaluator')
232
- def evaluator_obs(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
233
- metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
234
- version: nil, **kwargs)
235
- span(
236
- trace_id: trace_id,
237
- name: name,
238
- start_time: start_time,
239
- end_time: end_time,
240
- input: input,
241
- output: output,
242
- metadata: metadata,
243
- level: level,
244
- status_message: status_message,
245
- parent_observation_id: parent_observation_id,
246
- version: version,
247
- as_type: ObservationType::EVALUATOR,
248
- **kwargs
249
- )
250
- end
251
-
252
- # Create a guardrail observation (wrapper around span with as_type: 'guardrail')
253
- def guardrail(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
254
- metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
255
- version: nil, **kwargs)
256
- span(
257
- trace_id: trace_id,
258
- name: name,
259
- start_time: start_time,
260
- end_time: end_time,
261
- input: input,
262
- output: output,
263
- metadata: metadata,
264
- level: level,
265
- status_message: status_message,
266
- parent_observation_id: parent_observation_id,
267
- version: version,
268
- as_type: ObservationType::GUARDRAIL,
269
- **kwargs
270
- )
271
- end
272
-
273
187
  # Generation operations
274
188
  def generation(trace_id:, id: nil, name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
275
189
  model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
@@ -322,42 +236,24 @@ module Langfuse
322
236
  end
323
237
 
324
238
  # Prompt operations
325
- def get_prompt(name, version: nil, label: nil, cache_ttl_seconds: 60)
239
+ def get_prompt(name, version: nil, label: nil, cache_ttl_seconds: 60, retries: nil)
326
240
  cache_key = "prompt:#{name}:#{version}:#{label}"
241
+ cached = @prompt_cache.read(cache_key, cache_ttl_seconds)
242
+ return cached if cached
327
243
 
328
- if (cached_prompt = @prompt_cache&.dig(cache_key)) && (Time.now - cached_prompt[:cached_at] < cache_ttl_seconds)
329
- return cached_prompt[:prompt]
330
- end
331
-
332
- encoded_name = Utils.url_encode(name)
333
- path = "/api/public/v2/prompts/#{encoded_name}"
334
- params = {}
335
- params[:version] = version if version
336
- params[:label] = label if label
337
-
338
- @logger.debug("Making request to: #{@host}#{path} with params: #{params}")
339
-
340
- response = get(path, params)
341
-
342
- @logger.debug("Response status: #{response.status}")
343
- @logger.debug("Response headers: #{response.headers}")
344
- @logger.debug("Response body type: #{response.body.class}")
244
+ begin
245
+ prompt = request_prompt(name, version: version, label: label, retries: retries)
246
+ rescue StandardError => e
247
+ # An expired entry beats no prompt at all: serving it keeps the
248
+ # application running through a Langfuse outage.
249
+ stale = @prompt_cache.read_stale(cache_key)
250
+ raise unless stale
345
251
 
346
- # Check if response body is a string (HTML) instead of parsed JSON
347
- if response.body.is_a?(String) && response.body.include?('<!DOCTYPE html>')
348
- @logger.debug('Received HTML response instead of JSON:')
349
- @logger.debug(response.body[0..200])
350
- raise APIError,
351
- 'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
252
+ @logger.warn("Langfuse prompt fetch failed (#{name}), serving the cached copy: #{e.message}")
253
+ return stale
352
254
  end
353
255
 
354
- prompt = Prompt.new(response.body)
355
-
356
- # Cache the prompt
357
- @prompt_cache ||= {}
358
- @prompt_cache[cache_key] = { prompt: prompt, cached_at: Time.now }
359
-
360
- prompt
256
+ @prompt_cache.write(cache_key, prompt)
361
257
  end
362
258
 
363
259
  def create_prompt(name:, prompt:, labels: [], config: {}, **kwargs)
@@ -405,7 +301,7 @@ module Langfuse
405
301
  alias create_score score
406
302
 
407
303
  # Event queue management
408
- def enqueue_event(type, body)
304
+ def enqueue_event(type, body, trace_ref: nil)
409
305
  # 验证事件类型是否有效
410
306
  valid_types = %w[
411
307
  trace-create trace-update
@@ -416,13 +312,15 @@ module Langfuse
416
312
  ]
417
313
 
418
314
  unless valid_types.include?(type)
419
- @logger.debug("Warning: Invalid event type '#{type}'. Skipping event.")
315
+ @logger.debug { "Warning: Invalid event type '#{type}'. Skipping event." }
420
316
  return
421
317
  end
422
318
 
423
- prepared_body = Utils.prepare_event_body(body)
424
- inject_default_environment(prepared_body)
425
- apply_mask(prepared_body)
319
+ # Runs before the event is queued so events inherited from a parent process
320
+ # are discarded without dropping the event we are about to enqueue.
321
+ ensure_flush_thread
322
+
323
+ prepared_body = prepare_queued_body(body)
426
324
 
427
325
  return unless sampled_event?(type, prepared_body)
428
326
 
@@ -432,42 +330,28 @@ module Langfuse
432
330
  timestamp: Utils.current_timestamp,
433
331
  body: prepared_body
434
332
  }
333
+ event[:trace_ref] = trace_ref if trace_ref
435
334
 
436
- if type == 'trace-update'
437
- # 查找对应的 trace-create 事件并更新
438
- trace_id = body['id'] || body[:id]
439
- if trace_id
440
- existing_event_index = @event_queue.find_index do |existing_event|
441
- existing_event[:type] == 'trace-create' &&
442
- (existing_event[:body]['id'] == trace_id || existing_event[:body][:id] == trace_id)
443
- end
444
-
445
- if existing_event_index
446
- # 更新现有的 trace-create 事件
447
- @event_queue[existing_event_index][:body].merge!(event[:body])
448
- @event_queue[existing_event_index][:timestamp] = event[:timestamp]
449
- @logger.debug("Updated existing trace-create event for trace_id: #{trace_id}")
450
- else
451
- # 如果没找到对应的 trace-create 事件,将 trace-update 转换为 trace-create
452
- event[:type] = 'trace-create'
453
- @event_queue << event
454
- @logger.debug("Converted trace-update to trace-create for trace_id: #{trace_id}")
455
- end
335
+ # The queue is drained under the same lock, so a concurrent flush can no
336
+ # longer take an event out between finding it and merging into it.
337
+ queued = @queue_mutex.synchronize do
338
+ if type == 'trace-update'
339
+ merge_or_queue_trace_update?(event)
456
340
  else
457
- @logger.debug('Warning: trace-update event missing trace_id, skipping')
341
+ push_event?(event)
458
342
  end
459
- else
460
- @event_queue << event
461
343
  end
462
- @logger.debug("Enqueued event: #{type}")
344
+ return unless queued
345
+
346
+ @logger.debug { "Enqueued event: #{type}" }
463
347
 
464
348
  request_flush if @auto_flush && @event_queue.length >= @flush_at
465
349
  end
466
350
 
467
351
  def flush
468
- return if @event_queue.empty?
469
-
470
- events = @event_queue.shift(@event_queue.length)
352
+ events = @queue_mutex.synchronize do
353
+ @event_queue.empty? ? [] : @event_queue.shift(@event_queue.length)
354
+ end
471
355
  return if events.empty?
472
356
 
473
357
  send_batch(events)
@@ -477,12 +361,42 @@ module Langfuse
477
361
  return if @shutdown
478
362
 
479
363
  @shutdown = true
480
- @flush_thread&.kill if @auto_flush
364
+ stop_flush_thread
481
365
  flush unless @event_queue.empty?
482
366
  end
483
367
 
484
368
  private
485
369
 
370
+ def setup_transport
371
+ @connection = build_connection
372
+ return unless @ingestion_mode == :otel
373
+
374
+ @otel_connection = build_otel_connection
375
+ @otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug, logger: @logger)
376
+ end
377
+
378
+ def request_prompt(name, version:, label:, retries: nil)
379
+ path = "/api/public/v2/prompts/#{Utils.url_encode(name)}"
380
+ params = {}
381
+ params[:version] = version if version
382
+ params[:label] = label if label
383
+
384
+ @logger.debug { "Making request to: #{@host}#{path} with params: #{params}" }
385
+
386
+ response = request(:get, path, params: params, retries: retries)
387
+
388
+ @logger.debug { "Response status: #{response.status}, body type: #{response.body.class}" }
389
+
390
+ # Check if response body is a string (HTML) instead of parsed JSON
391
+ if response.body.is_a?(String) && response.body.include?('<!DOCTYPE html>')
392
+ @logger.debug { "Received HTML response instead of JSON: #{response.body[0..200]}" }
393
+ raise APIError,
394
+ 'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
395
+ end
396
+
397
+ Prompt.new(response.body)
398
+ end
399
+
486
400
  def build_default_logger
487
401
  logger = Logger.new(StdoutLogDevice.new)
488
402
  logger.level = @debug ? Logger::DEBUG : Logger::WARN
@@ -499,7 +413,10 @@ module Langfuse
499
413
 
500
414
  if env_key
501
415
  env_val = ENV.fetch(env_key, nil)
502
- return env_val.to_i if env_val && %i[flush_interval flush_at timeout retries].include?(config_attr)
416
+ if env_val && %i[flush_interval flush_at timeout retries max_queue_size].include?(config_attr)
417
+ return env_val.to_i
418
+ end
419
+
503
420
  return env_val if env_val
504
421
  end
505
422
 
@@ -557,6 +474,16 @@ module Langfuse
557
474
  end
558
475
  end
559
476
 
477
+ # Camelize top-level keys, inject the default environment, then apply the
478
+ # mask. Every body that enters the queue — including a trace_ref rebuilt
479
+ # after the matching create has already flushed — must go through this.
480
+ def prepare_queued_body(body)
481
+ prepared = Utils.prepare_event_body(body)
482
+ inject_default_environment(prepared)
483
+ apply_mask(prepared)
484
+ prepared
485
+ end
486
+
560
487
  def inject_default_environment(body)
561
488
  return unless @environment
562
489
  return if body.key?('environment')
@@ -604,6 +531,128 @@ module Langfuse
604
531
  @flush_mutex.synchronize { @flush_condition.signal }
605
532
  end
606
533
 
534
+ # Merge a trace-update into the queued trace-create for the same trace.
535
+ # Returns whether the queue changed. Callers must hold @queue_mutex.
536
+ def merge_or_queue_trace_update?(event)
537
+ trace_id = event[:body]['id']
538
+
539
+ unless trace_id
540
+ @logger.debug { 'Warning: trace-update event missing trace_id, skipping' }
541
+ return false
542
+ end
543
+
544
+ position = @event_queue.find_index do |queued_event|
545
+ queued_event[:type] == 'trace-create' && queued_event[:body]['id'] == trace_id
546
+ end
547
+
548
+ unless position
549
+ # Nothing left to merge into (already flushed): send it as a create so
550
+ # the server can upsert. Use the trace's full state (via :trace_ref) to
551
+ # avoid sending a partial body that would produce a broken observation.
552
+ event[:type] = 'trace-create'
553
+ if event[:trace_ref]
554
+ # to_dict is the live instance state (symbol keys, unmasked). Re-run
555
+ # the same prepare/env/mask path as enqueue_event so the API still
556
+ # sees camelCase keys, the default environment, and redacted PII.
557
+ event[:body] = prepare_queued_body(event[:trace_ref].to_dict)
558
+ event.delete(:trace_ref)
559
+ end
560
+ @logger.debug { "Converted trace-update to trace-create for trace_id: #{trace_id}" }
561
+ return push_event?(event)
562
+ end
563
+
564
+ @event_queue[position][:body].merge!(event[:body])
565
+ @event_queue[position][:timestamp] = event[:timestamp]
566
+ @logger.debug { "Updated existing trace-create event for trace_id: #{trace_id}" }
567
+ true
568
+ end
569
+
570
+ # Append an event, evicting the oldest ones when the queue is full. Without a
571
+ # bound, an unreachable Langfuse would grow the queue until the process dies.
572
+ # Callers must hold @queue_mutex. Returns whether the event was queued.
573
+ def push_event?(event)
574
+ dropped = 0
575
+ while @event_queue.length >= @max_queue_size
576
+ @event_queue.shift
577
+ dropped += 1
578
+ end
579
+
580
+ if dropped.positive?
581
+ @dropped_events += dropped
582
+ warn_dropped_events
583
+ end
584
+
585
+ @event_queue << event
586
+ true
587
+ end
588
+
589
+ def warn_dropped_events
590
+ return unless @dropped_events == 1 || (@dropped_events % DROPPED_EVENTS_WARN_INTERVAL).zero?
591
+
592
+ @logger.warn("Langfuse event queue is full (max_queue_size=#{@max_queue_size}); " \
593
+ "dropped #{@dropped_events} oldest events so far")
594
+ end
595
+
596
+ # Put events back for the next flush. Permanent failures are dropped instead:
597
+ # they would fail again on every flush and block the queue indefinitely.
598
+ # Events are prepended so they are retried before any newly enqueued events,
599
+ # preserving the original chronological order.
600
+ def requeue_events(events, error)
601
+ return if events.empty?
602
+
603
+ if permanent_failure?(error)
604
+ @logger.warn("Langfuse dropped #{events.length} events after a permanent failure " \
605
+ "(#{error.class}): #{error.message}")
606
+ return
607
+ end
608
+
609
+ @queue_mutex.synchronize { events.reverse_each { |event| prepend_event(event) } }
610
+ end
611
+
612
+ # Insert an event at the head of the queue, evicting the oldest (tail) if full.
613
+ # Callers must hold @queue_mutex.
614
+ def prepend_event(event)
615
+ if @event_queue.length >= @max_queue_size
616
+ @event_queue.pop
617
+ @dropped_events += 1
618
+ warn_dropped_events
619
+ end
620
+ @event_queue.unshift(event)
621
+ end
622
+
623
+ def permanent_failure?(error)
624
+ error.is_a?(ValidationError) || error.is_a?(AuthenticationError)
625
+ end
626
+
627
+ # Threads do not survive fork. Recreate the flush thread in the child and drop
628
+ # the events it inherited, which the parent process still flushes itself.
629
+ def ensure_flush_thread
630
+ return unless @auto_flush
631
+ return if @flush_thread_pid == Process.pid && @flush_thread&.alive?
632
+
633
+ if @flush_thread_pid && @flush_thread_pid != Process.pid
634
+ inherited = @queue_mutex.synchronize { @event_queue.shift(@event_queue.length) }
635
+ @logger.debug { "Dropped #{inherited.length} events inherited from pid #{@flush_thread_pid}" }
636
+ end
637
+
638
+ start_flush_thread
639
+ end
640
+
641
+ # Let the flush thread finish the send it is in the middle of; killing it
642
+ # would lose the events it already drained from the queue.
643
+ def stop_flush_thread
644
+ thread = @flush_thread
645
+ return unless thread
646
+
647
+ @stop_flushing = true
648
+ @flush_thread = nil
649
+ request_flush
650
+ return if thread.join(FLUSH_THREAD_JOIN_TIMEOUT)
651
+
652
+ @logger.warn("Langfuse flush thread did not stop within #{FLUSH_THREAD_JOIN_TIMEOUT}s; terminating it")
653
+ thread.kill
654
+ end
655
+
607
656
  def debug_event_data(events)
608
657
  return unless @debug
609
658
 
@@ -655,24 +704,56 @@ module Langfuse
655
704
  end
656
705
 
657
706
  def send_batch_legacy(valid_events)
658
- chunks = chunk_events(valid_events)
659
- response = nil
660
-
661
- chunks.each_with_index do |chunk, index|
662
- batch_data = build_batch_data(chunk)
663
- @logger.debug("Sending batch data: #{batch_data}")
707
+ payload = encode_batch(valid_events)
664
708
 
709
+ # Common case: the whole batch fits, so this single JSON pass covers both
710
+ # the size check and the request body. Only an oversized payload pays for
711
+ # the per-event accounting in chunk_events.
712
+ if payload && payload.bytesize <= MAX_BATCH_SIZE_BYTES
665
713
  begin
666
- response = post('/api/public/ingestion', batch_data)
667
- log_ingestion_errors(response)
668
- @logger.debug("Flushed #{chunk.length} events (legacy)")
714
+ return post_ingestion(payload, valid_events.length)
669
715
  rescue StandardError => e
670
- @logger.debug("Failed to flush events: #{e.message}")
671
- chunks[index..].each { |failed_chunk| failed_chunk.each { |event| @event_queue << event } }
716
+ @logger.debug { "Failed to flush events: #{e.message}" }
717
+ requeue_events(valid_events, e)
672
718
  raise
673
719
  end
674
720
  end
675
721
 
722
+ send_batch_legacy_chunked(valid_events)
723
+ end
724
+
725
+ def send_batch_legacy_chunked(valid_events)
726
+ chunks = chunk_events(valid_events)
727
+ response = nil
728
+
729
+ chunks.each_with_index do |chunk, index|
730
+ response = post_ingestion(encode_batch(chunk) || build_batch_data(chunk), chunk.length)
731
+ rescue StandardError => e
732
+ @logger.debug { "Failed to flush events: #{e.message}" }
733
+ requeue_events(chunks[index..].flatten(1), e)
734
+ raise
735
+ end
736
+
737
+ response
738
+ end
739
+
740
+ # Faraday forwards a String body untouched, so a pre-serialized batch is not
741
+ # encoded a second time by the JSON middleware.
742
+ def encode_batch(events)
743
+ JSON.generate(build_batch_data(events))
744
+ rescue StandardError => e
745
+ @logger.debug { "Could not pre-serialize the batch, letting Faraday encode it: #{e.message}" }
746
+ nil
747
+ end
748
+
749
+ def post_ingestion(payload, event_count)
750
+ # Block form: interpolating a multi-megabyte batch would cost the same
751
+ # whether or not debug logging is enabled.
752
+ @logger.debug { "Sending batch data: #{payload}" }
753
+
754
+ response = post('/api/public/ingestion', payload)
755
+ log_ingestion_errors(response)
756
+ @logger.debug { "Flushed #{event_count} events (legacy)" }
676
757
  response
677
758
  end
678
759
 
@@ -682,18 +763,21 @@ module Langfuse
682
763
  response = nil
683
764
 
684
765
  unless otel_events.empty?
685
- @logger.debug("Sending #{otel_events.length} events via OTEL")
766
+ @logger.debug { "Sending #{otel_events.length} events via OTEL" }
767
+ chunks = chunk_events(otel_events)
686
768
 
687
- begin
688
- response = @otel_exporter.export(otel_events)
689
- handle_response(response)
690
- @logger.debug("Flushed #{otel_events.length} events (otel)")
769
+ chunks.each_with_index do |chunk, index|
770
+ response = export_otel_chunk(chunk)
691
771
  rescue StandardError => e
692
- @logger.debug("Failed to flush OTEL events: #{e.message}")
693
- # Re-queue both OTEL and score events scores were already drained
694
- # from the queue by flush and would otherwise be permanently lost.
695
- otel_events.each { |event| @event_queue << event }
696
- score_events.each { |event| @event_queue << event }
772
+ @logger.debug { "Failed to flush OTEL events: #{e.message}" }
773
+ # Re-queue the not-yet-sent OTel chunks. Permanent failures (4xx)
774
+ # are dropped by requeue_events; transient ones are re-queued.
775
+ requeue_events(chunks[index..].flatten(1), e)
776
+ # Score events were never attempted and must always be re-queued,
777
+ # regardless of why the OTel chunk failed.
778
+ unless score_events.empty?
779
+ @queue_mutex.synchronize { score_events.each { |ev| prepend_event(ev) } }
780
+ end
697
781
  raise
698
782
  end
699
783
  end
@@ -708,6 +792,56 @@ module Langfuse
708
792
  response
709
793
  end
710
794
 
795
+ # Export one chunk of events to the OTLP endpoint with retries for transient errors.
796
+ def export_otel_chunk(chunk, retries: nil)
797
+ allowed_retries = retries || @retries
798
+ attempt = 0
799
+ response = nil
800
+
801
+ begin
802
+ response = execute_otel_export(chunk)
803
+ handle_response(response)
804
+ log_otel_partial_success(response)
805
+ @logger.debug { "Flushed #{chunk.length} events (otel)" }
806
+ response
807
+ rescue Langfuse::Error => e
808
+ raise unless attempt < allowed_retries && retryable_error?(e, response)
809
+
810
+ attempt += 1
811
+ delay = retry_delay(attempt, response)
812
+ @logger.debug { "Retrying OTEL export in #{delay.round(2)}s (attempt #{attempt}/#{allowed_retries}): #{e.message}" }
813
+ response = nil
814
+ sleep(delay)
815
+ retry
816
+ end
817
+ end
818
+
819
+ def execute_otel_export(chunk)
820
+ @otel_exporter.export(chunk)
821
+ rescue Faraday::TimeoutError => e
822
+ raise TimeoutError, "Request timed out: #{e.message}"
823
+ rescue Faraday::ConnectionFailed => e
824
+ raise NetworkError, "Connection failed: #{e.message}"
825
+ rescue Faraday::Error => e
826
+ raise APIError, "OTEL export failed: #{e.message}"
827
+ end
828
+
829
+ # The OTLP endpoint answers 200 even when it rejected part of the payload.
830
+ def log_otel_partial_success(response)
831
+ body = response.respond_to?(:body) ? response.body : nil
832
+ return unless body.is_a?(Hash)
833
+
834
+ partial = body['partialSuccess'] || body[:partialSuccess]
835
+ return unless partial.is_a?(Hash)
836
+
837
+ rejected = (partial['rejectedSpans'] || partial[:rejectedSpans]).to_i
838
+ message = (partial['errorMessage'] || partial[:errorMessage]).to_s
839
+ return if rejected.zero? && message.empty?
840
+
841
+ details = " - #{message}" unless message.empty?
842
+ @logger.warn("Langfuse OTEL partial success: #{rejected} spans rejected#{details}")
843
+ end
844
+
711
845
  # Align score references with the OTel-derived trace/span IDs so scores
712
846
  # attach to the correct entities when ingesting via the OTel endpoint.
713
847
  def normalize_otel_score_event(event)
@@ -777,26 +911,38 @@ module Langfuse
777
911
  def start_flush_thread
778
912
  return unless @auto_flush
779
913
 
780
- Thread.new do
781
- loop do
914
+ @stop_flushing = false
915
+ @flush_thread_pid = Process.pid
916
+ @flush_thread = Thread.new do
917
+ until @stop_flushing
782
918
  # Wait for the flush interval or an early wake-up (flush_at threshold)
783
919
  @flush_mutex.synchronize { @flush_condition.wait(@flush_mutex, @flush_interval) }
920
+ break if @stop_flushing
921
+
784
922
  begin
785
923
  flush unless @event_queue.empty?
786
924
  rescue StandardError => e
787
- @logger.debug("Error in flush thread: #{e.message}")
925
+ @logger.debug { "Error in flush thread: #{e.message}" }
788
926
  end
789
927
  end
790
928
  end
791
929
  end
792
930
 
793
931
  def resolve_ingestion_mode(explicit_mode)
794
- return explicit_mode.to_sym if explicit_mode
795
-
796
932
  env_mode = ENV.fetch('LANGFUSE_INGESTION_MODE', nil)
797
- return env_mode.to_sym if env_mode && !env_mode.empty?
933
+ env_mode = nil if env_mode&.empty?
934
+ mode = explicit_mode || env_mode || Langfuse.configuration.ingestion_mode
935
+ return :legacy if mode.nil?
798
936
 
799
- Langfuse.configuration.ingestion_mode || :legacy
937
+ # Unrecognized values used to silently behave like :legacy, so a typo in
938
+ # LANGFUSE_INGESTION_MODE looked like a working v4 setup.
939
+ mode = mode.to_s.downcase.to_sym
940
+ return mode if INGESTION_MODES.include?(mode)
941
+
942
+ @logger.warn do
943
+ "Unknown Langfuse ingestion_mode #{mode.inspect}, expected one of #{INGESTION_MODES.join(', ')}. Using :legacy."
944
+ end
945
+ :legacy
800
946
  end
801
947
 
802
948
  def build_connection
@@ -817,11 +963,24 @@ module Langfuse
817
963
  # 添加调试日志
818
964
  conn.response :logger if @debug
819
965
 
820
- # 使用默认适配器
821
- conn.adapter Faraday.default_adapter
966
+ apply_http_adapter(conn)
822
967
  end
823
968
  end
824
969
 
970
+ # The default net_http adapter opens and closes a connection per request.
971
+ # `http_adapter` lets an application swap in a keep-alive adapter (for
972
+ # example `:net_http_persistent`) without this gem depending on it; an
973
+ # adapter that is not installed falls back instead of breaking tracing.
974
+ def apply_http_adapter(conn)
975
+ return conn.adapter(Faraday.default_adapter) if @http_adapter.nil?
976
+
977
+ conn.adapter(*Array(@http_adapter))
978
+ rescue StandardError => e
979
+ @logger.warn("Langfuse could not use the #{@http_adapter.inspect} Faraday adapter " \
980
+ "(#{e.message}); falling back to #{Faraday.default_adapter.inspect}")
981
+ conn.adapter(Faraday.default_adapter)
982
+ end
983
+
825
984
  # Build a separate Faraday connection for OTEL with the v4 ingestion header.
826
985
  def build_otel_connection
827
986
  Faraday.new(url: @host) do |conn|
@@ -834,7 +993,7 @@ module Langfuse
834
993
 
835
994
  conn.options.timeout = @timeout
836
995
  conn.response :logger if @debug
837
- conn.adapter Faraday.default_adapter
996
+ apply_http_adapter(conn)
838
997
  end
839
998
  end
840
999
 
@@ -859,31 +1018,82 @@ module Langfuse
859
1018
  request(:patch, path, json: data)
860
1019
  end
861
1020
 
862
- def request(method, path, params: {}, json: nil)
863
- retries_left = @retries
1021
+ def request(method, path, params: {}, json: nil, retries: nil)
1022
+ allowed_retries = retries || @retries
1023
+ attempt = 0
1024
+ response = nil
864
1025
 
865
1026
  begin
866
- response = @connection.send(method) do |req|
867
- req.url path
868
- req.params = params if params.any?
869
- req.body = json if json
870
- end
871
-
1027
+ response = execute_request(method, path, params, json)
872
1028
  handle_response(response)
873
- rescue Faraday::TimeoutError => e
874
- raise TimeoutError, "Request timed out: #{e.message}"
875
- rescue Faraday::ConnectionFailed => e
876
- if retries_left.positive?
877
- retries_left -= 1
878
- sleep(2**(@retries - retries_left))
879
- retry
880
- end
881
- raise NetworkError, "Connection failed: #{e.message}"
1029
+ rescue Langfuse::Error => e
1030
+ # Typed errors raised by handle_response (401/404/429/4xx/5xx) keep their
1031
+ # class so callers can rescue AuthenticationError/RateLimitError etc.
1032
+ raise unless attempt < allowed_retries && retryable_error?(e, response)
1033
+
1034
+ attempt += 1
1035
+ delay = retry_delay(attempt, response)
1036
+ @logger.debug { "Retrying #{method.upcase} #{path} in #{delay.round(2)}s (attempt #{attempt}/#{allowed_retries}): #{e.message}" }
1037
+ response = nil
1038
+ sleep(delay)
1039
+ retry
882
1040
  rescue StandardError => e
883
1041
  raise APIError, "Request failed: #{e.message}"
884
1042
  end
885
1043
  end
886
1044
 
1045
+ def execute_request(method, path, params, json)
1046
+ @connection.send(method) do |req|
1047
+ req.url path
1048
+ req.params = params if params.any?
1049
+ req.body = json if json
1050
+ end
1051
+ rescue Faraday::TimeoutError => e
1052
+ raise TimeoutError, "Request timed out: #{e.message}"
1053
+ rescue Faraday::ConnectionFailed => e
1054
+ raise NetworkError, "Connection failed: #{e.message}"
1055
+ end
1056
+
1057
+ # Transient failures worth another attempt. Authentication and validation
1058
+ # errors would fail identically on a retry, so they are raised immediately.
1059
+ def retryable_error?(error, response)
1060
+ case error
1061
+ when TimeoutError, NetworkError, RateLimitError
1062
+ true
1063
+ when APIError
1064
+ response.respond_to?(:status) && response.status >= 500
1065
+ else
1066
+ false
1067
+ end
1068
+ end
1069
+
1070
+ # Honor the server's Retry-After when present, otherwise back off
1071
+ # exponentially with jitter.
1072
+ def retry_delay(attempt, response)
1073
+ server_delay = retry_after_seconds(response)
1074
+ return server_delay if server_delay
1075
+
1076
+ backoff = [RETRY_BASE_DELAY_SECONDS * (2**(attempt - 1)), MAX_RETRY_DELAY_SECONDS].min
1077
+ backoff * (0.5 + (rand * 0.5))
1078
+ end
1079
+
1080
+ def retry_after_seconds(response)
1081
+ raw = response.respond_to?(:headers) ? response.headers&.[]('retry-after') : nil
1082
+ return nil if raw.nil? || raw.to_s.strip.empty?
1083
+
1084
+ seconds = Float(raw, exception: false) || http_date_delay(raw)
1085
+ return nil unless seconds
1086
+
1087
+ seconds.clamp(0, MAX_RETRY_DELAY_SECONDS)
1088
+ end
1089
+
1090
+ # Retry-After may also be an HTTP date instead of a number of seconds.
1091
+ def http_date_delay(raw)
1092
+ Time.httpdate(raw.to_s) - Time.now
1093
+ rescue ArgumentError
1094
+ nil
1095
+ end
1096
+
887
1097
  def handle_response(response)
888
1098
  @logger.debug("Handling response with status: #{response.status}")
889
1099