langfuse-ruby 0.1.7 → 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,36 +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
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
+
12
30
  attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
13
- :ingestion_mode
31
+ :ingestion_mode, :environment, :sample_rate, :flush_at, :mask, :logger
14
32
 
15
33
  def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: 30, retries: 3,
16
- flush_interval: nil, auto_flush: nil, ingestion_mode: nil)
17
- @public_key = public_key || ENV['LANGFUSE_PUBLIC_KEY'] || Langfuse.configuration.public_key
18
- @secret_key = secret_key || ENV['LANGFUSE_SECRET_KEY'] || Langfuse.configuration.secret_key
19
- @host = host || ENV['LANGFUSE_HOST'] || Langfuse.configuration.host
20
- @debug = debug || Langfuse.configuration.debug
21
- @timeout = timeout || Langfuse.configuration.timeout
22
- @retries = retries || Langfuse.configuration.retries
23
- @flush_interval = flush_interval || ENV['LANGFUSE_FLUSH_INTERVAL']&.to_i || Langfuse.configuration.flush_interval
24
- @auto_flush = if auto_flush.nil?
25
- ENV['LANGFUSE_AUTO_FLUSH'] == 'false' ? false : Langfuse.configuration.auto_flush
26
- else
27
- auto_flush
28
- 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)
29
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
30
52
 
31
53
  raise AuthenticationError, 'Public key is required' unless @public_key
32
54
  raise AuthenticationError, 'Secret key is required' unless @secret_key
33
55
 
34
56
  @connection = build_connection
35
57
  @otel_connection = build_otel_connection if @ingestion_mode == :otel
36
- @otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug) if @ingestion_mode == :otel
58
+ @otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug, logger: @logger) if @ingestion_mode == :otel
37
59
  @event_queue = Concurrent::Array.new
60
+ @flush_mutex = Mutex.new
61
+ @flush_condition = ConditionVariable.new
38
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
39
76
  end
40
77
 
41
78
  # Trace operations
@@ -43,7 +80,7 @@ module Langfuse
43
80
  input: nil, output: nil, metadata: nil, tags: nil, timestamp: nil, **kwargs)
44
81
  Trace.new(
45
82
  client: self,
46
- id: id || Utils.generate_id,
83
+ id: id || generate_trace_id,
47
84
  name: name,
48
85
  user_id: user_id,
49
86
  session_id: session_id,
@@ -59,12 +96,13 @@ module Langfuse
59
96
  end
60
97
 
61
98
  # Span operations
62
- 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,
63
100
  metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
64
101
  version: nil, as_type: nil, **kwargs)
65
102
  Span.new(
66
103
  client: self,
67
104
  trace_id: trace_id,
105
+ id: id || generate_observation_id,
68
106
  name: name,
69
107
  start_time: start_time || Utils.current_timestamp,
70
108
  end_time: end_time,
@@ -233,13 +271,15 @@ module Langfuse
233
271
  end
234
272
 
235
273
  # Generation operations
236
- 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,
237
275
  model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
276
+ usage_details: nil, cost_details: nil, prompt: nil,
238
277
  metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
239
278
  version: nil, **kwargs)
240
279
  Generation.new(
241
280
  client: self,
242
281
  trace_id: trace_id,
282
+ id: id || generate_observation_id,
243
283
  name: name,
244
284
  start_time: start_time || Utils.current_timestamp,
245
285
  end_time: end_time,
@@ -249,6 +289,9 @@ module Langfuse
249
289
  input: input,
250
290
  output: output,
251
291
  usage: usage,
292
+ usage_details: usage_details,
293
+ cost_details: cost_details,
294
+ prompt: prompt,
252
295
  metadata: metadata,
253
296
  level: level,
254
297
  status_message: status_message,
@@ -259,11 +302,12 @@ module Langfuse
259
302
  end
260
303
 
261
304
  # Event operations
262
- 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,
263
306
  level: nil, status_message: nil, parent_observation_id: nil, version: nil, **kwargs)
