langfuse-ruby 0.1.6 → 0.2.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.
@@ -6,32 +6,73 @@ require 'faraday/multipart'
6
6
  require 'json'
7
7
  require 'base64'
8
8
  require 'concurrent'
9
+ require 'logger'
10
+ require 'digest'
9
11
 
10
12
  module Langfuse
11
13
  class Client
12
- attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush
14
+ # The ingestion API limits batch payloads to 3.5 MB in total
15
+ MAX_BATCH_SIZE_BYTES = 3_500_000
16
+
17
+ # Allowed format for the tracing environment field
18
+ ENVIRONMENT_PATTERN = /\A(?!langfuse)[a-z0-9\-_]{1,40}\z/
19
+
20
+ # Log device that resolves $stdout at write time so output redirection
21
+ # (e.g. in tests) keeps working after the logger was created.
22
+ class StdoutLogDevice
23
+ def write(message)
24
+ $stdout.write(message)
25
+ end
26
+
27
+ def close; end
28
+ end
29
+
30
+ attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
31
+ :ingestion_mode, :environment, :sample_rate, :flush_at, :mask, :logger
13
32
 
14
33
  def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: 30, retries: 3,
15
- flush_interval: nil, auto_flush: nil)
16
- @public_key = public_key || ENV['LANGFUSE_PUBLIC_KEY'] || Langfuse.configuration.public_key
17
- @secret_key = secret_key || ENV['LANGFUSE_SECRET_KEY'] || Langfuse.configuration.secret_key
18
- @host = host || ENV['LANGFUSE_HOST'] || Langfuse.configuration.host
19
- @debug = debug || Langfuse.configuration.debug
20
- @timeout = timeout || Langfuse.configuration.timeout
21
- @retries = retries || Langfuse.configuration.retries
22
- @flush_interval = flush_interval || ENV['LANGFUSE_FLUSH_INTERVAL']&.to_i || Langfuse.configuration.flush_interval
23
- @auto_flush = if auto_flush.nil?
24
- ENV['LANGFUSE_AUTO_FLUSH'] == 'false' ? false : Langfuse.configuration.auto_flush
25
- else
26
- auto_flush
27
- end
34
+ 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)
36
+ @public_key = config_value(public_key, 'LANGFUSE_PUBLIC_KEY', :public_key)
37
+ @secret_key = config_value(secret_key, 'LANGFUSE_SECRET_KEY', :secret_key)
38
+ @host = host || ENV['LANGFUSE_HOST'] || ENV['LANGFUSE_BASE_URL'] || Langfuse.configuration.host
39
+ @debug = debug || ENV['LANGFUSE_DEBUG'] == 'true' || Langfuse.configuration.debug
40
+ @timeout = config_value(timeout, nil, :timeout) { 30 }
41
+ @retries = config_value(retries, nil, :retries) { 3 }
42
+ @flush_interval = config_value(flush_interval, 'LANGFUSE_FLUSH_INTERVAL', :flush_interval) { 5 }
43
+ @flush_at = config_value(flush_at, 'LANGFUSE_FLUSH_AT', :flush_at) { 15 }
44
+ @auto_flush = resolve_auto_flush(auto_flush)
45
+ @ingestion_mode = resolve_ingestion_mode(ingestion_mode)
46
+ @logger = logger || Langfuse.configuration.logger || build_default_logger
47
+ @environment = resolve_environment(environment)
48
+ @sample_rate = resolve_sample_rate(sample_rate)
49
+ @mask = resolve_mask(mask)
50
+ @shutdown_on_exit = shutdown_on_exit.nil? ? Langfuse.configuration.shutdown_on_exit : shutdown_on_exit
51
+ @shutdown = false
28
52
 
29
53
  raise AuthenticationError, 'Public key is required' unless @public_key
30
54
  raise AuthenticationError, 'Secret key is required' unless @secret_key
31
55
 
32
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
33
59
  @event_queue = Concurrent::Array.new
60
+ @flush_mutex = Mutex.new
61
+ @flush_condition = ConditionVariable.new
34
62
  @flush_thread = start_flush_thread if @auto_flush
63
+ register_shutdown_hook if @shutdown_on_exit
64
+ end
65
+
66
+ # Generate a trace ID matching the active ingestion mode
67
+ # (W3C 32-char hex for :otel, UUID for :legacy)
68
+ def generate_trace_id
69
+ @ingestion_mode == :otel ? Utils.generate_hex_trace_id : Utils.generate_id
70
+ end
71
+
72
+ # Generate an observation ID matching the active ingestion mode
73
+ # (W3C 16-char hex for :otel, UUID for :legacy)
74
+ def generate_observation_id
75
+ @ingestion_mode == :otel ? Utils.generate_hex_span_id : Utils.generate_id
35
76
  end
