langfuse-ruby 0.1.7 → 0.2.1

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