langfuse-rb 0.10.0 → 0.11.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/lib/langfuse/api_client.rb +175 -509
  4. data/lib/langfuse/app_root_tracking.rb +164 -0
  5. data/lib/langfuse/cache_warmer.rb +21 -13
  6. data/lib/langfuse/chat_prompt_client.rb +21 -4
  7. data/lib/langfuse/client.rb +174 -229
  8. data/lib/langfuse/config.rb +308 -65
  9. data/lib/langfuse/evaluation.rb +8 -4
  10. data/lib/langfuse/exit_hook.rb +77 -0
  11. data/lib/langfuse/fork_safety.rb +71 -0
  12. data/lib/langfuse/masking_exporter.rb +98 -0
  13. data/lib/langfuse/observations.rb +2 -1
  14. data/lib/langfuse/otel_attributes.rb +1 -0
  15. data/lib/langfuse/otel_setup.rb +32 -44
  16. data/lib/langfuse/otel_span_batch.rb +113 -0
  17. data/lib/langfuse/otel_span_masking.rb +89 -0
  18. data/lib/langfuse/otel_span_patch_applier.rb +97 -0
  19. data/lib/langfuse/pending_score_queue.rb +62 -0
  20. data/lib/langfuse/prompt_cache.rb +11 -0
  21. data/lib/langfuse/prompt_cache_coordinator.rb +288 -0
  22. data/lib/langfuse/prompt_cache_events.rb +31 -10
  23. data/lib/langfuse/prompt_variables.rb +54 -0
  24. data/lib/langfuse/propagation.rb +101 -34
  25. data/lib/langfuse/rails_cache_adapter.rb +27 -2
  26. data/lib/langfuse/read_api.rb +242 -0
  27. data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
  28. data/lib/langfuse/score_client.rb +209 -89
  29. data/lib/langfuse/score_value.rb +58 -0
  30. data/lib/langfuse/span_processor.rb +53 -4
  31. data/lib/langfuse/stale_while_revalidate.rb +3 -4
  32. data/lib/langfuse/text_prompt_client.rb +15 -6
  33. data/lib/langfuse/trace_export_guard.rb +47 -0
  34. data/lib/langfuse/traced_execution.rb +18 -12
  35. data/lib/langfuse/types.rb +15 -1
  36. data/lib/langfuse/version.rb +1 -1
  37. data/lib/langfuse.rb +157 -44
  38. metadata +16 -2
@@ -6,6 +6,8 @@ require "base64"
6
6
  require "json"
7
7
  require "uri"
8
8
  require_relative "prompt_fetch_result"
9
+ require_relative "prompt_cache_coordinator"
10
+ require_relative "read_api"
9
11
 
10
12
  module Langfuse
11
13
  # HTTP client for Langfuse API
@@ -24,14 +26,7 @@ module Langfuse
24
26
  #
25
27
  class ApiClient # rubocop:disable Metrics/ClassLength
26
28
  include PromptCacheEvents
27
-
28
- # Bundles the resolved cache key with the per-call TTL override so private
29
- # prompt-fetch helpers take one arg instead of four.
30
- PromptFetchOptions = Struct.new(:key, :cache_ttl, keyword_init: true) do
31
- def name = key.name
32
- def version = key.version
33
- def label = key.label
34
- end
29
+ include ReadApi
35
30
 
36
31
  # @return [String] Langfuse public API key
37
32
  attr_reader :public_key
@@ -69,8 +64,12 @@ module Langfuse
69
64
  @timeout = timeout
70
65
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
71
66
  @cache = cache
72
- @cache_backend_name = compute_cache_backend_name
73
67
  setup_prompt_cache_events(cache_observer: cache_observer)
68
+ @prompt_cache_coordinator = PromptCacheCoordinator.new(
69
+ cache: cache,
70
+ event_emitter: self,
71
+ fetch_prompt: ->(name, version:, label:) { fetch_prompt_from_api(name, version: version, label: label) }
72
+ )
74
73
  end
75
74
  # rubocop:enable Metrics/ParameterLists
76
75
 
@@ -105,15 +104,7 @@ module Langfuse
105
104
  # puts "#{prompt['name']} (v#{prompt['version']})"
106
105
  # end
107
106
  def list_prompts(page: nil, limit: nil)
108
- with_faraday_error_handling do
109
- params = { page: page, limit: limit }.compact
110
-
111
- response = connection.get("/api/public/v2/prompts", params)
112
- result = handle_response(response)
113
-
114
- # API returns { data: [...], meta: {...} }
115
- result["data"] || []
116
- end
107
+ request(:get, "/api/public/v2/prompts", params: { page: page, limit: limit }.compact)["data"] || []
117
108
  end
118
109
 
119
110
  # Fetch a prompt from the Langfuse API
@@ -148,16 +139,7 @@ module Langfuse
148
139
  # @raise [UnauthorizedError] if authentication fails
149
140
  # @raise [ApiError] for other API errors
150
141
  def get_prompt_result(name, version: nil, label: nil, cache_ttl: nil)
151
- validate_prompt_fetch_options!(version, label, cache_ttl)
152
-
153
- options = PromptFetchOptions.new(
154
- key: prompt_cache_key(name, version: version, label: label),
155
- cache_ttl: cache_ttl
156
- )
157
- return fetch_uncached_prompt_result(options, CacheStatus::DISABLED) if cache.nil?
158
- return fetch_uncached_prompt_result(options, CacheStatus::BYPASS) if cache_ttl&.zero?
159
-
160
- fetch_cached_prompt_result(options)
142
+ @prompt_cache_coordinator.get_prompt_result(name, version: version, label: label, cache_ttl: cache_ttl)
161
143
  end
162
144
 
163
145
  # Refresh a prompt from the API, optionally writing through to cache.
@@ -173,14 +155,7 @@ module Langfuse
173
155
  # @raise [UnauthorizedError] if authentication fails
174
156
  # @raise [ApiError] for other API errors
175
157
  def refresh_prompt(name, version: nil, label: nil, cache_ttl: nil)