36
77
 
37
78
  # Trace operations
@@ -39,7 +80,7 @@ module Langfuse
39
80
  input: nil, output: nil, metadata: nil, tags: nil, timestamp: nil, **kwargs)
40
81
  Trace.new(
41
82
  client: self,
42
- id: id || Utils.generate_id,
83
+ id: id || generate_trace_id,
43
84
  name: name,
44
85
  user_id: user_id,
45
86
  session_id: session_id,
@@ -55,12 +96,13 @@ module Langfuse
55
96
  end
56
97
 
57
98
  # Span operations
58
- def span(trace_id:, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
99
+ def span(trace_id:, id: nil, name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
59
100
  metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
60
101
  version: nil, as_type: nil, **kwargs)
61
102
  Span.new(
62
103
  client: self,
63
104
  trace_id: trace_id,
105
+ id: id || generate_observation_id,
64
106
  name: name,
65
107
  start_time: start_time || Utils.current_timestamp,
66
108
  end_time: end_time,
@@ -229,13 +271,15 @@ module Langfuse
229
271
  end
230
272
 
231
273
  # Generation operations
232
- def generation(trace_id:, name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
274
+ def generation(trace_id:, id: nil, name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
233
275
  model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
276
+ usage_details: nil, cost_details: nil, prompt: nil,
234
277
  metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
235
278
  version: nil, **kwargs)
236
279
  Generation.new(
237
280
  client: self,
238
281
  trace_id: trace_id,
282
+ id: id || generate_observation_id,
239
283
  name: name,
240
284
  start_time: start_time || Utils.current_timestamp,
241
285
  end_time: end_time,
@@ -245,6 +289,9 @@ module Langfuse
245
289
  input: input,
246
290
  output: output,
247
291
  usage: usage,
292
+ usage_details: usage_details,
293
+ cost_details: cost_details,
294
+ prompt: prompt,
248
295
  metadata: metadata,
249
296
  level: level,
250
297
  status_message: status_message,
@@ -255,11 +302,12 @@ module Langfuse
255
302
  end
256
303
 
257
304
  # Event operations
258
- def event(trace_id:, name:, start_time: nil, input: nil, output: nil, metadata: nil,
305
+ def event(trace_id:, name:, id: nil, start_time: nil, input: nil, output: nil, metadata: nil,
259
306
  level: nil, status_message: nil, parent_observation_id: nil, version: nil, **kwargs)
260
307
  Event.new(
261
308
  client: self,
262
309
  trace_id: trace_id,
310
+ id: id || generate_observation_id,
263
311
  name: name,
264
312
  start_time: start_time,
265
313
  input: input,
@@ -287,18 +335,18 @@ module Langfuse
287
335
  params[:version] = version if version
288
336
  params[:label] = label if label
289
337
 
290
- puts "Making request to: #{@host}#{path} with params: #{params}" if @debug
338
+ @logger.debug("Making request to: #{@host}#{path} with params: #{params}")
291
339
 
292
340
  response = get(path, params)
293
341
 
294
- puts "Response status: #{response.status}" if @debug
295
- puts "Response headers: #{response.headers}" if @debug
296
- puts "Response body type: #{response.body.class}" if @debug
342
+ @logger.debug("Response status: #{response.status}")
343
+ @logger.debug("Response headers: #{response.headers}")
344
+ @logger.debug("Response body type: #{response.body.class}")
297
345
 
298
346
  # Check if response body is a string (HTML) instead of parsed JSON
299
347
  if response.body.is_a?(String) && response.body.include?('<!DOCTYPE html>')
300
- puts 'Received HTML response instead of JSON:' if @debug
301
- puts response.body[0..200] if @debug
348
+ @logger.debug('Received HTML response instead of JSON:')
349
+ @logger.debug(response.body[0..200])
302
350
  raise APIError,
303
351
  'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
304
352
  end
@@ -326,20 +374,35 @@ module Langfuse
326
374
  end
327
375
 
328
376
  # Score/Evaluation operations
329
- def score(name:, value:, trace_id: nil, observation_id: nil, data_type: nil, comment: nil, **kwargs)
377
+ # Scores can target a trace, an observation (trace_id + observation_id),
378
+ # a session (session_id) or a dataset run (dataset_run_id).
379
+ def score(name:, value:, trace_id: nil, observation_id: nil, session_id: nil, dataset_run_id: nil,
380
+ id: nil, data_type: nil, comment: nil, metadata: nil, config_id: nil, queue_id: nil,
381
+ environment: nil, **kwargs)
330
382
  data = {
383
+ id: id,
384
+ trace_id: trace_id,
385
+ observation_id: observation_id,
386
+ session_id: session_id,
387
+ dataset_run_id: dataset_run_id,
331
388
  name: name,
332
389
  value: value,
333
390
  data_type: data_type,
334
391
  comment: comment,
392
+ metadata: metadata,
393
+ config_id: config_id,
394
+ queue_id: queue_id,
395
+ environment: environment,
335
396
  **kwargs
336
- }
397
+ }.compact
337
398
 
338
- data[:trace_id] = trace_id if trace_id
339
- data[:observation_id] = observation_id if observation_id
399
+ if trace_id.nil? && observation_id.nil? && session_id.nil? && dataset_run_id.nil?
400
+ @logger.warn('Langfuse score should reference a trace_id, observation_id, session_id or dataset_run_id')
401
+ end
340
402
 
341
403
  enqueue_event('score-create', data)
342
404
  end
405
+ alias create_score score
343
406
 
344
407
  # Event queue management
345
408
  def enqueue_event(type, body)
@@ -353,15 +416,21 @@ module Langfuse
353
416
  ]
354
417
 
355
418
  unless valid_types.include?(type)
356
- puts "Warning: Invalid event type '#{type}'. Skipping event." if @debug
419
+ @logger.debug("Warning: Invalid event type '#{type}'. Skipping event.")
357
420
  return
358
421
  end
359
422
 
423
+ prepared_body = Utils.prepare_event_body(body)
424
+ inject_default_environment(prepared_body)
425
+ apply_mask(prepared_body)
426
+
427
+ return unless sampled_event?(type, prepared_body)
428
+
360
429
  event = {
361
430
  id: Utils.generate_id,
362
431
  type: type,
363
432
  timestamp: Utils.current_timestamp,
364
- body: Utils.deep_stringify_keys(body)
433
+ body: prepared_body
365
434
  }
366
435
 
367
436
  if type == 'trace-update'
@@ -377,20 +446,22 @@ module Langfuse
377
446
  # 更新现有的 trace-create 事件
378
447
  @event_queue[existing_event_index][:body].merge!(event[:body])
379
448
  @event_queue[existing_event_index][:timestamp] = event[:timestamp]
380
- puts "Updated existing trace-create event for trace_id: #{trace_id}" if @debug
449
+ @logger.debug("Updated existing trace-create event for trace_id: #{trace_id}")
381
450
  else
382
451
  # 如果没找到对应的 trace-create 事件,将 trace-update 转换为 trace-create
383
452
  event[:type] = 'trace-create'
384
453
  @event_queue << event
385
- puts "Converted trace-update to trace-create for trace_id: #{trace_id}" if @debug
454
+ @logger.debug("Converted trace-update to trace-create for trace_id: #{trace_id}")
386
455
  end
387
- elsif @debug
388
- puts 'Warning: trace-update event missing trace_id, skipping'
456
+ else
457
+ @logger.debug('Warning: trace-update event missing trace_id, skipping')
389
458
  end
390
459
  else
391
460
  @event_queue << event
392
461
  end
393
- puts "Enqueued event: #{type}" if @debug
462
+ @logger.debug("Enqueued event: #{type}")
463
+
464
+ request_flush if @auto_flush && @event_queue.length >= @flush_at
394
465
  end
395
466
 
396
467
  def flush
@@ -403,31 +474,155 @@ module Langfuse
403
474
  end
404
475
 
405
476
  def shutdown
477
+ return if @shutdown
478
+
479
+ @shutdown = true
406
480
  @flush_thread&.kill if @auto_flush
407
481
  flush unless @event_queue.empty?
408
482
  end
409
483
 
410
484
  private
411
485
 
486
+ def build_default_logger
487
+ logger = Logger.new(StdoutLogDevice.new)
488
+ logger.level = @debug ? Logger::DEBUG : Logger::WARN
489
+ logger.progname = 'langfuse'
490
+ logger.formatter = proc do |severity, _time, progname, msg|
491
+ "#{severity} -- #{progname}: #{msg}\n"
492
+ end
493
+ logger
494
+ end
495
+
496
+ # Resolve a config value with precedence: explicit arg > env var > config attr > block default
497
+ def config_value(explicit, env_key, config_attr)
498
+ return explicit if explicit
499
+
500
+ if env_key
501
+ 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)
503
+ return env_val if env_val
504
+ end
505
+
506
+ Langfuse.configuration.send(config_attr) || (yield if block_given?)
507
+ end
508
+
509
+ def resolve_auto_flush(auto_flush)
510
+ if auto_flush.nil?
511
+ ENV['LANGFUSE_AUTO_FLUSH'] == 'false' ? false : Langfuse.configuration.auto_flush
512
+ else
513
+ auto_flush
514
+ end
515
+ end
516
+
517
+ def resolve_environment(explicit_environment)
518
+ environment = explicit_environment || ENV['LANGFUSE_TRACING_ENVIRONMENT'] || Langfuse.configuration.environment
519
+ return nil if environment.nil? || environment.to_s.empty?
520
+
521
+ environment = environment.to_s
522
+ unless environment.match?(ENVIRONMENT_PATTERN)
523
+ @logger.warn("Invalid Langfuse environment '#{environment}'. It must match #{ENVIRONMENT_PATTERN.inspect}. " \
524
+ 'Events may be rejected by the server.')
525
+ end
526
+ environment
527
+ end
528
+
529
+ def resolve_sample_rate(explicit_sample_rate)
530
+ rate = explicit_sample_rate || ENV['LANGFUSE_SAMPLE_RATE']&.to_f || Langfuse.configuration.sample_rate
531
+ return nil if rate.nil?
532
+
533
+ rate = rate.to_f
534
+ unless rate.between?(0.0, 1.0)
535
+ @logger.warn("Invalid Langfuse sample_rate #{rate}, expected 0.0..1.0. Disabling sampling.")
536
+ return nil
537
+ end
538
+ rate
539
+ end
540
+
541
+ def resolve_mask(explicit_mask)
542
+ mask = explicit_mask || Langfuse.configuration.mask
543
+ return nil if mask.nil?
544
+
545
+ unless mask.respond_to?(:call)
546
+ @logger.warn('Langfuse mask must respond to #call. Ignoring mask.')
547
+ return nil
548
+ end
549
+ mask
550
+ end
551
+
552
+ def register_shutdown_hook
553
+ at_exit do
554
+ shutdown
555
+ rescue StandardError => e
556
+ @logger.debug("Langfuse shutdown on exit failed: #{e.message}")
557
+ end
558
+ end
559
+
560
+ def inject_default_environment(body)
561
+ return unless @environment
562
+ return if body.key?('environment')
563
+
564
+ body['environment'] = @environment
565
+ end
566
+
567
+ def apply_mask(body)
568
+ return unless @mask
569
+
570
+ %w[input output metadata].each do |field|
571
+ next unless body.key?(field) && !body[field].nil?
572
+
573
+ body[field] = begin
574
+ @mask.call(body[field])
575
+ rescue StandardError => e
576
+ @logger.error("Langfuse mask function failed: #{e.message}")
577
+ '<masked due to failed mask function>'
578
+ end
579
+ end
580
+ end
581
+
582
+ # Deterministic trace-based sampling: all events of a trace share the same decision.
583
+ def sampled_event?(type, body)
584
+ return true unless @sample_rate
585
+
586
+ trace_id = %w[trace-create trace-update].include?(type) ? body['id'] : body['traceId']
587
+ return true if trace_id.nil?
588
+
589
+ return true if trace_sampled?(trace_id)
590
+
591
+ @logger.debug("Dropping event for trace #{trace_id} due to sampling (rate: #{@sample_rate})")
592
+ false
593
+ end
594
+
595
+ def trace_sampled?(trace_id)
596
+ return true if @sample_rate >= 1.0
597
+ return false if @sample_rate <= 0.0
598
+
599
+ normalized = Digest::SHA256.hexdigest(trace_id.to_s)[0, 8].to_i(16).to_f / 0xffffffff
600
+ normalized < @sample_rate
601
+ end
602
+
603
+ def request_flush
604
+ @flush_mutex.synchronize { @flush_condition.signal }
605
+ end
606
+
412
607
  def debug_event_data(events)
413
608
  return unless @debug
414
609
 
415
- puts "\n=== Event Data Debug Information ==="
610
+ @logger.debug('=== Event Data Debug Information ===')
416
611
  events.each_with_index do |event, index|
417
- puts "Event #{index + 1}:"
418
- puts " ID: #{event[:id]}"
419
- puts " Type: #{event[:type]}"
420
- puts " Timestamp: #{event[:timestamp]}"
421
- puts " Body keys: #{event[:body]&.keys || 'nil'}"
612
+ @logger.debug("Event #{index + 1}:")
613
+ @logger.debug(" ID: #{event[:id]}")
614
+ @logger.debug(" Type: #{event[:type]}")
615
+ @logger.debug(" Timestamp: #{event[:timestamp]}")
616
+ @logger.debug(" Body keys: #{event[:body]&.keys || 'nil'}")
422
617
 
423
618
  # 检查常见的问题
424
- puts ' ⚠️ WARNING: Empty or nil type!' if event[:type].nil? || event[:type].to_s.empty?
619
+ @logger.debug(' ⚠️ WARNING: Empty or nil type!') if event[:type].nil? || event[:type].to_s.empty?
425
620
 
426
- puts ' ⚠️ WARNING: Empty body!' if event[:body].nil?
621
+ @logger.debug(' ⚠️ WARNING: Empty body!') if event[:body].nil?
427
622
 
428
- puts ' ---'
623
+ @logger.debug(' ---')
429
624
  end
430
- puts "=== End Debug Information ===\n"
625
+ @logger.debug('=== End Debug Information ===')
431
626
  end
432
627
 
433
628
  def send_batch(events)
@@ -437,10 +632,10 @@ module Langfuse
437
632
  # 验证事件数据
438
633
  valid_events = events.select do |event|
439
634
  if event[:type].nil? || event[:type].to_s.empty?
440
- puts "Warning: Event with empty type detected, skipping: #{event[:id]}" if @debug
635
+ @logger.debug("Warning: Event with empty type detected, skipping: #{event[:id]}")
441
636
  false
442
637
  elsif event[:body].nil?
443
- puts "Warning: Event with empty body detected, skipping: #{event[:id]}" if @debug
638
+ @logger.debug("Warning: Event with empty body detected, skipping: #{event[:id]}")
444
639
  false
445
640
  else
446
641
  true
@@ -448,22 +643,123 @@ module Langfuse
448
643
  end
449
644
 
450
645
  if valid_events.empty?
451
- puts 'No valid events to send' if @debug
646
+ @logger.debug('No valid events to send')
452
647
  return
453
648
  end
454
649
 
455
- batch_data = build_batch_data(valid_events)
456
- puts "Sending batch data: #{batch_data}" if @debug
650
+ if @ingestion_mode == :otel
651
+ send_batch_otel(valid_events)
652
+ else
653
+ send_batch_legacy(valid_events)
654
+ end
655
+ end
457
656
 
458
- begin
459
- response = post('/api/public/ingestion', batch_data)
460
- puts "Flushed #{valid_events.length} events" if @debug
461
- response
462
- rescue StandardError => e
463
- puts "Failed to flush events: #{e.message}" if @debug
464
- # Re-queue events on failure
465
- valid_events.each { |event| @event_queue << event }
466
- raise
657
+ 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}")
664
+
665
+ begin
666
+ response = post('/api/public/ingestion', batch_data)
667
+ log_ingestion_errors(response)
668
+ @logger.debug("Flushed #{chunk.length} events (legacy)")
669
+ 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 } }
672
+ raise
673
+ end
674
+ end
675
+
676
+ response
677
+ end
678
+
679
+ def send_batch_otel(valid_events)
680
+ score_events, otel_events = valid_events.partition { |event| event[:type] == 'score-create' }
681
+
682
+ response = nil
683
+
684
+ unless otel_events.empty?
685
+ @logger.debug("Sending #{otel_events.length} events via OTEL")
686
+
687
+ begin
688
+ response = @otel_exporter.export(otel_events)
689
+ handle_response(response)
690
+ @logger.debug("Flushed #{otel_events.length} events (otel)")
691
+ 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 }
697
+ raise
698
+ end
699
+ end
700
+
701
+ # Scores are not part of the OTLP trace mapping; they always go through
702
+ # the ingestion API. IDs are normalized to match the OTel-derived IDs.
703
+ unless score_events.empty?
704
+ score_events.each { |event| normalize_otel_score_event(event) }
705
+ response = send_batch_legacy(score_events)
706
+ end
707
+
708
+ response
709
+ end
710
+
711
+ # Align score references with the OTel-derived trace/span IDs so scores
712
+ # attach to the correct entities when ingesting via the OTel endpoint.
713
+ def normalize_otel_score_event(event)
714
+ body = event[:body]
715
+ return unless body.is_a?(Hash)
716
+
717
+ body['traceId'] = OtelExporter.to_otel_trace_id(body['traceId']) if body['traceId']
718
+ body['observationId'] = OtelExporter.to_otel_span_id(body['observationId']) if body['observationId']
719
+ end
720
+
721
+ # Split events into chunks that respect the ingestion API batch size limit.
722
+ def chunk_events(events)
723
+ chunks = [[]]
724
+ current_size = 0
725
+
726
+ events.each do |event|
727
+ event_size = estimated_event_size(event)
728
+
729
+ if event_size > MAX_BATCH_SIZE_BYTES
730
+ @logger.warn("Langfuse event #{event[:id]} exceeds the maximum batch size of #{MAX_BATCH_SIZE_BYTES} bytes and was dropped")
731
+ next
732
+ end
733
+
734
+ if current_size + event_size > MAX_BATCH_SIZE_BYTES && !chunks.last.empty?
735
+ chunks << []
736
+ current_size = 0
737
+ end
738
+
739
+ chunks.last << event
740
+ current_size += event_size
741
+ end
742
+
743
+ chunks.reject(&:empty?)
744
+ end
745
+
746
+ def estimated_event_size(event)
747
+ JSON.generate(event).bytesize
748
+ rescue StandardError
749
+ 1024
750
+ end
751
+
752
+ # The ingestion API responds with 207 and per-event successes/errors.
753
+ def log_ingestion_errors(response)
754
+ body = response.respond_to?(:body) ? response.body : nil
755
+ return unless body.is_a?(Hash)
756
+
757
+ errors = body['errors']
758
+ return unless errors.is_a?(Array) && errors.any?
759
+
760
+ errors.each do |error|
761
+ @logger.warn("Langfuse ingestion partial failure (status #{error['status']}): " \
762
+ "event #{error['id']} - #{error['message']}")
467
763
  end
