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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +21 -10
- data/.github/workflows/release.yml +2 -2
- data/.mise.toml +8 -0
- data/.rubocop.yml +2 -2
- data/CHANGELOG.md +34 -10
- data/CLAUDE.md +21 -9
- data/Gemfile +1 -0
- data/Gemfile.lock +15 -5
- data/Makefile +5 -1
- data/README.md +141 -9
- data/langfuse-ruby.gemspec +2 -0
- data/lib/langfuse/client.rb +345 -74
- data/lib/langfuse/generation.rb +36 -4
- data/lib/langfuse/otel_exporter.rb +49 -57
- data/lib/langfuse/span.rb +1 -0
- data/lib/langfuse/trace.rb +16 -6
- data/lib/langfuse/utils.rb +32 -0
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +14 -6
- metadata +31 -2
data/lib/langfuse/client.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
@
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
|
|
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 ||
|
|
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
|
-
|
|
338
|
+
@logger.debug("Making request to: #{@host}#{path} with params: #{params}")
|
|
295
339
|
|
|
296
340
|
response = get(path, params)
|
|
297
341
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
454
|
+
@logger.debug("Converted trace-update to trace-create for trace_id: #{trace_id}")
|
|
390
455
|
end
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
+
@logger.debug('=== Event Data Debug Information ===')
|
|
420
611
|
events.each_with_index do |event, index|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
619
|
+
@logger.debug(' ⚠️ WARNING: Empty or nil type!') if event[:type].nil? || event[:type].to_s.empty?
|
|
429
620
|
|
|
430
|
-
|
|
621
|
+
@logger.debug(' ⚠️ WARNING: Empty body!') if event[:body].nil?
|
|
431
622
|
|
|
432
|
-
|
|
623
|
+
@logger.debug(' ---')
|
|
433
624
|
end
|
|
434
|
-
|
|
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
|
-
|
|
635
|
+
@logger.debug("Warning: Event with empty type detected, skipping: #{event[:id]}")
|
|
445
636
|
false
|
|
446
637
|
elsif event[:body].nil?
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
response
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
888
|
+
@logger.debug("Handling response with status: #{response.status}")
|
|
618
889
|
|
|
619
890
|
case response.status
|
|
620
891
|
when 200..299
|