176
- validate_prompt_fetch_options!(version, label, cache_ttl)
177
-
178
- refresh_prompt_result(
179
- PromptFetchOptions.new(
180
- key: prompt_cache_key(name, version: version, label: label),
181
- cache_ttl: cache_ttl
182
- )
183
- )
158
+ @prompt_cache_coordinator.refresh_prompt(name, version: version, label: label, cache_ttl: cache_ttl)
184
159
  end
185
160
 
186
161
  # Inspect the logical and generated cache keys for a prompt.
@@ -191,15 +166,7 @@ module Langfuse
191
166
  # @return [PromptCacheKey] Logical and generated cache keys
192
167
  # @raise [ArgumentError] if both version and label are provided
193
168
  def prompt_cache_key(name, version: nil, label: nil)
194
- raise ArgumentError, "Cannot specify both version and label" if version && label
195
-
196
- logical_key = PromptCache.build_key(name, version: version, label: label)
197
- storage_key = if generated_storage_key_cache?
198
- cache.storage_key(logical_key, name: name)
199
- else
200
- logical_key
201
- end
202
- PromptCacheKey.new(name: name, version: version, label: label, logical_key: logical_key, storage_key: storage_key)
169
+ @prompt_cache_coordinator.prompt_cache_key(name, version: version, label: label)
203
170
  end
204
171
 
205
172
  # Invalidate one exact logical prompt cache key.
@@ -210,13 +177,7 @@ module Langfuse
210
177
  # @return [PromptCacheKey] The invalidated key
211
178
  # @raise [ArgumentError] if both version and label are provided
212
179
  def invalidate_prompt_cache(name, version: nil, label: nil)
213
- key = prompt_cache_key(name, version: version, label: label)
214
- deleted = cache&.delete(key.storage_key) || false
215
- emit_prompt_cache_event(:delete) { event_payload(key, CacheStatus::MISS, CacheSource::CACHE, deleted: deleted) }
216
- emit_prompt_cache_event(:invalidate) do
217
- event_payload(key, CacheStatus::MISS, CacheSource::CACHE, scope: :exact)
218
- end
219
- key
180
+ @prompt_cache_coordinator.invalidate_prompt_cache(name, version: version, label: label)
220
181
  end
221
182
 
222
183
  # Invalidate all cached variants for one prompt name.
@@ -224,29 +185,33 @@ module Langfuse
224
185
  # @param name [String] The prompt name
225
186
  # @return [Integer, nil] New generation, or nil when cache is disabled
226
187
  def invalidate_prompt_cache_by_name(name)
227
- generation = cache&.invalidate_name(name)
228
- payload = { name: name, backend: cache_backend_name, generation: generation, scope: :name }
229
- emit_prompt_cache_event(:invalidate, payload)
230
- generation
188
+ @prompt_cache_coordinator.invalidate_prompt_cache_by_name(name)
231
189
  end
232
190
 
233
191
  # Logically clear the whole Langfuse prompt cache namespace.
234
192
  #
235
193
  # @return [Integer, nil] New global generation, or nil when cache is disabled
236
194
  def clear_prompt_cache
237
- generation = cache&.clear_logically
238
- emit_prompt_cache_event(:clear, backend: cache_backend_name, generation: generation)
239
- generation
195
+ @prompt_cache_coordinator.clear_prompt_cache
240
196
  end
241
197
 
242
198
  # Return prompt cache statistics.
243
199
  #
244
200
  # @return [Hash] Cache statistics
245
201
  def prompt_cache_stats
246
- return disabled_prompt_cache_stats unless cache
202
+ @prompt_cache_coordinator.prompt_cache_stats
203
+ end
247
204
 
248
- cache.stats
205
+ # Validate the configured prompt cache backend.
206
+ #
207
+ # @return [Boolean] true when the configured backend is usable
208
+ # @raise [ConfigurationError] if the backend is invalid
209
+ # rubocop:disable Naming/PredicateMethod
210
+ def validate_prompt_cache_backend!
211
+ @cache&.validate!
212
+ true
249
213
  end
214
+ # rubocop:enable Naming/PredicateMethod
250
215
 
251
216
  # Create a new prompt (or new version if prompt with same name exists)
252
217
  #
@@ -271,21 +236,12 @@ module Langfuse
271
236
  #
272
237
  # rubocop:disable Metrics/ParameterLists
273
238
  def create_prompt(name:, prompt:, type:, config: {}, labels: [], tags: [], commit_message: nil)
274
- with_faraday_error_handling do
275
- path = "/api/public/v2/prompts"
276
- payload = {
277
- name: name,
278
- prompt: prompt,
279
- type: type,
280
- config: config,
281
- labels: labels,
282
- tags: tags
283
- }
284
- payload[:commitMessage] = commit_message if commit_message
285
-
286
- response = connection.post(path, payload)
287
- handle_response(response).tap { invalidate_prompt_cache_after_mutation(name) }
288
- end
239
+ payload = {
240
+ name: name, prompt: prompt, type: type, config: config,
241
+ labels: labels, tags: tags, commitMessage: commit_message
242
+ }.compact
243
+ request(:post, "/api/public/v2/prompts", body: payload)
244
+ .tap { @prompt_cache_coordinator.invalidate_after_mutation(name) }
289
245
  end
290
246
  # rubocop:enable Metrics/ParameterLists
291
247
 
@@ -309,13 +265,9 @@ module Langfuse
309
265
  def update_prompt(name:, version:, labels:)
310
266
  raise ArgumentError, "labels must be an array" unless labels.is_a?(Array)
311
267
 
312
- with_faraday_error_handling do
313
- path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}/versions/#{version}"
314
- payload = { newLabels: labels }
315
-
316
- response = connection.patch(path, payload)
317
- handle_response(response).tap { invalidate_prompt_cache_after_mutation(name) }
318
- end
268
+ path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}/versions/#{version}"
269
+ request(:patch, path, body: { newLabels: labels })
270
+ .tap { @prompt_cache_coordinator.invalidate_after_mutation(name) }
319
271
  end