468
764
  end
469
765
 
@@ -483,16 +779,26 @@ module Langfuse
483
779
 
484
780
  Thread.new do
485
781
  loop do
486
- sleep(@flush_interval) # Configurable flush interval
782
+ # Wait for the flush interval or an early wake-up (flush_at threshold)
783
+ @flush_mutex.synchronize { @flush_condition.wait(@flush_mutex, @flush_interval) }
487
784
  begin
488
785
  flush unless @event_queue.empty?
489
786
  rescue StandardError => e
490
- puts "Error in flush thread: #{e.message}" if @debug
787
+ @logger.debug("Error in flush thread: #{e.message}")
491
788
  end
492
789
  end
493
790
  end
494
791
  end
495
792
 
793
+ def resolve_ingestion_mode(explicit_mode)
794
+ return explicit_mode.to_sym if explicit_mode
795
+
796
+ env_mode = ENV.fetch('LANGFUSE_INGESTION_MODE', nil)
797
+ return env_mode.to_sym if env_mode && !env_mode.empty?
798
+
799
+ Langfuse.configuration.ingestion_mode || :legacy
800
+ end
801
+
496
802
  def build_connection
497
803
  Faraday.new(url: @host) do |conn|
498
804
  # 配置请求和响应处理
@@ -516,6 +822,22 @@ module Langfuse
516
822
  end
517
823
  end
518
824
 
825
+ # Build a separate Faraday connection for OTEL with the v4 ingestion header.
826
+ def build_otel_connection
827
+ Faraday.new(url: @host) do |conn|
828
+ conn.response :json, content_type: /\bjson$/
829
+
830
+ conn.headers['User-Agent'] = "langfuse-ruby/#{Langfuse::VERSION}"
831
+ conn.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
832
+ conn.headers['x-langfuse-ingestion-version'] = '4'
833
+ conn.headers['Content-Type'] = 'application/json'
834
+
835
+ conn.options.timeout = @timeout
836
+ conn.response :logger if @debug
837
+ conn.adapter Faraday.default_adapter
838
+ end
839
+ end
840
+
519
841
  # HTTP methods
520
842
  def get(path, params = {})
521
843
  request(:get, path, params: params)
@@ -563,7 +885,7 @@ module Langfuse
563
885
  end
564
886
 
565
887
  def handle_response(response)
566
- puts "Handling response with status: #{response.status}" if @debug
888
+ @logger.debug("Handling response with status: #{response.status}")
567
889
 
568
890
  case response.status
569
891
  when 200..299