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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/lib/langfuse/api_client.rb +175 -509
- data/lib/langfuse/app_root_tracking.rb +164 -0
- data/lib/langfuse/cache_warmer.rb +21 -13
- data/lib/langfuse/chat_prompt_client.rb +21 -4
- data/lib/langfuse/client.rb +174 -229
- data/lib/langfuse/config.rb +308 -65
- 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 +11 -0
- data/lib/langfuse/prompt_cache_coordinator.rb +288 -0
- data/lib/langfuse/prompt_cache_events.rb +31 -10
- data/lib/langfuse/prompt_variables.rb +54 -0
- data/lib/langfuse/propagation.rb +101 -34
- data/lib/langfuse/rails_cache_adapter.rb +27 -2
- data/lib/langfuse/read_api.rb +242 -0
- data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
- data/lib/langfuse/score_client.rb +209 -89
- 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 +15 -6
- 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 +157 -44
- metadata +16 -2
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "opentelemetry/trace"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "fork_safety"
|
|
7
|
+
require_relative "pending_score_queue"
|
|
5
8
|
|
|
6
9
|
module Langfuse
|
|
7
10
|
# Client for creating and batching Langfuse scores
|
|
@@ -32,6 +35,9 @@ module Langfuse
|
|
|
32
35
|
attr_reader :logger
|
|
33
36
|
|
|
34
37
|
HEX_TRACE_ID_PATTERN = /\A[0-9a-f]{32}\z/
|
|
38
|
+
MAX_BATCH_PAYLOAD_BYTES = 2_500_000
|
|
39
|
+
EMPTY_BATCH_PAYLOAD_BYTES = JSON.generate(batch: []).bytesize
|
|
40
|
+
private_constant :MAX_BATCH_PAYLOAD_BYTES, :EMPTY_BATCH_PAYLOAD_BYTES
|
|
35
41
|
|
|
36
42
|
# Initialize a new ScoreClient
|
|
37
43
|
#
|
|
@@ -41,8 +47,9 @@ module Langfuse
|
|
|
41
47
|
@api_client = api_client
|
|
42
48
|
@config = config
|
|
43
49
|
@logger = config.logger
|
|
44
|
-
@queue =
|
|
45
|
-
@
|
|
50
|
+
@queue = PendingScoreQueue.new(capacity: config.score_queue_capacity)
|
|
51
|
+
@shutdown_mutex = Mutex.new
|
|
52
|
+
@flush_mutex = Mutex.new
|
|
46
53
|
@flush_thread = nil
|
|
47
54
|
@shutdown = false
|
|
48
55
|
# Match the immutable tracing setup contract: once this client exists, later config
|
|
@@ -50,20 +57,21 @@ module Langfuse
|
|
|
50
57
|
@score_sampler = Sampling.build_sampler(config.sample_rate)
|
|
51
58
|
|
|
52
59
|
start_flush_timer
|
|
60
|
+
ForkSafety.register(self)
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
# Create a score event and queue it for batching
|
|
56
64
|
#
|
|
57
65
|
# @param name [String] Score name (required)
|
|
58
66
|
# @param value [Numeric, Integer, String] Score value (type depends on data_type)
|
|
59
|
-
# @param id [String, nil] Score ID
|
|
67
|
+
# @param id [String, nil] Score ID; use a stable value as an idempotency key
|
|
60
68
|
# @param trace_id [String, nil] Trace ID to associate with the score
|
|
61
69
|
# @param session_id [String, nil] Session ID to associate with the score
|
|
62
70
|
# @param observation_id [String, nil] Observation ID to associate with the score
|
|
63
71
|
# @param comment [String, nil] Optional comment
|
|
64
72
|
# @param metadata [Hash, nil] Optional metadata hash
|
|
65
|
-
# @param environment [String, nil] Optional environment
|
|
66
|
-
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
|
|
73
|
+
# @param environment [String, nil] Optional per-score environment override
|
|
74
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical, :text, :correction)
|
|
67
75
|
# @param dataset_run_id [String, nil] Optional dataset run ID to associate with the score
|
|
68
76
|
# @param config_id [String, nil] Optional score config ID
|
|
69
77
|
# @return [void]
|
|
@@ -77,30 +85,95 @@ module Langfuse
|
|
|
77
85
|
#
|
|
78
86
|
# @example Categorical score
|
|
79
87
|
# create(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
|
|
88
|
+
#
|
|
89
|
+
# @example Text score (1 to 500 characters)
|
|
90
|
+
# create(name: "reviewer_notes", value: "Helpful but verbose", trace_id: "abc123", data_type: :text)
|
|
91
|
+
#
|
|
92
|
+
# @example Corrected output (conventionally named "output")
|
|
93
|
+
# create(name: "output", value: "The corrected output", trace_id: "abc123",
|
|
94
|
+
# observation_id: "def456", data_type: :correction)
|
|
80
95
|
# rubocop:disable Metrics/ParameterLists
|
|
81
96
|
def create(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
|
|
82
97
|
metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
|
|
83
|
-
|
|
84
|
-
normalized_value = normalize_value(value, data_type)
|
|
85
|
-
data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
|
|
98
|
+
return unless config.telemetry_enabled?
|
|
86
99
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
score = build_score_body(
|
|
101
|
+
name: name,
|
|
102
|
+
value: value,
|
|
103
|
+
id: id,
|
|
104
|
+
trace_id: trace_id,
|
|
105
|
+
session_id: session_id,
|
|
106
|
+
observation_id: observation_id,
|
|
107
|
+
comment: comment,
|
|
108
|
+
metadata: metadata,
|
|
109
|
+
environment: environment,
|
|
110
|
+
data_type: data_type,
|
|
111
|
+
dataset_run_id: dataset_run_id,
|
|
112
|
+
config_id: config_id
|
|
94
113
|
)
|
|
95
114
|
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
return unless enqueue_trace_linked_score?(trace_id)
|
|
116
|
+
|
|
117
|
+
enqueue_score_event(build_score_event(score))
|
|
98
118
|
rescue StandardError => e
|
|
99
119
|
logger.error("Langfuse score creation failed: #{e.message}")
|
|
100
120
|
raise
|
|
101
121
|
end
|
|
102
122
|
# rubocop:enable Metrics/ParameterLists
|
|
103
123
|
|
|
124
|
+
# Create a score immediately through the Scores API.
|
|
125
|
+
#
|
|
126
|
+
# {#create} is fire-and-forget — it queues the event and reports nothing
|
|
127
|
+
# about whether it was actually delivered, matching how this SDK's
|
|
128
|
+
# tracing already works. That fits scoring inline from a still-open span
|
|
129
|
+
# (see {#score_active_observation}/{#score_active_trace}), but not a
|
|
130
|
+
# standalone verdict arriving out-of-band (e.g. user feedback landing in
|
|
131
|
+
# a request unrelated to the turn it's scoring). Pass a stable +id+ when
|
|
132
|
+
# the caller may retry after an ambiguous network failure.
|
|
133
|
+
#
|
|
134
|
+
# @param name [String] Score name (required)
|
|
135
|
+
# @param value [Numeric, Integer, String] Score value (type depends on data_type)
|
|
136
|
+
# @param id [String, nil] Score ID; use a stable value as an idempotency key
|
|
137
|
+
# @param trace_id [String, nil] Trace ID to associate with the score
|
|
138
|
+
# @param session_id [String, nil] Session ID to associate with the score
|
|
139
|
+
# @param observation_id [String, nil] Observation ID to associate with the score
|
|
140
|
+
# @param comment [String, nil] Optional comment
|
|
141
|
+
# @param metadata [Hash, nil] Optional metadata hash
|
|
142
|
+
# @param environment [String, nil] Optional per-score environment override
|
|
143
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical, :text, :correction)
|
|
144
|
+
# @param dataset_run_id [String, nil] Optional dataset run ID to associate with the score
|
|
145
|
+
# @param config_id [String, nil] Optional score config ID
|
|
146
|
+
# @return [String, nil] ID of the created score, or nil when telemetry is disabled
|
|
147
|
+
# @raise [ArgumentError] if validation fails
|
|
148
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
149
|
+
# @raise [ApiError] if the API request fails
|
|
150
|
+
#
|
|
151
|
+
# @example Create a score with an idempotency key
|
|
152
|
+
# score_client.create!(id: "feedback-abc123", name: "quality", value: 0.85, trace_id: "abc123")
|
|
153
|
+
# rubocop:disable Metrics/ParameterLists
|
|
154
|
+
def create!(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
|
|
155
|
+
metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
|
|
156
|
+
return unless config.telemetry_enabled?
|
|
157
|
+
|
|
158
|
+
score = build_score_body(
|
|
159
|
+
name: name,
|
|
160
|
+
value: value,
|
|
161
|
+
id: id,
|
|
162
|
+
trace_id: trace_id,
|
|
163
|
+
session_id: session_id,
|
|
164
|
+
observation_id: observation_id,
|
|
165
|
+
comment: comment,
|
|
166
|
+
metadata: metadata,
|
|
167
|
+
environment: environment,
|
|
168
|
+
data_type: data_type,
|
|
169
|
+
dataset_run_id: dataset_run_id,
|
|
170
|
+
config_id: config_id
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
api_client.create_score(payload: score)
|
|
174
|
+
end
|
|
175
|
+
# rubocop:enable Metrics/ParameterLists
|
|
176
|
+
|
|
104
177
|
# Create a score for the currently active observation (from OTel span)
|
|
105
178
|
#
|
|
106
179
|
# Extracts observation_id and trace_id from the active OpenTelemetry span.
|
|
@@ -109,7 +182,7 @@ module Langfuse
|
|
|
109
182
|
# @param value [Numeric, Integer, String] Score value
|
|
110
183
|
# @param comment [String, nil] Optional comment
|
|
111
184
|
# @param metadata [Hash, nil] Optional metadata hash
|
|
112
|
-
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
|
|
185
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical, :text, :correction)
|
|
113
186
|
# @return [void]
|
|
114
187
|
# @raise [ArgumentError] if no active span or validation fails
|
|
115
188
|
#
|
|
@@ -140,7 +213,7 @@ module Langfuse
|
|
|
140
213
|
# @param value [Numeric, Integer, String] Score value
|
|
141
214
|
# @param comment [String, nil] Optional comment
|
|
142
215
|
# @param metadata [Hash, nil] Optional metadata hash
|
|
143
|
-
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
|
|
216
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical, :text, :correction)
|
|
144
217
|
# @return [void]
|
|
145
218
|
# @raise [ArgumentError] if no active span or validation fails
|
|
146
219
|
#
|
|
@@ -168,19 +241,9 @@ module Langfuse
|
|
|
168
241
|
#
|
|
169
242
|
# @return [void]
|
|
170
243
|
def flush
|
|
171
|
-
return
|
|
244
|
+
return unless config.telemetry_enabled?
|
|
172
245
|
|
|
173
|
-
|
|
174
|
-
@queue.size.times do
|
|
175
|
-
events << @queue.pop(true)
|
|
176
|
-
rescue StandardError
|
|
177
|
-
nil
|
|
178
|
-
end
|
|
179
|
-
events.compact!
|
|
180
|
-
|
|
181
|
-
return if events.empty?
|
|
182
|
-
|
|
183
|
-
send_batch(events)
|
|
246
|
+
@flush_mutex.synchronize { flush_pending_batches }
|
|
184
247
|
rescue StandardError => e
|
|
185
248
|
logger.error("Langfuse score flush failed: #{e.message}")
|
|
186
249
|
# Don't raise - silent error handling for batch operations
|
|
@@ -192,7 +255,7 @@ module Langfuse
|
|
|
192
255
|
#
|
|
193
256
|
# @return [void]
|
|
194
257
|
def shutdown
|
|
195
|
-
@
|
|
258
|
+
@shutdown_mutex.synchronize do
|
|
196
259
|
return if @shutdown
|
|
197
260
|
|
|
198
261
|
@shutdown = true
|
|
@@ -203,10 +266,60 @@ module Langfuse
|
|
|
203
266
|
|
|
204
267
|
private
|
|
205
268
|
|
|
206
|
-
#
|
|
269
|
+
# Discard parent-owned queued work and restore the child process timer.
|
|
270
|
+
def reset_after_fork
|
|
271
|
+
inherited_shutdown = @shutdown
|
|
272
|
+
@queue = PendingScoreQueue.new(capacity: config.score_queue_capacity)
|
|
273
|
+
@shutdown_mutex = Mutex.new
|
|
274
|
+
@flush_mutex = Mutex.new
|
|
275
|
+
@flush_thread = nil
|
|
276
|
+
@shutdown = inherited_shutdown
|
|
277
|
+
start_flush_timer unless @shutdown
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def enqueue_score_event(event)
|
|
281
|
+
unless @queue.push(event)
|
|
282
|
+
logger.error(
|
|
283
|
+
"Langfuse score queue is full (capacity=#{@queue.capacity}); dropping new asynchronous score"
|
|
284
|
+
)
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
flush if @queue.size >= config.batch_size
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def flush_pending_batches
|
|
292
|
+
loop do
|
|
293
|
+
batch = next_batch
|
|
294
|
+
break if batch.empty?
|
|
295
|
+
|
|
296
|
+
delivery = send_batch(batch)
|
|
297
|
+
break if delivery == :retry
|
|
298
|
+
|
|
299
|
+
@queue.remove_prefix(batch.length)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def next_batch
|
|
304
|
+
batch = []
|
|
305
|
+
payload_bytes = EMPTY_BATCH_PAYLOAD_BYTES
|
|
306
|
+
|
|
307
|
+
@queue.first(config.batch_size).each do |event|
|
|
308
|
+
event_json = JSON.generate(event)
|
|
309
|
+
additional_bytes = event_json.bytesize + (batch.empty? ? 0 : 1)
|
|
310
|
+
break if batch.any? && payload_bytes + additional_bytes > MAX_BATCH_PAYLOAD_BYTES
|
|
311
|
+
|
|
312
|
+
batch << event
|
|
313
|
+
payload_bytes += additional_bytes
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
batch
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Validate score inputs and build the canonical API body.
|
|
207
320
|
#
|
|
208
321
|
# @param name [String] Score name
|
|
209
|
-
# @param value [
|
|
322
|
+
# @param value [Numeric, Integer, String] Raw score value (type depends on data_type)
|
|
210
323
|
# @param id [String, nil] Score ID
|
|
211
324
|
# @param trace_id [String, nil] Trace ID
|
|
212
325
|
# @param session_id [String, nil] Session ID
|
|
@@ -214,66 +327,54 @@ module Langfuse
|
|
|
214
327
|
# @param comment [String, nil] Comment
|
|
215
328
|
# @param metadata [Hash, nil] Metadata
|
|
216
329
|
# @param environment [String, nil] Environment
|
|
217
|
-
# @param data_type [
|
|
218
|
-
# @
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
330
|
+
# @param data_type [Symbol] Data type (:numeric, :boolean, :categorical, :text, :correction)
|
|
331
|
+
# @param dataset_run_id [String, nil] Dataset run ID
|
|
332
|
+
# @param config_id [String, nil] Score config ID
|
|
333
|
+
# @return [Hash] Score attributes in API format
|
|
334
|
+
# @raise [ArgumentError] if validation fails
|
|
335
|
+
# rubocop:disable Metrics/ParameterLists
|
|
336
|
+
def build_score_body(name:, value:, id:, trace_id:, session_id:, observation_id:, comment:, metadata:,
|
|
337
|
+
environment:, data_type:, dataset_run_id: nil, config_id: nil)
|
|
338
|
+
validate_name(name)
|
|
339
|
+
normalized_value = ScoreValue.normalize(value, data_type)
|
|
340
|
+
data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
|
|
341
|
+
validate_correction_subject!(data_type:, trace_id:, session_id:, dataset_run_id:, config_id:)
|
|
342
|
+
|
|
343
|
+
snapshot_score_body(
|
|
223
344
|
id: id || SecureRandom.uuid,
|
|
224
345
|
name: name,
|
|
225
|
-
value:
|
|
226
|
-
dataType:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
346
|
+
value: normalized_value,
|
|
347
|
+
dataType: data_type_str,
|
|
348
|
+
traceId: trace_id,
|
|
349
|
+
sessionId: session_id,
|
|
350
|
+
observationId: observation_id,
|
|
351
|
+
comment: comment,
|
|
352
|
+
metadata: metadata,
|
|
353
|
+
environment: environment || config.environment,
|
|
354
|
+
datasetRunId: dataset_run_id,
|
|
355
|
+
configId: config_id
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
# rubocop:enable Metrics/ParameterLists
|
|
236
359
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
360
|
+
# Snapshot the wire body so caller mutation cannot poison the pending queue.
|
|
361
|
+
def snapshot_score_body(attributes)
|
|
362
|
+
JSON.parse(JSON.generate(attributes.compact), symbolize_names: true)
|
|
363
|
+
rescue JSON::JSONError => e
|
|
364
|
+
raise ArgumentError, "Score data must be JSON-serializable: #{e.message}"
|
|
243
365
|
end
|
|
244
|
-
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
245
366
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
# @param data_type [Symbol] Data type symbol
|
|
250
|
-
# @return [Object] Normalized value
|
|
251
|
-
# @raise [ArgumentError] if value doesn't match data type
|
|
252
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
253
|
-
def normalize_value(value, data_type)
|
|
254
|
-
case data_type
|
|
255
|
-
when :numeric
|
|
256
|
-
raise ArgumentError, "Numeric value must be Numeric, got #{value.class}" unless value.is_a?(Numeric)
|
|
257
|
-
|
|
258
|
-
value
|
|
259
|
-
when :boolean
|
|
260
|
-
case value
|
|
261
|
-
when true, 1
|
|
262
|
-
1
|
|
263
|
-
when false, 0
|
|
264
|
-
0
|
|
265
|
-
else
|
|
266
|
-
raise ArgumentError, "Boolean value must be true/false or 0/1, got #{value.inspect}"
|
|
267
|
-
end
|
|
268
|
-
when :categorical
|
|
269
|
-
raise ArgumentError, "Categorical value must be a String, got #{value.class}" unless value.is_a?(String)
|
|
367
|
+
def build_score_event(score)
|
|
368
|
+
{ id: SecureRandom.uuid, type: "score-create", timestamp: Time.now.utc.iso8601(3), body: score }
|
|
369
|
+
end
|
|
270
370
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
371
|
+
def validate_correction_subject!(data_type:, trace_id:, session_id:, dataset_run_id:, config_id:)
|
|
372
|
+
return unless data_type == :correction
|
|
373
|
+
return if trace_id.is_a?(String) && !trace_id.empty? && !session_id && !dataset_run_id && !config_id
|
|
374
|
+
|
|
375
|
+
raise ArgumentError,
|
|
376
|
+
"Correction scores require trace_id and cannot use session_id, dataset_run_id, or config_id"
|
|
275
377
|
end
|
|
276
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
|
277
378
|
|
|
278
379
|
# Validate score name
|
|
279
380
|
#
|
|
@@ -330,12 +431,31 @@ module Langfuse
|
|
|
330
431
|
# Send a batch of events to the API
|
|
331
432
|
#
|
|
332
433
|
# @param events [Array<Hash>] Array of event hashes
|
|
333
|
-
# @return [
|
|
434
|
+
# @return [Symbol] :delivered, :discarded, or :retry
|
|
334
435
|
def send_batch(events)
|
|
335
436
|
api_client.send_batch(events)
|
|
437
|
+
:delivered
|
|
438
|
+
rescue BatchDeliveryError => e
|
|
439
|
+
return retry_batch(e) if e.retryable?
|
|
440
|
+
|
|
441
|
+
discard_batch(events, e)
|
|
442
|
+
rescue UnauthorizedError => e
|
|
443
|
+
retry_batch(e)
|
|
336
444
|
rescue StandardError => e
|
|
337
445
|
logger.error("Langfuse score batch send failed: #{e.message}")
|
|
338
|
-
|
|
446
|
+
:retry
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def retry_batch(error)
|
|
450
|
+
logger.error("Langfuse score batch send failed and will retry: #{error.message}")
|
|
451
|
+
:retry
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def discard_batch(events, error)
|
|
455
|
+
logger.error(
|
|
456
|
+
"Langfuse dropped #{events.length} score event(s) after a permanent batch failure: #{error.message}"
|
|
457
|
+
)
|
|
458
|
+
:discarded
|
|
339
459
|
end
|
|
340
460
|
|
|
341
461
|
# Start the background flush timer thread
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Normalizes score values to the wire representation expected by Langfuse.
|
|
5
|
+
# @api private
|
|
6
|
+
module ScoreValue
|
|
7
|
+
TEXT_LENGTH_RANGE = (1..500)
|
|
8
|
+
private_constant :TEXT_LENGTH_RANGE
|
|
9
|
+
|
|
10
|
+
# @param value [Object] raw score value
|
|
11
|
+
# @param data_type [Symbol] score data type
|
|
12
|
+
# @return [Numeric, String] normalized score value
|
|
13
|
+
# @raise [ArgumentError] if the value does not match the data type
|
|
14
|
+
def self.normalize(value, data_type)
|
|
15
|
+
case data_type
|
|
16
|
+
when :numeric then numeric(value)
|
|
17
|
+
when :boolean then boolean(value)
|
|
18
|
+
when :categorical then string(value, "Categorical")
|
|
19
|
+
when :text then text(value)
|
|
20
|
+
when :correction then string(value, "Correction")
|
|
21
|
+
else raise ArgumentError, "Invalid data_type: #{data_type}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.numeric(value)
|
|
26
|
+
raise ArgumentError, "Numeric value must be Numeric, got #{value.class}" unless value.is_a?(Numeric)
|
|
27
|
+
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
private_class_method :numeric
|
|
31
|
+
|
|
32
|
+
def self.boolean(value)
|
|
33
|
+
case value
|
|
34
|
+
when true, 1 then 1
|
|
35
|
+
when false, 0 then 0
|
|
36
|
+
else raise ArgumentError, "Boolean value must be true/false or 0/1, got #{value.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
private_class_method :boolean
|
|
40
|
+
|
|
41
|
+
def self.text(value)
|
|
42
|
+
string(value, "Text")
|
|
43
|
+
unless TEXT_LENGTH_RANGE.cover?(value.length)
|
|
44
|
+
raise ArgumentError, "Text value must contain 1 to 500 characters, got #{value.length}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
private_class_method :text
|
|
50
|
+
|
|
51
|
+
def self.string(value, label)
|
|
52
|
+
raise ArgumentError, "#{label} value must be a String, got #{value.class}" unless value.is_a?(String)
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
private_class_method :string
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "opentelemetry/sdk"
|
|
4
|
+
require_relative "resilient_metrics_reporter"
|
|
4
5
|
|
|
5
6
|
module Langfuse
|
|
6
7
|
# Batch span processor that owns Langfuse's enrichment and export filtering.
|
|
@@ -13,12 +14,14 @@ module Langfuse
|
|
|
13
14
|
@logger = config.logger
|
|
14
15
|
@default_trace_attributes = build_default_trace_attributes(config).freeze
|
|
15
16
|
@should_export_span = config.should_export_span || Langfuse.method(:default_export_span?)
|
|
17
|
+
@app_root_tracker = AppRootTracking::Tracker.new
|
|
16
18
|
|
|
17
19
|
super(
|
|
18
20
|
exporter,
|
|
19
21
|
max_queue_size: config.batch_size * 2,
|
|
20
22
|
schedule_delay: schedule_delay_for(config),
|
|
21
|
-
max_export_batch_size: config.batch_size
|
|
23
|
+
max_export_batch_size: config.batch_size,
|
|
24
|
+
metrics_reporter: ResilientMetricsReporter.wrap(config.metrics_reporter, logger: config.logger)
|
|
22
25
|
)
|
|
23
26
|
end
|
|
24
27
|
|
|
@@ -32,6 +35,7 @@ module Langfuse
|
|
|
32
35
|
|
|
33
36
|
apply_attributes(span, @default_trace_attributes)
|
|
34
37
|
apply_attributes(span, propagated_attributes(parent_context))
|
|
38
|
+
remember_app_root_state(span, parent_context)
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
# Drop spans when the export filter rejects them or raises.
|
|
@@ -39,9 +43,8 @@ module Langfuse
|
|
|
39
43
|
# @param span [OpenTelemetry::SDK::Trace::Span] The span that ended
|
|
40
44
|
# @return [void]
|
|
41
45
|
def on_finish(span)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
super
|
|
46
|
+
ready_spans = @app_root_tracker.finish(span, exportable: should_export_span?(span))
|
|
47
|
+
ready_spans.each { |ready_span| super(copy_with_app_root(ready_span)) }
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
private
|
|
@@ -51,6 +54,23 @@ module Langfuse
|
|
|
51
54
|
SYNC_SCHEDULE_DELAY_MS = 60_000
|
|
52
55
|
private_constant :SYNC_SCHEDULE_DELAY_MS
|
|
53
56
|
|
|
57
|
+
ExportSpan = Struct.new(:span, :attributes, keyword_init: true) do
|
|
58
|
+
def context
|
|
59
|
+
span.context
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_span_data
|
|
63
|
+
span.to_span_data.tap do |span_data|
|
|
64
|
+
span_data.attributes = attributes
|
|
65
|
+
span_data.total_recorded_attributes = [
|
|
66
|
+
span_data.total_recorded_attributes,
|
|
67
|
+
attributes.size
|
|
68
|
+
].max
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_constant :ExportSpan
|
|
73
|
+
|
|
54
74
|
def schedule_delay_for(config)
|
|
55
75
|
config.tracing_async ? config.flush_interval * 1000 : SYNC_SCHEDULE_DELAY_MS
|
|
56
76
|
end
|
|
@@ -71,6 +91,35 @@ module Langfuse
|
|
|
71
91
|
attributes.each { |key, value| span.set_attribute(key, value) }
|
|
72
92
|
end
|
|
73
93
|
|
|
94
|
+
def remember_app_root_state(span, parent_context)
|
|
95
|
+
propagated_trace_id = Propagation._get_langfuse_trace_id_from_baggage(parent_context)
|
|
96
|
+
trace_claimed = propagated_trace_id == span.context.trace_id.unpack1("H*")
|
|
97
|
+
@app_root_tracker.remember(
|
|
98
|
+
span,
|
|
99
|
+
trace_claimed: trace_claimed,
|
|
100
|
+
untracked_parent_root_eligible: AppRootTracking.eligible_without_tracked_parent?(span)
|
|
101
|
+
)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
@logger.error(
|
|
104
|
+
"Langfuse app-root tracking failed for span '#{span.name}'. The span will not export: " \
|
|
105
|
+
"#{e.class}: #{e.message}"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def copy_with_app_root(ready_span)
|
|
110
|
+
span = ready_span.span
|
|
111
|
+
attributes = span.attributes || {}
|
|
112
|
+
return span if (attributes[OtelAttributes::IS_APP_ROOT] == true) == ready_span.app_root
|
|
113
|
+
|
|
114
|
+
copied_attributes = attributes.dup
|
|
115
|
+
if ready_span.app_root
|
|
116
|
+
copied_attributes[OtelAttributes::IS_APP_ROOT] = true
|
|
117
|
+
else
|
|
118
|
+
copied_attributes.delete(OtelAttributes::IS_APP_ROOT)
|
|
119
|
+
end
|
|
120
|
+
ExportSpan.new(span: span, attributes: copied_attributes.freeze)
|
|
121
|
+
end
|
|
122
|
+
|
|
74
123
|
def should_export_span?(span)
|
|
75
124
|
@should_export_span.call(span)
|
|
76
125
|
rescue StandardError => e
|
|
@@ -174,13 +174,12 @@ module Langfuse
|
|
|
174
174
|
# @param key [String] Cache key
|
|
175
175
|
# @yield Block to execute to fetch fresh data
|
|
176
176
|
# @return [void]
|
|
177
|
-
# rubocop:disable Naming/PredicateMethod
|
|
178
177
|
def schedule_refresh(key, ttl: nil, stale_ttl: nil, on_success: nil, on_failure: nil, &block)
|
|
179
178
|
# Prevent duplicate refreshes
|
|
180
179
|
lock_key = build_lock_key(key)
|
|
181
180
|
return false unless acquire_lock(lock_key)
|
|
182
181
|
|
|
183
|
-
@thread_pool.post do
|
|
182
|
+
scheduled = @thread_pool.post do
|
|
184
183
|
value = block.call
|
|
185
184
|
set_cache_entry(key, value, ttl: ttl, stale_ttl: stale_ttl)
|
|
186
185
|
on_success&.call(value)
|
|
@@ -191,9 +190,9 @@ module Langfuse
|
|
|
191
190
|
release_lock(lock_key)
|
|
192
191
|
end
|
|
193
192
|
|
|
194
|
-
|
|
193
|
+
release_lock(lock_key) unless scheduled
|
|
194
|
+
scheduled
|
|
195
195
|
end
|
|
196
|
-
# rubocop:enable Naming/PredicateMethod
|
|
197
196
|
|
|
198
197
|
# Fetch data and cache it with SWR metadata
|
|
199
198
|
#
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "prompt_renderer"
|
|
4
|
+
require_relative "prompt_variables"
|
|
4
5
|
|
|
5
6
|
module Langfuse
|
|
6
7
|
# Text prompt client for compiling text prompts with variable substitution
|
|
@@ -11,7 +12,7 @@ module Langfuse
|
|
|
11
12
|
# @example Basic usage
|
|
12
13
|
# prompt_data = api_client.get_prompt("greeting")
|
|
13
14
|
# text_prompt = Langfuse::TextPromptClient.new(prompt_data)
|
|
14
|
-
# text_prompt.compile(
|
|
15
|
+
# text_prompt.compile(name: "Alice")
|
|
15
16
|
# # => "Hello Alice!"
|
|
16
17
|
#
|
|
17
18
|
# @example Accessing metadata
|
|
@@ -35,7 +36,7 @@ module Langfuse
|
|
|
35
36
|
# @return [Hash] Prompt configuration
|
|
36
37
|
attr_reader :config
|
|
37
38
|
|
|
38
|
-
# @return [String] Raw prompt template
|
|
39
|
+
# @return [String] Raw prompt template
|
|
39
40
|
attr_reader :prompt
|
|
40
41
|
|
|
41
42
|
# @return [String, nil] Optional commit message for this prompt version
|
|
@@ -71,10 +72,22 @@ module Langfuse
|
|
|
71
72
|
"text"
|
|
72
73
|
end
|
|
73
74
|
|
|
75
|
+
# Return the unique variables referenced by the prompt template
|
|
76
|
+
#
|
|
77
|
+
# Section names are included because callers must provide their values.
|
|
78
|
+
# Variables inside sections include the full section path.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<String>] Referenced variable names in source order
|
|
81
|
+
# @raise [Mustache::Parser::SyntaxError] if the prompt contains invalid Mustache syntax
|
|
82
|
+
def variables
|
|
83
|
+
PromptVariables.extract(prompt)
|
|
84
|
+
end
|
|
85
|
+
|
|
74
86
|
# Compile the prompt with variable substitution
|
|
75
87
|
#
|
|
76
88
|
# @param kwargs [Hash] Variables to substitute in the template (as keyword arguments)
|
|
77
89
|
# @return [String] The compiled prompt text
|
|
90
|
+
# @raise [ArgumentError] if variables cannot be rendered
|
|
78
91
|
#
|
|
79
92
|
# @example
|
|
80
93
|
# text_prompt.compile(name: "Alice", greeting: "Hi")
|
|
@@ -87,10 +100,6 @@ module Langfuse
|
|
|
87
100
|
|
|
88
101
|
private
|
|
89
102
|
|
|
90
|
-
# Validate prompt data structure
|
|
91
|
-
#
|
|
92
|
-
# @param prompt_data [Hash] The prompt data to validate
|
|
93
|
-
# @raise [ArgumentError] if validation fails
|
|
94
103
|
def validate_prompt_data!(prompt_data)
|
|
95
104
|
raise ArgumentError, "prompt_data must be a Hash" unless prompt_data.is_a?(Hash)
|
|
96
105
|
raise ArgumentError, "prompt_data must include 'prompt' field" unless prompt_data.key?("prompt")
|