320
272
 
321
273
  # Send a batch of events to the Langfuse ingestion API
@@ -344,18 +296,28 @@ module Langfuse
344
296
  raise ArgumentError, "events must be an array" unless events.is_a?(Array)
345
297
  raise ArgumentError, "events array cannot be empty" if events.empty?
346
298
 
347
- path = "/api/public/ingestion"
348
- payload = { batch: events }
349
-
350
- response = connection.post(path, payload)
299
+ response = connection.post("/api/public/ingestion", { batch: events })
351
300
  handle_batch_response(response)
352
301
  rescue Faraday::RetriableResponse => e
353
- # Retry middleware exhausted all retries - handle the final response
354
302
  logger.error("Langfuse batch send failed: Retries exhausted - #{e.response.status}")
355
303
  handle_batch_response(e.response)
356
304
  rescue Faraday::Error => e
357
305
  logger.error("Langfuse batch send failed: #{e.message}")
358
- raise ApiError, "Batch send failed: #{e.message}"
306
+ raise BatchDeliveryError.new("Batch send failed: #{e.message}", retryable: true)
307
+ end
308
+
309
+ # Create a score through the synchronous Scores API.
310
+ #
311
+ # @param payload [Hash] Validated score attributes in API format
312
+ # @return [String] ID of the created score
313
+ # @raise [UnauthorizedError] if authentication fails
314
+ # @raise [ApiError] if the API request fails or omits the created score ID
315
+ def create_score(payload:)
316
+ response = request(:post, "/api/public/scores", body: payload)
317
+ score_id = response["id"]
318
+ return score_id if score_id.is_a?(String) && !score_id.empty?
319
+
320
+ raise ApiError, "Score creation response did not include an id"
359
321
  end
360
322
 
361
323
  # Create a dataset run item (link a trace to a dataset item within a run)
@@ -374,16 +336,12 @@ module Langfuse
374
336
  # api_client.create_dataset_run_item(dataset_item_id: "item-123", run_name: "eval-v1", trace_id: "trace-abc")
375
337
  def create_dataset_run_item(dataset_item_id:, run_name:, trace_id: nil,
376
338
  observation_id: nil, metadata: nil, run_description: nil)
377
- with_faraday_error_handling do
378
- payload = { datasetItemId: dataset_item_id, runName: run_name }
379
- payload[:traceId] = trace_id if trace_id
380
- payload[:observationId] = observation_id if observation_id
381
- payload[:metadata] = metadata if metadata
382
- payload[:runDescription] = run_description if run_description
383
-
384
- response = connection.post("/api/public/dataset-run-items", payload)
385
- handle_response(response)
386
- end
339
+ payload = {
340
+ datasetItemId: dataset_item_id, runName: run_name,
341
+ traceId: trace_id, observationId: observation_id,
342
+ metadata: metadata, runDescription: run_description
343
+ }.compact
344
+ request(:post, "/api/public/dataset-run-items", body: payload)
387
345
  end
388
346
 
389
347
  # Fetch a dataset run by dataset and run name
@@ -395,10 +353,7 @@ module Langfuse
395
353
  # @raise [UnauthorizedError] if authentication fails
396
354
  # @raise [ApiError] for other API errors
397
355
  def get_dataset_run(dataset_name:, run_name:)
398
- with_faraday_error_handling do
399
- response = connection.get(dataset_run_path(dataset_name: dataset_name, run_name: run_name))
400
- handle_response(response)
401
- end
356
+ request(:get, dataset_run_path(dataset_name: dataset_name, run_name: run_name))
402
357
  end
403
358
 
404
359
  # List dataset runs in a dataset
@@ -410,8 +365,7 @@ module Langfuse
410
365
  # @raise [UnauthorizedError] if authentication fails
411
366
  # @raise [ApiError] for other API errors
412
367
  def list_dataset_runs(dataset_name:, page: nil, limit: nil)
413
- result = list_dataset_runs_paginated(dataset_name: dataset_name, page: page, limit: limit)
414
- result["data"] || []
368
+ list_dataset_runs_paginated(dataset_name: dataset_name, page: page, limit: limit)["data"] || []
415
369
  end
416
370
 
417
371
  # Full paginated response including "meta" for internal pagination use
@@ -419,10 +373,7 @@ module Langfuse
419
373
  # @api private
420
374
  # @return [Hash] Full response hash with "data" array and "meta" pagination info
421
375
  def list_dataset_runs_paginated(dataset_name:, page: nil, limit: nil)
422
- with_faraday_error_handling do
423
- response = connection.get(dataset_runs_path(dataset_name), build_dataset_runs_params(page: page, limit: limit))
424
- handle_response(response)
425
- end
376
+ request(:get, dataset_runs_path(dataset_name), params: { page: page, limit: limit }.compact)
426
377
  end
427
378
 
428
379
  # Delete a dataset run by name
@@ -451,19 +402,16 @@ module Langfuse
451
402
  # data = api_client.get_projects
452
403
  # project_id = data["data"][0]["id"]
453
404
  def get_projects # rubocop:disable Naming/AccessorMethodName
454
- with_faraday_error_handling do
455
- response = connection.get("/api/public/projects")
456
- handle_response(response)
457
- end
405
+ request(:get, "/api/public/projects")
458
406
  end
459
407
 
460
408
  # Shut down the API client and release resources
461
409
  #
462
- # Shuts down the cache if it supports shutdown (e.g., SWR thread pool).
410
+ # Shuts down the cache backend's SWR thread pool when present.
463
411
  #
464
412
  # @return [void]
465
413
  def shutdown
466
- cache.shutdown if cache.respond_to?(:shutdown)
414
+ @cache&.shutdown
467
415
  end
468
416
 
469
417
  # List traces in the project
@@ -493,14 +441,13 @@ module Langfuse
493
441
  from_timestamp: nil, to_timestamp: nil, order_by: nil,
494
442
  tags: nil, version: nil, release: nil, environment: nil,
495
443
  fields: nil, filter: nil)
