langfuse-rb 0.10.1 → 0.12.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/CHANGELOG.md +47 -1
- data/lib/langfuse/api_client.rb +75 -16
- data/lib/langfuse/app_root_tracking.rb +164 -0
- data/lib/langfuse/chat_prompt_client.rb +18 -1
- data/lib/langfuse/client.rb +114 -32
- data/lib/langfuse/config.rb +306 -63
- data/lib/langfuse/evaluation.rb +8 -4
- data/lib/langfuse/exit_hook.rb +77 -0
- data/lib/langfuse/fork_safety.rb +71 -0
- data/lib/langfuse/masking_exporter.rb +98 -0
- data/lib/langfuse/observations.rb +2 -1
- data/lib/langfuse/otel_attributes.rb +1 -0
- data/lib/langfuse/otel_setup.rb +32 -44
- data/lib/langfuse/otel_span_batch.rb +113 -0
- data/lib/langfuse/otel_span_masking.rb +89 -0
- data/lib/langfuse/otel_span_patch_applier.rb +97 -0
- data/lib/langfuse/pending_score_queue.rb +62 -0
- data/lib/langfuse/prompt_cache.rb +8 -0
- data/lib/langfuse/prompt_variables.rb +54 -0
- data/lib/langfuse/propagation.rb +101 -34
- data/lib/langfuse/rails_cache_adapter.rb +17 -1
- data/lib/langfuse/read_api.rb +242 -0
- data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
- data/lib/langfuse/score_client.rb +201 -80
- data/lib/langfuse/score_value.rb +58 -0
- data/lib/langfuse/span_processor.rb +53 -4
- data/lib/langfuse/stale_while_revalidate.rb +3 -4
- data/lib/langfuse/text_prompt_client.rb +13 -1
- data/lib/langfuse/trace_export_guard.rb +47 -0
- data/lib/langfuse/traced_execution.rb +18 -12
- data/lib/langfuse/types.rb +15 -1
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +156 -44
- metadata +50 -8
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mustache"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Extracts referenced variables from parsed Mustache templates.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class PromptVariables
|
|
10
|
+
TAG_TYPES = %i[etag utag].freeze
|
|
11
|
+
SECTION_TYPES = %i[section inverted_section].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# @api private
|
|
15
|
+
def extract(template)
|
|
16
|
+
tokens = Mustache::Template.new(template).tokens
|
|
17
|
+
collect(tokens, []).reject(&:empty?).uniq
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def collect(tokens, scope)
|
|
23
|
+
tokens.each_with_object([]) do |token, variables|
|
|
24
|
+
next unless token.is_a?(Array)
|
|
25
|
+
|
|
26
|
+
variables.concat(token.first == :mustache ? from_tag(token, scope) : collect(token, scope))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from_tag(token, scope)
|
|
31
|
+
return variable_path(token, scope) if TAG_TYPES.include?(token[1])
|
|
32
|
+
return section_paths(token, scope) if SECTION_TYPES.include?(token[1])
|
|
33
|
+
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def variable_path(token, scope)
|
|
38
|
+
path = scoped_path(token, scope)
|
|
39
|
+
path.empty? ? [] : [path.join(".")]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def section_paths(token, scope)
|
|
43
|
+
section_path = scoped_path(token, scope)
|
|
44
|
+
body_scope = token[1] == :section ? section_path : scope
|
|
45
|
+
[section_path.join("."), *collect(token[4], body_scope)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scoped_path(token, scope)
|
|
49
|
+
segments = token.dig(2, 2)
|
|
50
|
+
segments == ["."] ? scope : scope + segments
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/langfuse/propagation.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Langfuse
|
|
|
6
6
|
# Attribute propagation utilities for Langfuse OpenTelemetry integration.
|
|
7
7
|
#
|
|
8
8
|
# This module provides the `propagate_attributes` method for setting trace-level
|
|
9
|
-
# attributes
|
|
9
|
+
# attributes that automatically propagate to all child spans
|
|
10
10
|
# within the context.
|
|
11
11
|
#
|
|
12
12
|
# @example Basic usage
|
|
@@ -21,29 +21,31 @@ module Langfuse
|
|
|
21
21
|
#
|
|
22
22
|
# rubocop:disable Metrics/ModuleLength
|
|
23
23
|
module Propagation
|
|
24
|
+
# Baggage key prefix for cross-service propagation
|
|
25
|
+
BAGGAGE_PREFIX = "langfuse_"
|
|
26
|
+
|
|
27
|
+
# Baggage key that records which Langfuse trace already owns the application root
|
|
28
|
+
LANGFUSE_TRACE_ID_BAGGAGE_KEY = "#{BAGGAGE_PREFIX}trace_id".freeze
|
|
29
|
+
|
|
30
|
+
ENVIRONMENT_VALUE_PATTERN = /\A(?!langfuse)[a-z0-9_-]+\z/
|
|
31
|
+
private_constant :ENVIRONMENT_VALUE_PATTERN
|
|
32
|
+
|
|
24
33
|
# Map of propagated attribute keys to span attribute keys
|
|
25
34
|
SPAN_KEY_MAP = {
|
|
26
35
|
"user_id" => OtelAttributes::TRACE_USER_ID,
|
|
27
36
|
"session_id" => OtelAttributes::TRACE_SESSION_ID,
|
|
28
37
|
"version" => OtelAttributes::VERSION,
|
|
29
38
|
"tags" => OtelAttributes::TRACE_TAGS,
|
|
30
|
-
"metadata" => OtelAttributes::TRACE_METADATA
|
|
39
|
+
"metadata" => OtelAttributes::TRACE_METADATA,
|
|
40
|
+
"trace_name" => OtelAttributes::TRACE_NAME,
|
|
41
|
+
"release" => OtelAttributes::RELEASE,
|
|
42
|
+
"environment" => OtelAttributes::ENVIRONMENT
|
|
31
43
|
}.freeze
|
|
32
44
|
|
|
33
45
|
# OpenTelemetry context keys for propagated attributes
|
|
34
|
-
CONTEXT_KEYS =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"metadata" => OpenTelemetry::Context.create_key("langfuse_metadata"),
|
|
38
|
-
"version" => OpenTelemetry::Context.create_key("langfuse_version"),
|
|
39
|
-
"tags" => OpenTelemetry::Context.create_key("langfuse_tags")
|
|
40
|
-
}.freeze
|
|
41
|
-
|
|
42
|
-
# List of propagated attribute keys (derived from CONTEXT_KEYS)
|
|
43
|
-
PROPAGATED_ATTRIBUTES = CONTEXT_KEYS.keys.freeze
|
|
44
|
-
|
|
45
|
-
# Baggage key prefix for cross-service propagation
|
|
46
|
-
BAGGAGE_PREFIX = "langfuse_"
|
|
46
|
+
CONTEXT_KEYS = SPAN_KEY_MAP.keys.to_h do |key|
|
|
47
|
+
[key, OpenTelemetry::Context.create_key("#{BAGGAGE_PREFIX}#{key}")]
|
|
48
|
+
end.freeze
|
|
47
49
|
|
|
48
50
|
# Propagate trace-level attributes to all spans created within this context.
|
|
49
51
|
#
|
|
@@ -57,6 +59,9 @@ module Langfuse
|
|
|
57
59
|
# @param metadata [Hash<String, String>, nil] Additional metadata (all values ≤200 characters)
|
|
58
60
|
# @param version [String, nil] Version identifier (≤200 characters)
|
|
59
61
|
# @param tags [Array<String>, nil] List of tags (each ≤200 characters)
|
|
62
|
+
# @param trace_name [String, nil] Trace name (≤200 characters)
|
|
63
|
+
# @param release [String, nil] Release identifier (≤200 characters)
|
|
64
|
+
# @param environment [String, nil] Lowercase environment identifier (≤40 characters)
|
|
60
65
|
# @param as_baggage [Boolean] If true, propagates via OpenTelemetry baggage for cross-service propagation
|
|
61
66
|
# @yield Block within which attributes are propagated
|
|
62
67
|
# @return [Object] The result of the block
|
|
@@ -76,32 +81,29 @@ module Langfuse
|
|
|
76
81
|
# # All spans inherit these attributes
|
|
77
82
|
# end
|
|
78
83
|
#
|
|
84
|
+
# rubocop:disable Metrics/ParameterLists
|
|
79
85
|
def self.propagate_attributes(user_id: nil, session_id: nil, metadata: nil, version: nil, tags: nil,
|
|
80
|
-
as_baggage: false, &block)
|
|
86
|
+
trace_name: nil, release: nil, environment: nil, as_baggage: false, &block)
|
|
81
87
|
raise ArgumentError, "Block required" unless block
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
user_id
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
as_baggage: as_baggage,
|
|
90
|
-
&block
|
|
91
|
-
)
|
|
89
|
+
attributes = {
|
|
90
|
+
"user_id" => user_id, "session_id" => session_id, "metadata" => metadata,
|
|
91
|
+
"version" => version, "tags" => tags, "trace_name" => trace_name,
|
|
92
|
+
"release" => release, "environment" => environment
|
|
93
|
+
}
|
|
94
|
+
_propagate_attributes(attributes, as_baggage: as_baggage, &block)
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
# Internal implementation of propagate_attributes
|
|
95
98
|
#
|
|
99
|
+
# @param attributes [Hash<String, Object>] Propagated attribute values
|
|
100
|
+
# @param as_baggage [Boolean] Whether to add the values to OpenTelemetry baggage
|
|
96
101
|
# @api private
|
|
97
|
-
def self._propagate_attributes(
|
|
98
|
-
as_baggage: false, &)
|
|
102
|
+
def self._propagate_attributes(attributes, as_baggage:, &)
|
|
99
103
|
current_context = OpenTelemetry::Context.current
|
|
100
104
|
current_span = OpenTelemetry::Trace.current_span
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
PROPAGATED_ATTRIBUTES.each do |key|
|
|
104
|
-
value = binding.local_variable_get(key.to_sym)
|
|
106
|
+
attributes.each do |key, value|
|
|
105
107
|
next if value.nil?
|
|
106
108
|
next if key == "tags" && value.empty?
|
|
107
109
|
|
|
@@ -120,6 +122,7 @@ module Langfuse
|
|
|
120
122
|
# Execute block in new context
|
|
121
123
|
OpenTelemetry::Context.with_current(current_context, &)
|
|
122
124
|
end
|
|
125
|
+
# rubocop:enable Metrics/ParameterLists
|
|
123
126
|
|
|
124
127
|
# Validate an attribute value based on its type
|
|
125
128
|
#
|
|
@@ -140,6 +143,8 @@ module Langfuse
|
|
|
140
143
|
validated_metadata[k.to_s] = v.to_s if _validate_string_value(v, "metadata.#{k}")
|
|
141
144
|
end
|
|
142
145
|
validated_metadata.any? ? validated_metadata : nil
|
|
146
|
+
when "environment"
|
|
147
|
+
_validate_environment_value(value)
|
|
143
148
|
else
|
|
144
149
|
_validate_propagated_value(value, key)
|
|
145
150
|
end
|
|
@@ -157,7 +162,7 @@ module Langfuse
|
|
|
157
162
|
propagated_attributes = _extract_baggage_attributes(context)
|
|
158
163
|
|
|
159
164
|
# Handle OTEL context values
|
|
160
|
-
|
|
165
|
+
SPAN_KEY_MAP.each_key do |key|
|
|
161
166
|
context_key = _get_propagated_context_key(key)
|
|
162
167
|
value = context.value(context_key)
|
|
163
168
|
|
|
@@ -165,7 +170,10 @@ module Langfuse
|
|
|
165
170
|
|
|
166
171
|
span_key = _get_propagated_span_key(key)
|
|
167
172
|
|
|
168
|
-
if key == "
|
|
173
|
+
if key == "environment"
|
|
174
|
+
validated_environment = _validate_environment_value(value)
|
|
175
|
+
propagated_attributes[span_key] = validated_environment if validated_environment
|
|
176
|
+
elsif key == "metadata" && value.is_a?(Hash)
|
|
169
177
|
value.each do |k, v|
|
|
170
178
|
metadata_key = "#{OtelAttributes::TRACE_METADATA}.#{k}"
|
|
171
179
|
propagated_attributes[metadata_key] = v.to_s
|
|
@@ -318,6 +326,29 @@ module Langfuse
|
|
|
318
326
|
end
|
|
319
327
|
# rubocop:enable Naming/PredicateMethod
|
|
320
328
|
|
|
329
|
+
# Validate a propagated environment value against the cross-SDK contract.
|
|
330
|
+
#
|
|
331
|
+
# @param value [Object] Environment value to validate
|
|
332
|
+
# @return [String, nil] Validated environment or nil
|
|
333
|
+
# @api private
|
|
334
|
+
def self._validate_environment_value(value)
|
|
335
|
+
return _drop_environment("value is not a string") unless value.is_a?(String)
|
|
336
|
+
return _drop_environment("value is over 40 characters (#{value.length} chars)") if value.length > 40
|
|
337
|
+
|
|
338
|
+
return value if ENVIRONMENT_VALUE_PATTERN.match?(value)
|
|
339
|
+
|
|
340
|
+
_drop_environment(
|
|
341
|
+
"must use lowercase letters, numbers, hyphens, or underscores and must not start with 'langfuse'"
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def self._drop_environment(reason)
|
|
346
|
+
Langfuse.configuration.logger.warn(
|
|
347
|
+
"Langfuse: Propagated attribute 'environment' #{reason}. Dropping value."
|
|
348
|
+
)
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
321
352
|
# Get context key for a propagated attribute
|
|
322
353
|
#
|
|
323
354
|
# @param key [String] Attribute key (user_id, session_id, etc.)
|
|
@@ -378,6 +409,40 @@ module Langfuse
|
|
|
378
409
|
defined?(OpenTelemetry::Baggage)
|
|
379
410
|
end
|
|
380
411
|
|
|
412
|
+
# Get the Langfuse trace claim from OpenTelemetry baggage.
|
|
413
|
+
#
|
|
414
|
+
# @param context [OpenTelemetry::Context] Context to inspect
|
|
415
|
+
# @return [String, nil] Lowercase trace ID or nil
|
|
416
|
+
# @api private
|
|
417
|
+
def self._get_langfuse_trace_id_from_baggage(context)
|
|
418
|
+
return nil unless baggage_available?
|
|
419
|
+
|
|
420
|
+
OpenTelemetry::Baggage.values(context: context)[LANGFUSE_TRACE_ID_BAGGAGE_KEY]&.to_s&.downcase
|
|
421
|
+
rescue StandardError => e
|
|
422
|
+
Langfuse.configuration.logger.debug("Langfuse: Trace baggage read failed: #{e.message}")
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Set the Langfuse trace claim in OpenTelemetry baggage.
|
|
427
|
+
#
|
|
428
|
+
# @param trace_id [String] Lowercase hexadecimal trace ID
|
|
429
|
+
# @param context [OpenTelemetry::Context] Context to extend
|
|
430
|
+
# @return [OpenTelemetry::Context] Context with the trace claim
|
|
431
|
+
# @api private
|
|
432
|
+
def self._set_langfuse_trace_id_in_baggage(trace_id, context:)
|
|
433
|
+
return context unless baggage_available?
|
|
434
|
+
|
|
435
|
+
normalized_trace_id = trace_id.downcase
|
|
436
|
+
return context if _get_langfuse_trace_id_from_baggage(context) == normalized_trace_id
|
|
437
|
+
|
|
438
|
+
OpenTelemetry::Baggage.set_value(
|
|
439
|
+
LANGFUSE_TRACE_ID_BAGGAGE_KEY, normalized_trace_id, context: context
|
|
440
|
+
)
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
Langfuse.configuration.logger.debug("Langfuse: Trace baggage write failed: #{e.message}")
|
|
443
|
+
context
|
|
444
|
+
end
|
|
445
|
+
|
|
381
446
|
# Extract propagated attributes from baggage
|
|
382
447
|
#
|
|
383
448
|
# @param context [OpenTelemetry::Context] The context to read baggage from
|
|
@@ -399,7 +464,7 @@ module Langfuse
|
|
|
399
464
|
|
|
400
465
|
attributes[span_key] = _parse_baggage_value(span_key, baggage_value)
|
|
401
466
|
end
|
|
402
|
-
attributes
|
|
467
|
+
attributes.compact
|
|
403
468
|
rescue StandardError => e
|
|
404
469
|
Langfuse.configuration.logger.debug("Langfuse: Baggage extraction failed: #{e.message}")
|
|
405
470
|
{}
|
|
@@ -413,7 +478,9 @@ module Langfuse
|
|
|
413
478
|
#
|
|
414
479
|
# @api private
|
|
415
480
|
def self._parse_baggage_value(span_key, baggage_value)
|
|
416
|
-
if span_key == OtelAttributes::
|
|
481
|
+
if span_key == OtelAttributes::ENVIRONMENT
|
|
482
|
+
_validate_environment_value(baggage_value)
|
|
483
|
+
elsif span_key == OtelAttributes::TRACE_TAGS && baggage_value.is_a?(String)
|
|
417
484
|
baggage_value.split(",")
|
|
418
485
|
else
|
|
419
486
|
baggage_value.to_s
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "prompt_cache"
|
|
4
4
|
require_relative "stale_while_revalidate"
|
|
5
|
+
require_relative "fork_safety"
|
|
5
6
|
|
|
6
7
|
module Langfuse
|
|
7
8
|
# Rails.cache adapter for distributed caching with Redis
|
|
@@ -20,6 +21,14 @@ module Langfuse
|
|
|
20
21
|
|
|
21
22
|
GENERATION_MEMO_TTL_SECONDS = 1.0
|
|
22
23
|
|
|
24
|
+
# Check whether Rails has a configured cache store.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def self.available?
|
|
29
|
+
!!(defined?(Rails) && Rails.respond_to?(:cache) && !Rails.cache.nil?)
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
# @return [Integer] Time-to-live in seconds
|
|
24
33
|
attr_reader :ttl
|
|
25
34
|
|
|
@@ -61,6 +70,7 @@ module Langfuse
|
|
|
61
70
|
@generation_memo = {}
|
|
62
71
|
@generation_memo_mutex = Mutex.new
|
|
63
72
|
initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
|
|
73
|
+
ForkSafety.register(self)
|
|
64
74
|
end
|
|
65
75
|
|
|
66
76
|
# Get a value from the cache
|
|
@@ -249,6 +259,12 @@ module Langfuse
|
|
|
249
259
|
|
|
250
260
|
private
|
|
251
261
|
|
|
262
|
+
# Replace inherited synchronization and worker state in the child process.
|
|
263
|
+
def reset_after_fork
|
|
264
|
+
@generation_memo = {}
|
|
265
|
+
@generation_memo_mutex = Mutex.new
|
|
266
|
+
end
|
|
267
|
+
|
|
252
268
|
# Implementation of StaleWhileRevalidate abstract methods
|
|
253
269
|
|
|
254
270
|
# Get value from cache (SWR interface)
|
|
@@ -416,7 +432,7 @@ module Langfuse
|
|
|
416
432
|
# @raise [ConfigurationError] if Rails.cache is not available
|
|
417
433
|
# @return [void]
|
|
418
434
|
def validate_rails_cache!
|
|
419
|
-
return if
|
|
435
|
+
return if self.class.available?
|
|
420
436
|
|
|
421
437
|
raise ConfigurationError,
|
|
422
438
|
"Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Langfuse
|
|
7
|
+
# Read endpoints for the current Langfuse query surface.
|
|
8
|
+
#
|
|
9
|
+
# Implements v2 observation and metrics reads plus the v3 scores read.
|
|
10
|
+
# Mixed into {ApiClient}, whose private +request+ helper provides HTTP
|
|
11
|
+
# transport and error handling.
|
|
12
|
+
#
|
|
13
|
+
# @note The v2 observations and metrics endpoints require Langfuse v4. There
|
|
14
|
+
# is no fallback to legacy endpoints because their response and pagination
|
|
15
|
+
# semantics differ.
|
|
16
|
+
module ReadApi
|
|
17
|
+
# Ruby keyword argument -> camelCase query parameter mappings. Start-time
|
|
18
|
+
# bounds are handled separately because they need ISO 8601 formatting.
|
|
19
|
+
OBSERVATION_QUERY_PARAMS = {
|
|
20
|
+
trace_id: :traceId, fields: :fields, cursor: :cursor, limit: :limit,
|
|
21
|
+
filter: :filter, name: :name, user_id: :userId, type: :type,
|
|
22
|
+
level: :level, parent_observation_id: :parentObservationId,
|
|
23
|
+
is_root_observation: :isRootObservation, environment: :environment,
|
|
24
|
+
session_id: :sessionId, version: :version,
|
|
25
|
+
expand_metadata: :expandMetadata
|
|
26
|
+
}.freeze
|
|
27
|
+
private_constant :OBSERVATION_QUERY_PARAMS
|
|
28
|
+
|
|
29
|
+
SCORE_QUERY_PARAMS = {
|
|
30
|
+
limit: :limit, cursor: :cursor, fields: :fields, id: :id, name: :name,
|
|
31
|
+
source: :source, data_type: :dataType, environment: :environment,
|
|
32
|
+
config_id: :configId, queue_id: :queueId, author_user_id: :authorUserId,
|
|
33
|
+
value: :value, value_min: :valueMin, value_max: :valueMax,
|
|
34
|
+
trace_id: :traceId, session_id: :sessionId,
|
|
35
|
+
observation_id: :observationId, experiment_id: :experimentId
|
|
36
|
+
}.freeze
|
|
37
|
+
private_constant :SCORE_QUERY_PARAMS
|
|
38
|
+
|
|
39
|
+
# List observations with cursor-based pagination and field selection
|
|
40
|
+
#
|
|
41
|
+
# Delegates to +GET /api/public/v2/observations+ on Langfuse v4.
|
|
42
|
+
# Returns observation rows, not reconstructed trace objects. The full
|
|
43
|
+
# response envelope is preserved: +"data"+ holds the observation rows and
|
|
44
|
+
# +"meta"+ holds the pagination cursor for the next page.
|
|
45
|
+
#
|
|
46
|
+
# Broad reads must be bounded: unless +trace_id+ narrows the query, both
|
|
47
|
+
# +from_start_time+ and +to_start_time+ are required.
|
|
48
|
+
#
|
|
49
|
+
# @param from_start_time [Time, String, nil] Inclusive lower bound on observation start time
|
|
50
|
+
# @param to_start_time [Time, String, nil] Exclusive upper bound on observation start time
|
|
51
|
+
# @param trace_id [String, nil] Filter by trace ID
|
|
52
|
+
# @param fields [String, nil] Comma-separated field groups to include
|
|
53
|
+
# (core, basic, time, io, metadata, model, usage, prompt, metrics, trace_context)
|
|
54
|
+
# @param cursor [String, nil] Cursor from the previous response's meta for the next page
|
|
55
|
+
# @param limit [Integer, nil] Items per page (max 1000, default 50)
|
|
56
|
+
# @param filter [String, nil] JSON string with structured filter conditions;
|
|
57
|
+
# takes precedence over individual query parameter filters
|
|
58
|
+
# @param name [String, nil] Filter by observation name
|
|
59
|
+
# @param user_id [String, nil] Filter by user ID
|
|
60
|
+
# @param type [String, nil] Filter by observation type (e.g. "GENERATION", "SPAN")
|
|
61
|
+
# @param level [String, nil] Filter by level (e.g. "DEFAULT", "ERROR")
|
|
62
|
+
# @param parent_observation_id [String, nil] Filter by parent observation ID
|
|
63
|
+
# @param is_root_observation [Boolean, nil] Filter by logical root status
|
|
64
|
+
# @param environment [Array<String>, nil] Filter by one or more environments
|
|
65
|
+
# @param session_id [String, nil] Filter by session ID
|
|
66
|
+
# @param version [String, nil] Filter by observation version
|
|
67
|
+
# @param expand_metadata [String, nil] Comma-separated metadata keys to return non-truncated
|
|
68
|
+
# @return [Hash] Full response hash with "data" rows and "meta" cursor info
|
|
69
|
+
# @raise [ArgumentError] if the read is unbounded (no trace_id and missing start-time bounds)
|
|
70
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
71
|
+
# @raise [ApiError] for other API errors (including deployments without Langfuse v4)
|
|
72
|
+
#
|
|
73
|
+
# @example Bounded read of recent generations
|
|
74
|
+
# page = api_client.list_observations(
|
|
75
|
+
# from_start_time: Time.now - 3600,
|
|
76
|
+
# to_start_time: Time.now,
|
|
77
|
+
# type: "GENERATION",
|
|
78
|
+
# fields: "core,basic,usage"
|
|
79
|
+
# )
|
|
80
|
+
# page["data"].each { |obs| puts obs["id"] }
|
|
81
|
+
# next_cursor = page.dig("meta", "cursor")
|
|
82
|
+
# rubocop:disable Metrics/ParameterLists
|
|
83
|
+
def list_observations(from_start_time: nil, to_start_time: nil, trace_id: nil,
|
|
84
|
+
fields: nil, cursor: nil, limit: nil, filter: nil,
|
|
85
|
+
name: nil, user_id: nil, type: nil, level: nil,
|
|
86
|
+
parent_observation_id: nil, is_root_observation: nil,
|
|
87
|
+
environment: nil, session_id: nil, version: nil,
|
|
88
|
+
expand_metadata: nil)
|
|
89
|
+
validate_bounded_observation_read!(trace_id, from_start_time, to_start_time)
|
|
90
|
+
params = build_observations_params(
|
|
91
|
+
from_start_time: from_start_time, to_start_time: to_start_time,
|
|
92
|
+
trace_id: trace_id, fields: fields, cursor: cursor, limit: limit,
|
|
93
|
+
filter: filter, name: name, user_id: user_id, type: type, level: level,
|
|
94
|
+
parent_observation_id: parent_observation_id, environment: environment,
|
|
95
|
+
is_root_observation: is_root_observation, session_id: session_id,
|
|
96
|
+
version: version, expand_metadata: expand_metadata
|
|
97
|
+
)
|
|
98
|
+
request(
|
|
99
|
+
:get,
|
|
100
|
+
"/api/public/v2/observations",
|
|
101
|
+
params: params,
|
|
102
|
+
params_encoder: Faraday::FlatParamsEncoder
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
# rubocop:enable Metrics/ParameterLists
|
|
106
|
+
|
|
107
|
+
# Query aggregate metrics on Langfuse v4
|
|
108
|
+
#
|
|
109
|
+
# Delegates to +GET /api/public/v2/metrics+. Supports the +observations+,
|
|
110
|
+
# +scores-numeric+, +scores-categorical+, and +scores-boolean+ views.
|
|
111
|
+
#
|
|
112
|
+
# @param query [Hash, String] Metrics query. A Hash is JSON-encoded into
|
|
113
|
+
# the endpoint's +query+ parameter; a pre-encoded JSON String is passed
|
|
114
|
+
# through unchanged.
|
|
115
|
+
# @return [Hash] The parsed metrics response
|
|
116
|
+
# @raise [ArgumentError] if query is neither a Hash nor a String
|
|
117
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
118
|
+
# @raise [ApiError] for other API errors (including deployments without Langfuse v4)
|
|
119
|
+
#
|
|
120
|
+
# @example Count observations by name
|
|
121
|
+
# api_client.query_metrics(query: {
|
|
122
|
+
# view: "observations",
|
|
123
|
+
# metrics: [{ measure: "count", aggregation: "count" }],
|
|
124
|
+
# dimensions: [{ field: "name" }],
|
|
125
|
+
# fromTimestamp: "2026-07-01T00:00:00Z",
|
|
126
|
+
# toTimestamp: "2026-07-02T00:00:00Z"
|
|
127
|
+
# })
|
|
128
|
+
def query_metrics(query:)
|
|
129
|
+
request(:get, "/api/public/v2/metrics", params: { query: encode_metrics_query(query) })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# List scores with polymorphic values (v3)
|
|
133
|
+
#
|
|
134
|
+
# Delegates to +GET /api/public/v3/scores+. The full response envelope is
|
|
135
|
+
# preserved: +"data"+ holds score rows and +"meta"+ holds the pagination
|
|
136
|
+
# cursor. Score values are polymorphic by +dataType+: NUMERIC scores return
|
|
137
|
+
# numbers, BOOLEAN scores return booleans, and CATEGORICAL, TEXT, and
|
|
138
|
+
# CORRECTION scores return strings.
|
|
139
|
+
#
|
|
140
|
+
# @param limit [Integer, nil] Items per page (max 100, default 50)
|
|
141
|
+
# @param cursor [String, nil] Cursor from the previous response's meta for the next page
|
|
142
|
+
# @param fields [String, nil] Comma-separated field groups in addition to core
|
|
143
|
+
# (details, subject, annotation)
|
|
144
|
+
# @param id [String, nil] Comma-separated score IDs to filter by
|
|
145
|
+
# @param name [String, nil] Comma-separated score names to filter by
|
|
146
|
+
# @param source [String, nil] Comma-separated score sources (e.g. API, ANNOTATION, EVAL)
|
|
147
|
+
# @param data_type [String, nil] Comma-separated data types
|
|
148
|
+
# (NUMERIC, BOOLEAN, CATEGORICAL, TEXT, CORRECTION)
|
|
149
|
+
# @param environment [String, nil] Comma-separated environments to filter by
|
|
150
|
+
# @param config_id [String, nil] Comma-separated score config IDs
|
|
151
|
+
# @param queue_id [String, nil] Comma-separated annotation queue IDs
|
|
152
|
+
# @param author_user_id [String, nil] Comma-separated author user IDs
|
|
153
|
+
# @param value [String, nil] Comma-separated exact values (requires a single
|
|
154
|
+
# NUMERIC, BOOLEAN, or CATEGORICAL data_type)
|
|
155
|
+
# @param value_min [Numeric, nil] Inclusive lower bound (requires data_type: "NUMERIC")
|
|
156
|
+
# @param value_max [Numeric, nil] Inclusive upper bound (requires data_type: "NUMERIC")
|
|
157
|
+
# @param trace_id [String, nil] Comma-separated trace IDs (mutually exclusive
|
|
158
|
+
# with session_id and experiment_id)
|
|
159
|
+
# @param session_id [String, nil] Comma-separated session IDs
|
|
160
|
+
# @param observation_id [String, nil] Comma-separated observation IDs (requires trace_id)
|
|
161
|
+
# @param experiment_id [String, nil] Comma-separated dataset run (experiment) IDs
|
|
162
|
+
# @param from_timestamp [Time, String, nil] Inclusive lower bound on score timestamp
|
|
163
|
+
# @param to_timestamp [Time, String, nil] Exclusive upper bound on score timestamp
|
|
164
|
+
# @return [Hash] Full response hash with "data" rows and "meta" cursor info
|
|
165
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
166
|
+
# @raise [ApiError] for other API errors
|
|
167
|
+
#
|
|
168
|
+
# @example Read corrections for a trace
|
|
169
|
+
# page = api_client.list_scores(trace_id: trace_id, data_type: "CORRECTION", fields: "subject,details")
|
|
170
|
+
# page["data"].each { |score| puts score["value"] }
|
|
171
|
+
# rubocop:disable Metrics/ParameterLists
|
|
172
|
+
def list_scores(limit: nil, cursor: nil, fields: nil, id: nil, name: nil,
|
|
173
|
+
source: nil, data_type: nil, environment: nil, config_id: nil,
|
|
174
|
+
queue_id: nil, author_user_id: nil, value: nil, value_min: nil,
|
|
175
|
+
value_max: nil, trace_id: nil, session_id: nil,
|
|
176
|
+
observation_id: nil, experiment_id: nil,
|
|
177
|
+
from_timestamp: nil, to_timestamp: nil)
|
|
178
|
+
params = build_scores_params(
|
|
179
|
+
limit: limit, cursor: cursor, fields: fields, id: id, name: name,
|
|
180
|
+
source: source, data_type: data_type, environment: environment,
|
|
181
|
+
config_id: config_id, queue_id: queue_id, author_user_id: author_user_id,
|
|
182
|
+
value: value, value_min: value_min, value_max: value_max,
|
|
183
|
+
trace_id: trace_id, session_id: session_id, observation_id: observation_id,
|
|
184
|
+
experiment_id: experiment_id, from_timestamp: from_timestamp, to_timestamp: to_timestamp
|
|
185
|
+
)
|
|
186
|
+
request(:get, "/api/public/v3/scores", params: params)
|
|
187
|
+
end
|
|
188
|
+
# rubocop:enable Metrics/ParameterLists
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
# v2 observation reads must always be bounded; an unbounded scan over the
|
|
193
|
+
# events table is rejected here rather than issued silently. Only values
|
|
194
|
+
# matching the documented query contract can satisfy the bound.
|
|
195
|
+
def validate_bounded_observation_read!(trace_id, from_start_time, to_start_time)
|
|
196
|
+
return if non_empty_string?(trace_id)
|
|
197
|
+
return if valid_query_time?(from_start_time) && valid_query_time?(to_start_time)
|
|
198
|
+
|
|
199
|
+
raise ArgumentError,
|
|
200
|
+
"from_start_time and to_start_time are required unless trace_id is provided"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def valid_query_time?(value)
|
|
204
|
+
non_empty_string?(format_query_time(value))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def non_empty_string?(value)
|
|
208
|
+
value.is_a?(String) && !value.strip.empty?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_observations_params(**options)
|
|
212
|
+
map_query_params(OBSERVATION_QUERY_PARAMS, options).merge(
|
|
213
|
+
fromStartTime: format_query_time(options[:from_start_time]),
|
|
214
|
+
toStartTime: format_query_time(options[:to_start_time])
|
|
215
|
+
).compact
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_scores_params(**options)
|
|
219
|
+
map_query_params(SCORE_QUERY_PARAMS, options).merge(
|
|
220
|
+
fromTimestamp: format_query_time(options[:from_timestamp]),
|
|
221
|
+
toTimestamp: format_query_time(options[:to_timestamp])
|
|
222
|
+
).compact
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def map_query_params(mapping, options)
|
|
226
|
+
mapping.to_h { |ruby_key, api_key| [api_key, options[ruby_key]] }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def encode_metrics_query(query)
|
|
230
|
+
case query
|
|
231
|
+
when Hash then JSON.generate(query)
|
|
232
|
+
when String then query
|
|
233
|
+
else raise ArgumentError, "query must be a Hash or JSON String, got #{query.class}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Accepts Time-like values or pre-formatted ISO 8601 strings.
|
|
238
|
+
def format_query_time(value)
|
|
239
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/atomic/atomic_boolean"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Protects trace processing from failures in an application-owned metrics reporter.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class ResilientMetricsReporter
|
|
10
|
+
def self.wrap(reporter, logger:)
|
|
11
|
+
return if reporter.nil?
|
|
12
|
+
|
|
13
|
+
new(reporter, logger: logger)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(reporter, logger:)
|
|
17
|
+
@reporter = reporter
|
|
18
|
+
@logger = logger
|
|
19
|
+
@warning_emitted = Concurrent::AtomicBoolean.new(false)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_to_counter(metric, increment: 1, labels: {})
|
|
23
|
+
safely(:add_to_counter) do
|
|
24
|
+
@reporter.add_to_counter(metric, increment: increment, labels: labels)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record_value(metric, value:, labels: {})
|
|
29
|
+
safely(:record_value) do
|
|
30
|
+
@reporter.record_value(metric, value: value, labels: labels)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def observe_value(metric, value:, labels: {})
|
|
35
|
+
safely(:observe_value) do
|
|
36
|
+
@reporter.observe_value(metric, value: value, labels: labels)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def safely(method_name)
|
|
43
|
+
yield
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
warn_once(method_name, e.class)
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def warn_once(method_name, error_class)
|
|
50
|
+
return unless @warning_emitted.make_true
|
|
51
|
+
|
|
52
|
+
@logger.warn(
|
|
53
|
+
"Langfuse metrics_reporter ##{method_name} failed with #{error_class}; " \
|
|
54
|
+
"future reporter failures will be suppressed"
|
|
55
|
+
)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|