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
@@ -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
@@ -162,7 +172,10 @@ module Langfuse
162
172
  current_generation_entries: nil,
163
173
  orphaned_entries: nil,
164
174
  total_entries: nil,
165
- global_generation: generation_value(global_generation_key),
175
+ ttl: ttl,
176
+ size: size,
177
+ max_size: nil,
178
+ global_generation: safe_generation_value(global_generation_key),
166
179
  unsupported_counts: CacheBackend::UNSUPPORTED_COUNT_KEYS
167
180
  }
168
181
  end
@@ -246,6 +259,12 @@ module Langfuse
246
259
 
247
260
  private
248
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
+
249
268
  # Implementation of StaleWhileRevalidate abstract methods
250
269
 
251
270
  # Get value from cache (SWR interface)
@@ -351,6 +370,12 @@ module Langfuse
351
370
  end
352
371
  end
353
372
 
373
+ def safe_generation_value(key)
374
+ return nil unless Rails.cache.respond_to?(:read)
375
+
376
+ generation_value(key)
377
+ end
378
+
354
379
  def bump_generation(key)
355
380
  incremented = increment_generation(key)
356
381
  if incremented
@@ -407,7 +432,7 @@ module Langfuse
407
432
  # @raise [ConfigurationError] if Rails.cache is not available
408
433
  # @return [void]
409
434
  def validate_rails_cache!
410
- return if defined?(Rails) && Rails.respond_to?(:cache)
435
+ return if self.class.available?
411
436
 
412
437
  raise ConfigurationError,
413
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