496
- result = list_traces_paginated(
444
+ list_traces_paginated(
497
445
  page: page, limit: limit, user_id: user_id, name: name,
498
446
  session_id: session_id, from_timestamp: from_timestamp,
499
447
  to_timestamp: to_timestamp, order_by: order_by, tags: tags,
500
448
  version: version, release: release, environment: environment,
501
449
  fields: fields, filter: filter
502
- )
503
- result["data"] || []
450
+ )["data"] || []
504
451
  end
505
452
  # rubocop:enable Metrics/ParameterLists
506
453
 
@@ -513,17 +460,14 @@ module Langfuse
513
460
  from_timestamp: nil, to_timestamp: nil, order_by: nil,
514
461
  tags: nil, version: nil, release: nil, environment: nil,
515
462
  fields: nil, filter: nil)
516
- with_faraday_error_handling do
517
- params = build_traces_params(
518
- page: page, limit: limit, user_id: user_id, name: name,
519
- session_id: session_id, from_timestamp: from_timestamp,
520
- to_timestamp: to_timestamp, order_by: order_by, tags: tags,
521
- version: version, release: release, environment: environment,
522
- fields: fields, filter: filter
523
- )
524
- response = connection.get("/api/public/traces", params)
525
- handle_response(response)
526
- end
463
+ params = build_traces_params(
464
+ page: page, limit: limit, user_id: user_id, name: name,
465
+ session_id: session_id, from_timestamp: from_timestamp,
466
+ to_timestamp: to_timestamp, order_by: order_by, tags: tags,
467
+ version: version, release: release, environment: environment,
468
+ fields: fields, filter: filter
469
+ )
470
+ request(:get, "/api/public/traces", params: params)
527
471
  end
528
472
  # rubocop:enable Metrics/ParameterLists
529
473
 
@@ -538,11 +482,7 @@ module Langfuse
538
482
  # @example
539
483
  # trace = api_client.get_trace("trace-uuid-123")
540
484
  def get_trace(id)
541
- with_faraday_error_handling do
542
- encoded_id = URI.encode_uri_component(id)
543
- response = connection.get("/api/public/traces/#{encoded_id}")
544
- handle_response(response)
545
- end
485
+ request(:get, "/api/public/traces/#{URI.encode_uri_component(id)}")
546
486
  end
547
487
 
548
488
  # List all datasets in the project
@@ -556,13 +496,7 @@ module Langfuse
556
496
  # @example
557
497
  # datasets = api_client.list_datasets(page: 1, limit: 10)
558
498
  def list_datasets(page: nil, limit: nil)
559
- with_faraday_error_handling do
560
- params = { page: page, limit: limit }.compact
561
-
562
- response = connection.get("/api/public/v2/datasets", params)
563
- result = handle_response(response)
564
- result["data"] || []
565
- end
499
+ request(:get, "/api/public/v2/datasets", params: { page: page, limit: limit }.compact)["data"] || []
566
500
  end
567
501
 
568
502
  # Fetch a dataset by name
@@ -576,11 +510,7 @@ module Langfuse
576
510
  # @example
577
511
  # data = api_client.get_dataset("my-dataset")
578
512
  def get_dataset(name)
579
- with_faraday_error_handling do
580
- encoded_name = URI.encode_uri_component(name)
581
- response = connection.get("/api/public/v2/datasets/#{encoded_name}")
582
- handle_response(response)
583
- end
513
+ request(:get, "/api/public/v2/datasets/#{URI.encode_uri_component(name)}")
584
514
  end
585
515
 
586
516
  # Create a new dataset
@@ -595,12 +525,8 @@ module Langfuse
595
525
  # @example
596
526
  # data = api_client.create_dataset(name: "my-dataset", description: "QA evaluation set")
597
527
  def create_dataset(name:, description: nil, metadata: nil)
598
- with_faraday_error_handling do
599
- payload = { name: name, description: description, metadata: metadata }.compact
600
-
601
- response = connection.post("/api/public/v2/datasets", payload)
602
- handle_response(response)
603
- end
528
+ request(:post, "/api/public/v2/datasets",
529
+ body: { name: name, description: description, metadata: metadata }.compact)
604
530
  end
605
531
 
606
532
  # Create a new dataset item (or upsert if id is provided)
@@ -627,16 +553,13 @@ module Langfuse
627
553
  def create_dataset_item(dataset_name:, input: nil, expected_output: nil,
628
554
  metadata: nil, id: nil, source_trace_id: nil,
629
555
  source_observation_id: nil, status: nil)
630
- with_faraday_error_handling do
631
- payload = build_dataset_item_payload(
632
- dataset_name: dataset_name, input: input, expected_output: expected_output,
633
- metadata: metadata, id: id, source_trace_id: source_trace_id,
634
- source_observation_id: source_observation_id, status: status
635
- )
636
-
637
- response = connection.post("/api/public/dataset-items", payload)
638
- handle_response(response)
639
- end
556
+ payload = {
557
+ datasetName: dataset_name, id: id, input: input,
558
+ expectedOutput: expected_output, metadata: metadata,
559
+ sourceTraceId: source_trace_id, sourceObservationId: source_observation_id,
560
+ status: status&.to_s&.upcase
561
+ }.compact
562
+ request(:post, "/api/public/dataset-items", body: payload)
640
563
  end
641
564
  # rubocop:enable Metrics/ParameterLists
642
565
 
@@ -651,11 +574,7 @@ module Langfuse
651
574
  # @example
652
575
  # data = api_client.get_dataset_item("item-uuid-123")
653
576
  def get_dataset_item(id)
654
- with_faraday_error_handling do
655
- encoded_id = URI.encode_uri_component(id)
656
- response = connection.get("/api/public/dataset-items/#{encoded_id}")
657
- handle_response(response)
658
- end
577
+ request(:get, "/api/public/dataset-items/#{URI.encode_uri_component(id)}")
659
578
  end
660
579
 
661
580
  # List items in a dataset with optional filters
@@ -671,13 +590,8 @@ module Langfuse
671
590
  #