264
307
  Event.new(
265
308
  client: self,
266
309
  trace_id: trace_id,
310
+ id: id || generate_observation_id,
267
311
  name: name,
268
312
  start_time: start_time,
269
313
  input: input,
@@ -291,18 +335,18 @@ module Langfuse
291
335
  params[:version] = version if version
292
336
  params[:label] = label if label
293
337
 
294
- puts "Making request to: #{@host}#{path} with params: #{params}" if @debug
338
+ @logger.debug("Making request to: #{@host}#{path} with params: #{params}")
295
339
 
296
340
  response = get(path, params)
297
341
 
298
- puts "Response status: #{response.status}" if @debug
299
- puts "Response headers: #{response.headers}" if @debug
300
- 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}")
301
345
 
302
346
  # Check if response body is a string (HTML) instead of parsed JSON
303
347
  if response.body.is_a?(String) && response.body.include?('<!DOCTYPE html>')
304
- puts 'Received HTML response instead of JSON:' if @debug
305
- puts response.body[0..200] if @debug
348
+ @logger.debug('Received HTML response instead of JSON:')
349
+ @logger.debug(response.body[0..200])
306
350
  raise APIError,
307
351
  'Received HTML response instead of JSON. This usually indicates a 404 error or incorrect API endpoint.'
308
352
  end
@@ -330,20 +374,35 @@ module Langfuse
330
374
  end
331
375
 
332
376
  # Score/Evaluation operations
333
- 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)
334
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,
335
388
  name: name,
336
389
  value: value,
337
390
  data_type: data_type,
338
391
  comment: comment,
392
+ metadata: metadata,
393
+ config_id: config_id,
394
+ queue_id: queue_id,
395
+ environment: environment,
339
396
  **kwargs
340
- }
397
+ }.compact
341
398
 
342
- data[:trace_id] = trace_id if trace_id
343
- 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
344
402
 
345
403
  enqueue_event('score-create', data)
346
404
  end
405
+ alias create_score score
347
406
 
348
407
  # Event queue management
349
408
  def enqueue_event(type, body)
@@ -357,15 +416,21 @@ module Langfuse
357
416
  ]
358
417
 
359
418
  unless valid_types.include?(type)
360
- puts "Warning: Invalid event type '#{type}'. Skipping event." if @debug
419
+ @logger.debug("Warning: Invalid event type '#{type}'. Skipping event.")
361
420
  return
362
421
  end
363
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
+
364
429
  event = {
365
430
  id: Utils.generate_id,
366
431
  type: type,
367
432
  timestamp: Utils.current_timestamp,
368
- body: Utils.deep_stringify_keys(body)
433
+ body: prepared_body
369
434
  }
370
435
 
371
436
  if type == 'trace-update'
@@ -381,20 +446,22 @@ module Langfuse
381
446
  # 更新现有的 trace-create 事件
382
447
  @event_queue[existing_event_index][:body].merge!(event[:body])
383
448
  @event_queue[existing_event_index][:timestamp] = event[:timestamp]
384
- 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}")
385
450
  else
386
451
  # 如果没找到对应的 trace-create 事件,将 trace-update 转换为 trace-create
387
452
  event[:type] = 'trace-create'
388
453
  @event_queue << event
389
- 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}")
390
455
  end
391
- elsif @debug
392
- puts 'Warning: trace-update event missing trace_id, skipping'
456
+ else
457
+ @logger.debug('Warning: trace-update event missing trace_id, skipping')
393
458
  end
394
459
  else
395
460
  @event_queue << event
396
461
  end
397
- puts "Enqueued event: #{type}" if @debug
462
+ @logger.debug("Enqueued event: #{type}")
463
+
464
+ request_flush if @auto_flush && @event_queue.length >= @flush_at
398
465
  end
399
466
 
400
467
  def flush
@@ -407,31 +474,155 @@ module Langfuse
407
474
  end
408
475
 
409
476
  def shutdown
477
+ return if @shutdown
478
+
479
+ @shutdown = true
410
480
  @flush_thread&.kill if @auto_flush
411
481
  flush unless @event_queue.empty?
412
482
  end
413
483
 
414
484
  private
415
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
+
416
607
  def debug_event_data(events)
417
608
  return unless @debug
418
609
 
419
- puts "\n=== Event Data Debug Information ==="
610
+ @logger.debug('=== Event Data Debug Information ===')
420
611
  events.each_with_index do |event, index|
