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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +21 -10
- data/.github/workflows/release.yml +4 -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 +8 -4
- data/README.md +171 -9
- data/langfuse-ruby.gemspec +2 -0
- data/lib/langfuse/client.rb +384 -62
- data/lib/langfuse/generation.rb +36 -4
- data/lib/langfuse/otel_exporter.rb +325 -0
- 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 +17 -6
- metadata +32 -2
data/lib/langfuse/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
@
|
|
18
|
-
@
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 ||
|
|
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
|
-
|
|
338
|
+
@logger.debug("Making request to: #{@host}#{path} with params: #{params}")
|
|
291
339
|
|
|
292
340
|
response = get(path, params)
|
|
293
341
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
454
|
+
@logger.debug("Converted trace-update to trace-create for trace_id: #{trace_id}")
|
|
386
455
|
end
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
+
@logger.debug('=== Event Data Debug Information ===')
|
|
416
611
|
events.each_with_index do |event, index|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
619
|
+
@logger.debug(' ⚠️ WARNING: Empty or nil type!') if event[:type].nil? || event[:type].to_s.empty?
|
|
425
620
|
|
|
426
|
-
|
|
621
|
+
@logger.debug(' ⚠️ WARNING: Empty body!') if event[:body].nil?
|
|
427
622
|
|
|
428
|
-
|
|
623
|
+
@logger.debug(' ---')
|
|
429
624
|
end
|
|
430
|
-
|
|
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
|
-
|
|
635
|
+
@logger.debug("Warning: Event with empty type detected, skipping: #{event[:id]}")
|
|
441
636
|
false
|
|
442
637
|
elsif event[:body].nil?
|
|
443
|
-
|
|
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
|
-
|
|
646
|
+
@logger.debug('No valid events to send')
|
|
452
647
|
return
|
|
453
648
|
end
|
|
454
649
|
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
888
|
+
@logger.debug("Handling response with status: #{response.status}")
|
|
567
889
|
|
|
568
890
|
case response.status
|
|
569
891
|
when 200..299
|