672
591
  # @example
673
592
  # items = api_client.list_dataset_items(dataset_name: "my-dataset", limit: 50)
674
- def list_dataset_items(dataset_name:, page: nil, limit: nil,
675
- source_trace_id: nil, source_observation_id: nil)
676
- result = list_dataset_items_paginated(
677
- dataset_name: dataset_name, page: page, limit: limit,
678
- source_trace_id: source_trace_id, source_observation_id: source_observation_id
679
- )
680
- result["data"] || []
593
+ def list_dataset_items(**)
594
+ list_dataset_items_paginated(**)["data"] || []
681
595
  end
682
596
 
683
597
  # Full paginated response including "meta" for internal pagination use
@@ -686,15 +600,11 @@ module Langfuse
686
600
  # @return [Hash] Full response hash with "data" array and "meta" pagination info
687
601
  def list_dataset_items_paginated(dataset_name:, page: nil, limit: nil,
688
602
  source_trace_id: nil, source_observation_id: nil)
689
- with_faraday_error_handling do
690
- params = build_dataset_items_params(
691
- dataset_name: dataset_name, page: page, limit: limit,
692
- source_trace_id: source_trace_id, source_observation_id: source_observation_id
693
- )
694
-
695
- response = connection.get("/api/public/dataset-items", params)
696
- handle_response(response)
697
- end
603
+ params = {
604
+ datasetName: dataset_name, page: page, limit: limit,
605
+ sourceTraceId: source_trace_id, sourceObservationId: source_observation_id
606
+ }.compact
607
+ request(:get, "/api/public/dataset-items", params: params)
698
608
  end
699
609
 
700
610
  # Delete a dataset item by ID
@@ -708,8 +618,7 @@ module Langfuse
708
618
  # @example
709
619
  # api_client.delete_dataset_item("item-uuid-123")
710
620
  def delete_dataset_item(id)
711
- encoded_id = URI.encode_uri_component(id)
712
- response = connection.delete("/api/public/dataset-items/#{encoded_id}")
621
+ response = connection.delete("/api/public/dataset-items/#{URI.encode_uri_component(id)}")
713
622
  handle_delete_dataset_item_response(response, id)
714
623
  rescue Faraday::RetriableResponse => e
715
624
  logger.error("Faraday error: Retries exhausted - #{e.response.status}")
@@ -721,314 +630,46 @@ module Langfuse
721
630
 
722
631
  private
723
632
 