421
- puts "Event #{index + 1}:"
422
- puts " ID: #{event[:id]}"
423
- puts " Type: #{event[:type]}"
424
- puts " Timestamp: #{event[:timestamp]}"
425
- 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'}")
426
617
 
427
618
  # 检查常见的问题
428
- 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?
429
620
 
430
- puts ' ⚠️ WARNING: Empty body!' if event[:body].nil?
621
+ @logger.debug(' ⚠️ WARNING: Empty body!') if event[:body].nil?
431
622
 
432
- puts ' ---'
623
+ @logger.debug(' ---')
433
624
  end
434
- puts "=== End Debug Information ===\n"
625
+ @logger.debug('=== End Debug Information ===')
435
626
  end
436
627
 
437
628
  def send_batch(events)
@@ -441,10 +632,10 @@ module Langfuse
441
632
  # 验证事件数据
442
633
  valid_events = events.select do |event|
443
634
  if event[:type].nil? || event[:type].to_s.empty?
444
- puts "Warning: Event with empty type detected, skipping: #{event[:id]}" if @debug
635
+ @logger.debug("Warning: Event with empty type detected, skipping: #{event[:id]}")
445
636
  false
446
637
  elsif event[:body].nil?
447
- puts "Warning: Event with empty body detected, skipping: #{event[:id]}" if @debug
638
+ @logger.debug("Warning: Event with empty body detected, skipping: #{event[:id]}")
448
639
  false
449
640
  else
450
641
  true
@@ -452,7 +643,7 @@ module Langfuse
452
643
  end
453
644
 
454
645
  if valid_events.empty?
455
- puts 'No valid events to send' if @debug
646
+ @logger.debug('No valid events to send')
456
647
  return
457
648
  end
458
649
 
@@ -464,32 +655,111 @@ module Langfuse
464
655
  end
465
656
 
466
657
  def send_batch_legacy(valid_events)
467
- batch_data = build_batch_data(valid_events)
468
- puts "Sending batch data: #{batch_data}" if @debug
469
-
470
- begin
471
- response = post('/api/public/ingestion', batch_data)
472
- puts "Flushed #{valid_events.length} events (legacy)" if @debug
473
- response
474
- rescue StandardError => e
475
- puts "Failed to flush events: #{e.message}" if @debug
476
- valid_events.each { |event| @event_queue << event }
477
- raise
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
478
674
  end
675
+
676
+ response
479
677
  end
480
678
 
481
679
  def send_batch_otel(valid_events)
482
- puts "Sending #{valid_events.length} events via OTEL" if @debug
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
483
700
 
484
- begin
485
- response = @otel_exporter.export(valid_events)
486
- handle_response(response)
487
- puts "Flushed #{valid_events.length} events (otel)" if @debug
488
- response
489
- rescue StandardError => e
490
- puts "Failed to flush OTEL events: #{e.message}" if @debug
491
- valid_events.each { |event| @event_queue << event }
492
- raise
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']}")
493
763
  end
494
764
  end
495
765
 
@@ -509,11 +779,12 @@ module Langfuse
509
779
 
510
780
  Thread.new do
511
781
  loop do
512
- 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) }
513
784
  begin
514
785
  flush unless @event_queue.empty?
515
786
  rescue StandardError => e
516
- puts "Error in flush thread: #{e.message}" if @debug
787
+ @logger.debug("Error in flush thread: #{e.message}")
517
788
  end
518
789
  end
519
790
  end
@@ -522,7 +793,7 @@ module Langfuse
522
793
  def resolve_ingestion_mode(explicit_mode)
523
794
  return explicit_mode.to_sym if explicit_mode
524
795
 
525
- env_mode = ENV['LANGFUSE_INGESTION_MODE']
796
+ env_mode = ENV.fetch('LANGFUSE_INGESTION_MODE', nil)
526
797
  return env_mode.to_sym if env_mode && !env_mode.empty?
527
798
 
528
799
  Langfuse.configuration.ingestion_mode || :legacy
@@ -614,7 +885,7 @@ module Langfuse
614
885
  end
615
886
 
616
887
  def handle_response(response)
617
- puts "Handling response with status: #{response.status}" if @debug
888
+ @logger.debug("Handling response with status: #{response.status}")
618
889
 
619
890
  case response.status
620
891
  when 200..299