724
- def validate_prompt_fetch_options!(version, label, cache_ttl)
725
- raise ArgumentError, "Cannot specify both version and label" if version && label
726
- return if cache_ttl.nil?
727
- raise ArgumentError, "cache_ttl must be a non-negative Integer" unless cache_ttl.is_a?(Integer)
728
- raise ArgumentError, "cache_ttl must be non-negative" if cache_ttl.negative?
729
- end
730
-
731
- def fetch_uncached_prompt_result(options, cache_status)
732
- prompt_data = fetch_prompt_for_options(options)
733
- build_prompt_result(options.key, prompt_data, cache_status, CacheSource::API)
734
- end
735
-
736
- def fetch_cached_prompt_result(options)
737
- return fetch_swr_prompt_result(options) if swr_cache_available?
738
-
739
- fetch_non_swr_prompt_result(options)
740
- end
741
-
742
- def fetch_swr_prompt_result(options)
743
- unless generated_storage_key_cache?
744
- prompt_data = fetch_with_swr_cache(options.key.storage_key, options.name, options.version, options.label)
745
- return cache_hit_prompt_result(options.key, prompt_data)
746
- end
747
-
748
- result = fetch_swr_cached_prompt_result(options)
749
- return result if result
750
-
751
- fetch_cache_miss_prompt_result(options, swr_enabled: true, distributed_enabled: false)
752
- end
753
-
754
- def fetch_non_swr_prompt_result(options)
755
- distributed_enabled = distributed_cache_available?
756
-
757
- if !generated_storage_key_cache? && distributed_enabled
758
- prompt_data = fetch_with_distributed_cache(options.key.storage_key, options.name, options.version,
759
- options.label)
760
- return cache_hit_prompt_result(options.key, prompt_data)
761
- end
762
-
763
- cached_data = cache.get(options.key.storage_key)
764
- return cache_hit_prompt_result(options.key, cached_data) if cached_data
765
-
766
- fetch_cache_miss_prompt_result(options, swr_enabled: false, distributed_enabled: distributed_enabled)
767
- end
768
-
769
- def fetch_swr_cached_prompt_result(options)
770
- key = options.key
771
- entry = cache.entry(key.storage_key) if cache.respond_to?(:entry)
772
- return nil unless entry.respond_to?(:fresh?)
773
- return cache_hit_prompt_result(key, entry.data) if entry.fresh?
774
- return nil unless entry.stale?
775
-
776
- emit_prompt_cache_event(:stale_serve) { event_payload(key, CacheStatus::STALE, CacheSource::CACHE) }
777
- schedule_prompt_cache_refresh(options)
778
- build_prompt_result(key, entry.data, CacheStatus::STALE, CacheSource::CACHE)
779
- end
780
-
781
- def cache_hit_prompt_result(key, prompt_data)
782
- emit_prompt_cache_event(:hit) { event_payload(key, CacheStatus::HIT, CacheSource::CACHE) }
783
- build_prompt_result(key, prompt_data, CacheStatus::HIT, CacheSource::CACHE)
784
- end
785
-
786
- def fetch_cache_miss_prompt_result(options, swr_enabled: false, distributed_enabled: nil)
787
- emit_prompt_cache_event(:miss) { event_payload(options.key, CacheStatus::MISS, CacheSource::API) }
788
- distributed_enabled = distributed_cache_available? if distributed_enabled.nil?
789
-
790
- if !swr_enabled && distributed_enabled
791
- fetch_cache_miss_with_lock(options)
792
- else
793
- fetch_cache_miss_directly(options, swr_enabled: swr_enabled)
794
- end
795
- end
796
-
797
- def fetch_cache_miss_with_lock(options)
798
- key = options.key
799
- fetched = false
800
- prompt_data = cache_fetch_with_lock(key.storage_key, options.cache_ttl) do
801
- fetched = true
802
- fetch_prompt_for_options(options)
803
- end
804
- emit_prompt_cache_event(:write) { event_payload(key, CacheStatus::MISS, CacheSource::API) } if fetched
805
- status = fetched ? CacheStatus::MISS : CacheStatus::HIT
806
- source = fetched ? CacheSource::API : CacheSource::CACHE
807
- build_prompt_result(key, prompt_data, status, source)
808
- end
809
-
810
- def fetch_cache_miss_directly(options, swr_enabled: false)
811
- prompt_data = fetch_prompt_for_options(options)
812
- write_prompt_cache(options.key, prompt_data, options.cache_ttl, swr_enabled: swr_enabled)
813
- build_prompt_result(options.key, prompt_data, CacheStatus::MISS, CacheSource::API)
814
- end
815
-
816
- def refresh_prompt_result(options)
817
- key = options.key
818
- emit_prompt_cache_event(:refresh_start) { event_payload(key, CacheStatus::REFRESH, CacheSource::API) }
819
- prompt_data = fetch_prompt_for_options(options)
820
- write_refresh_prompt_cache(key, prompt_data, options.cache_ttl)
821
- status = refresh_cache_status(options.cache_ttl)
822
- emit_prompt_cache_event(:refresh_success) { event_payload(key, status, CacheSource::API) }
823
- build_prompt_result(key, prompt_data, status, CacheSource::API)
824
- rescue StandardError => e
825
- emit_prompt_cache_event(:refresh_failure) do
826
- event_payload(key, CacheStatus::REFRESH, CacheSource::API,
827
- error_class: e.class.name, error_message: e.message)
828
- end
829
- raise
830
- end
831
-
832
- def schedule_prompt_cache_refresh(options)
833
- return unless cache.respond_to?(:refresh_async)
834
-
835
- key = options.key
836
- scheduled = cache.refresh_async(
837
- key.storage_key,
838
- ttl: options.cache_ttl,
839
- on_success: ->(_value) { emit_refresh_success_events(key) },
840
- on_failure: ->(error) { emit_refresh_failure_event(key, error) }
841
- ) { fetch_prompt_for_options(options) }
842
- return unless scheduled
843
-
844
- emit_prompt_cache_event(:refresh_start) { event_payload(key, CacheStatus::STALE, CacheSource::CACHE) }
845
- end
846
-
847
- def fetch_prompt_for_options(options)
848
- fetch_prompt_from_api(options.name, version: options.version, label: options.label)
849
- end
850
-
851
- def emit_refresh_success_events(key)
852
- emit_prompt_cache_event(:refresh_success) { event_payload(key, CacheStatus::REFRESH, CacheSource::API) }
853
- emit_prompt_cache_event(:write) { event_payload(key, CacheStatus::REFRESH, CacheSource::API) }
854
- end
855
-
856
- def emit_refresh_failure_event(key, error)
857
- emit_prompt_cache_event(:refresh_failure) do
858
- event_payload(key, CacheStatus::STALE, CacheSource::CACHE,
859
- error_class: error.class.name, error_message: error.message)
860
- end
861
- end
862
-
863
- def write_refresh_prompt_cache(key, prompt_data, cache_ttl)
864
- return unless cache
865
- return if cache_ttl&.zero?
866
-
867
- write_prompt_cache(key, prompt_data, cache_ttl,
868
- cache_status: CacheStatus::REFRESH, swr_enabled: swr_cache_available?)
869
- end
870
-
871
- def write_prompt_cache(key, prompt_data, cache_ttl, cache_status: CacheStatus::MISS, swr_enabled: false)
872
- if swr_enabled && cache.respond_to?(:write_with_stale_while_revalidate)
873
- cache.write_with_stale_while_revalidate(key.storage_key, prompt_data, ttl: cache_ttl)
874
- elsif cache_ttl.nil?
875
- cache.set(key.storage_key, prompt_data)
876
- else
877
- cache.set(key.storage_key, prompt_data, ttl: cache_ttl)
878
- end
879
- emit_prompt_cache_event(:write) { event_payload(key, cache_status, CacheSource::API) }
880
- end
881
-
882
- def cache_fetch_with_lock(storage_key, cache_ttl, &)
883
- return cache.fetch_with_lock(storage_key, &) if cache_ttl.nil?
884
-
885
- cache.fetch_with_lock(storage_key, ttl: cache_ttl, &)
886
- end
887
-
888
- def refresh_cache_status(cache_ttl)
889
- return CacheStatus::DISABLED unless cache
890
- return CacheStatus::BYPASS if cache_ttl&.zero?
891
-
892
- CacheStatus::REFRESH
893
- end
894
-
895
- def build_prompt_result(key, prompt_data, cache_status, source)
896
- PromptFetchResult.new(
897
- prompt: prompt_data,
898
- logical_key: key.logical_key,
899
- storage_key: key.storage_key,
900
- cache_status: cache_status,
901
- source: source,
902
- name: prompt_data["name"] || key.name,
903
- version: prompt_data["version"] || key.version,
904
- label: key.resolved_label
905
- )
906
- end
907
-
908
- attr_reader :cache_backend_name
909
-
910
- def compute_cache_backend_name
911
- return CacheBackend::DISABLED unless cache
912
- return CacheBackend::RAILS if cache.is_a?(RailsCacheAdapter)
913
- return CacheBackend::MEMORY if cache.is_a?(PromptCache)
914
-
915
- cache.class.name
916
- end
917
-
918
- def disabled_prompt_cache_stats
919
- {
920
- backend: CacheBackend::DISABLED,
921
- enabled: false,
922
- current_generation_entries: nil,
923
- orphaned_entries: nil,
924
- total_entries: nil,
925
- unsupported_counts: CacheBackend::UNSUPPORTED_COUNT_KEYS
926
- }
927
- end
928
-
929
- def generated_storage_key_cache?
930
- cache.is_a?(PromptCache) || cache.is_a?(RailsCacheAdapter)
931
- end
932
-
933
- def invalidate_prompt_cache_after_mutation(name)
934
- generation = cache&.invalidate_name(name)
935
- payload = { name: name, backend: cache_backend_name, generation: generation, scope: :name, mutation: true }
936
- emit_prompt_cache_event(:invalidate, payload)
937
- end
938
-
939
- # Check if SWR cache is available
940
- def swr_cache_available?
941
- cache.respond_to?(:swr_enabled?) && cache.swr_enabled?
942
- end
943
-
944
- # Check if distributed cache is available
945
- def distributed_cache_available?
946
- cache.respond_to?(:fetch_with_lock)
633
+ def cache_backend_name
634
+ @prompt_cache_coordinator.backend_name
947
635
  end
948
636
 
949
- # Build payload for create_dataset_item
950
- # rubocop:disable Metrics/ParameterLists
951
- def build_dataset_item_payload(dataset_name:, input:, expected_output:,
952
- metadata:, id:, source_trace_id:,
953
- source_observation_id:, status:)
954
- { datasetName: dataset_name }.tap do |payload|
955
- add_optional_dataset_item_fields(payload, input, expected_output, metadata, id)
956
- add_optional_source_fields(payload, source_trace_id, source_observation_id, status)
637
+ # Issue an HTTP request, raise on Faraday errors, parse the response.
638
+ #
639
+ # @api private
640
+ # @param verb [Symbol] HTTP verb (:get, :post, :patch, :delete)
641
+ # @param path [String] Request path
642
+ # @param params [Hash, nil] Query string params (GET/DELETE)
643
+ # @param body [Hash, nil] JSON body (POST/PATCH)
644
+ # @param params_encoder [Object, nil] Faraday query encoder for this request
645
+ # @return [Hash] Parsed response body
646
+ def request(verb, path, params: nil, body: nil, params_encoder: nil)
647
+ with_faraday_error_handling do
648
+ response = connection.public_send(verb, path, body || params) do |faraday_request|
649
+ faraday_request.options.params_encoder = params_encoder if params_encoder
650
+ end
651
+ handle_response(response)
957
652
  end
958
653
  end
959
- # rubocop:enable Metrics/ParameterLists
960
-
961
- def add_optional_dataset_item_fields(payload, input, expected_output, metadata, id)
962
- payload[:id] = id if id
963
- payload[:input] = input if input
964
- payload[:expectedOutput] = expected_output if expected_output
965
- payload[:metadata] = metadata if metadata
966
- end
967
-
968
- def add_optional_source_fields(payload, source_trace_id, source_observation_id, status)
969
- payload[:sourceTraceId] = source_trace_id if source_trace_id
970
- payload[:sourceObservationId] = source_observation_id if source_observation_id
971
- payload[:status] = status.to_s.upcase if status
972
- end
973
654
 
974
- # Build params for list_dataset_items
975
- def build_dataset_items_params(dataset_name:, page:, limit:,
976
- source_trace_id:, source_observation_id:)
655
+ def build_traces_params(**options)
977
656
  {
978
- datasetName: dataset_name,
979
- page: page,
980
- limit: limit,
981
- sourceTraceId: source_trace_id,
982
- sourceObservationId: source_observation_id
657
+ page: options[:page], limit: options[:limit], userId: options[:user_id], name: options[:name],
658
+ sessionId: options[:session_id],
659
+ fromTimestamp: options[:from_timestamp]&.iso8601,
660
+ toTimestamp: options[:to_timestamp]&.iso8601,
661
+ orderBy: options[:order_by], tags: options[:tags], version: options[:version],
662
+ release: options[:release], environment: options[:environment], fields: options[:fields],
663
+ filter: options[:filter]
983
664
  }.compact
984
665
  end
985
666
 
986
- # Build params for list_dataset_runs
987
- def build_dataset_runs_params(page:, limit:)
988
- { page: page, limit: limit }.compact
989
- end
990
-
991
- # Build endpoint path for dataset runs
992
667
  def dataset_runs_path(dataset_name)
993
- encoded_name = URI.encode_uri_component(dataset_name)
994
- "/api/public/datasets/#{encoded_name}/runs"
668
+ "/api/public/datasets/#{URI.encode_uri_component(dataset_name)}/runs"
995
669
  end
996
670
 
997
- # Build endpoint path for a specific dataset run
998
671
  def dataset_run_path(dataset_name:, run_name:)
999
- encoded_run_name = URI.encode_uri_component(run_name)
1000
- "#{dataset_runs_path(dataset_name)}/#{encoded_run_name}"
1001
- end
1002
-
1003
- # Build query params for list_traces, mapping snake_case to camelCase
1004
- # rubocop:disable Metrics/ParameterLists
1005
- def build_traces_params(page:, limit:, user_id:, name:, session_id:,
1006
- from_timestamp:, to_timestamp:, order_by:,
1007
- tags:, version:, release:, environment:, fields:, filter:)
1008
- {
1009
- page: page, limit: limit, userId: user_id, name: name,
1010
- sessionId: session_id,
1011
- fromTimestamp: from_timestamp&.iso8601,
1012
- toTimestamp: to_timestamp&.iso8601,
1013
- orderBy: order_by, tags: tags, version: version,
1014
- release: release, environment: environment, fields: fields,
1015
- filter: filter
1016
- }.compact
1017
- end
1018
- # rubocop:enable Metrics/ParameterLists
1019
-
1020
- # Fetch with SWR cache
1021
- def fetch_with_swr_cache(cache_key, name, version, label)
1022
- cache.fetch_with_stale_while_revalidate(cache_key) do
1023
- fetch_prompt_from_api(name, version: version, label: label)
1024
- end
1025
- end
1026
-
1027
- # Fetch with distributed cache (Rails.cache with stampede protection)
1028
- def fetch_with_distributed_cache(cache_key, name, version, label)
1029
- cache.fetch_with_lock(cache_key) do
1030
- fetch_prompt_from_api(name, version: version, label: label)
1031
- end
672
+ "#{dataset_runs_path(dataset_name)}/#{URI.encode_uri_component(run_name)}"
1032
673
  end
1033
674
 
1034
675
  # Fetch a prompt from the API (without caching)
@@ -1041,13 +682,8 @@ module Langfuse
1041
682
  # @raise [UnauthorizedError] if authentication fails
1042
683
  # @raise [ApiError] for other API errors
1043
684
  def fetch_prompt_from_api(name, version: nil, label: nil)
1044
- with_faraday_error_handling do
1045
- params = build_prompt_params(version: version, label: label)
1046
- path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}"
1047
-
1048
- response = connection.get(path, params)
1049
- handle_response(response)
1050
- end
685
+ path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}"
686
+ request(:get, path, params: { version: version, label: label }.compact)
1051
687
  end
1052
688
 
1053
689
  # Build a new Faraday connection
@@ -1073,7 +709,7 @@ module Langfuse
1073
709
  # - Max 2 retries (3 total attempts)
1074
710
  # - Exponential backoff (0.05s * 2^retry_count)
1075
711
  # - Retries GET, PATCH, and DELETE requests (idempotent operations)
1076
- # - Retries POST requests to batch endpoint (idempotent due to event UUIDs)
712
+ # - Retries ingestion batches and score creation (both include stable IDs)
1077
713
  # - Note: POST to create_prompt is NOT idempotent; retries may create duplicate versions
1078
714
  # - Retries on: 429 (rate limit), 503 (service unavailable), 504 (gateway timeout)
1079
715
  # - Does NOT retry on: 4xx errors (except 429), 5xx errors (except 503, 504)
@@ -1116,15 +752,6 @@ module Langfuse
1116
752
  "langfuse-rb/#{Langfuse::VERSION}"
1117
753
  end
1118
754
 
1119
- # Build query parameters for prompt request
1120
- #
1121
- # @param version [Integer, nil] Optional version number
1122
- # @param label [String, nil] Optional label
1123
- # @return [Hash] Query parameters
1124
- def build_prompt_params(version: nil, label: nil)
1125
- { version: version, label: label }.compact
1126
- end
1127
-
1128
755
  # Wrap a block with standard Faraday error handling.
1129
756
  #
1130
757
  # Catches RetriableResponse (retries exhausted) and generic Faraday errors,
@@ -1173,38 +800,77 @@ module Langfuse
1173
800
 
1174
801
  # Handle HTTP response for batch requests
1175
802
  #
803
+ # Per-event input errors can arrive with HTTP 207 instead of a 4xx response.
804
+ # The `errors` array reports rejected events, so HTTP status alone cannot
805
+ # identify a batch with rejected events. An empty array confirms only that
806
+ # the response reported no rejection; it does not prove downstream
807
+ # processing or immediate read visibility.
808
+ #
1176
809
  # @param response [Faraday::Response] The HTTP response
1177
810
  # @return [void]
1178
811
  # @raise [UnauthorizedError] if status is 401
1179
- # @raise [ApiError] for other error statuses
812
+ # @raise [ApiError] for other error statuses, or if the body lists rejected events
1180
813
  def handle_batch_response(response)
1181
814
  case response.status
1182
815
  when 200, 201, 204, 207
1183
- nil
816
+ raise_on_batch_errors(response)
1184
817
  when 401
1185
818
  raise UnauthorizedError, "Authentication failed. Check your API keys."
1186
819
  else
1187
820
  error_message = extract_error_message(response)
1188
- raise ApiError, "Batch send failed (#{response.status}): #{error_message}"
821
+ raise BatchDeliveryError.new(
822
+ "Batch send failed (#{response.status}): #{error_message}",
823
+ retryable: retryable_batch_status?(response.status)
824
+ )
1189
825
  end
1190
826
  end
1191
827
 
828
+ # @param response [Faraday::Response] The HTTP response
829
+ # @return [void]
830
+ # @raise [ApiError] if the response body's `errors` array is non-empty
831
+ def raise_on_batch_errors(response)
832
+ errors = Array(parse_response_body(response)["errors"])
833
+ return if errors.empty?
834
+
835
+ messages = errors.filter_map { |e| e["message"] || e["error"] }
836
+ summary = messages.empty? ? "#{errors.size} event(s) rejected" : messages.join("; ")
837
+ raise BatchDeliveryError.new(
838
+ "Batch send failed: #{summary}",
839
+ retryable: retryable_batch_errors?(errors)
840
+ )
841
+ end
842
+
843
+ def retryable_batch_errors?(errors)
844
+ statuses = errors.filter_map { |error| Integer(error["status"], exception: false) }
845
+ statuses.length == errors.length && statuses.all? { |status| retryable_batch_status?(status) }
846
+ end
847
+
848
+ def retryable_batch_status?(status)
849
+ status == 429 || status >= 500
850
+ end
851
+
1192
852
  # Extract error message from response body
1193
853
  #
1194
854
  # @param response [Faraday::Response] The HTTP response
1195
855
  # @return [String] The error message
1196
856
  def extract_error_message(response)
1197
- body_hash = case response.body
1198
- in Hash => h then h
1199
- in String => s then begin
1200
- JSON.parse(s)
1201
- rescue StandardError
1202
- {}
1203
- end
1204
- else {}
1205
- end
857
+ body_hash = parse_response_body(response)
1206
858
 
1207
859
  %w[message error].filter_map { |key| body_hash[key] }.first || "Unknown error"
1208
860
  end
861
+
862
+ # @param response [Faraday::Response] The HTTP response
863
+ # @return [Hash] The parsed JSON body, or {} if absent/unparsable
864
+ def parse_response_body(response)
865
+ case response.body
866
+ in Hash => h then h
867
+ in String => s then begin
868
+ JSON.parse(s)
869
+ rescue StandardError
870
+ {}
871
+ end
872
+ else {}
873
+ end
874
+ end
1209
875
  end
1210
876
  end