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
|
@@ -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
|
|
172
|
-
|
|
173
|
-
events = []
|
|
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?
|
|
244
|
+
return unless config.telemetry_enabled?
|
|
182
245
|
|
|
183
|
-
|
|
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,65 +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
|
-
# @
|
|
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
|
|
219
335
|
# rubocop:disable Metrics/ParameterLists
|
|
220
|
-
def
|
|
221
|
-
|
|
222
|
-
|
|
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:
|
|
346
|
+
value: normalized_value,
|
|
347
|
+
dataType: data_type_str,
|
|
227
348
|
traceId: trace_id,
|
|
228
349
|
sessionId: session_id,
|
|
229
350
|
observationId: observation_id,
|
|
230
351
|
comment: comment,
|
|
231
352
|
metadata: metadata,
|
|
232
|
-
environment: environment,
|
|
353
|
+
environment: environment || config.environment,
|
|
233
354
|
datasetRunId: dataset_run_id,
|
|
234
355
|
configId: config_id
|
|
235
|
-
|
|
236
|
-
{
|
|
237
|
-
id: SecureRandom.uuid,
|
|
238
|
-
type: "score-create",
|
|
239
|
-
timestamp: Time.now.utc.iso8601(3),
|
|
240
|
-
body: body
|
|
241
|
-
}
|
|
356
|
+
)
|
|
242
357
|
end
|
|
243
358
|
# rubocop:enable Metrics/ParameterLists
|
|
244
359
|
|
|
245
|
-
#
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
252
|
-
def normalize_value(value, data_type)
|
|
253
|
-
case data_type
|
|
254
|
-
when :numeric
|
|
255
|
-
raise ArgumentError, "Numeric value must be Numeric, got #{value.class}" unless value.is_a?(Numeric)
|
|
256
|
-
|
|
257
|
-
value
|
|
258
|
-
when :boolean
|
|
259
|
-
case value
|
|
260
|
-
when true, 1
|
|
261
|
-
1
|
|
262
|
-
when false, 0
|
|
263
|
-
0
|
|
264
|
-
else
|
|
265
|
-
raise ArgumentError, "Boolean value must be true/false or 0/1, got #{value.inspect}"
|
|
266
|
-
end
|
|
267
|
-
when :categorical
|
|
268
|
-
raise ArgumentError, "Categorical value must be a String, got #{value.class}" unless value.is_a?(String)
|
|
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}"
|
|
365
|
+
end
|
|
269
366
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
367
|
+
def build_score_event(score)
|
|
368
|
+
{ id: SecureRandom.uuid, type: "score-create", timestamp: Time.now.utc.iso8601(3), body: score }
|
|
369
|
+
end
|
|
370
|
+
|
|
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"
|
|
274
377
|
end
|
|
275
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
|
276
378
|
|
|
277
379
|
# Validate score name
|
|
278
380
|
#
|
|
@@ -329,12 +431,31 @@ module Langfuse
|
|
|
329
431
|
# Send a batch of events to the API
|
|
330
432
|
#
|
|
331
433
|
# @param events [Array<Hash>] Array of event hashes
|
|
332
|
-
# @return [
|
|
434
|
+
# @return [Symbol] :delivered, :discarded, or :retry
|
|
333
435
|
def send_batch(events)
|
|
334
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)
|
|
335
444
|
rescue StandardError => e
|
|
336
445
|
logger.error("Langfuse score batch send failed: #{e.message}")
|
|
337
|
-
|
|
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
|
|
338
459
|
end
|
|
339
460
|
|
|
340
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
|
|
@@ -71,6 +72,17 @@ 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)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Stops trace export while the live telemetry kill switch is disabled.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class TraceExportGuard
|
|
10
|
+
SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
11
|
+
private_constant :SUCCESS
|
|
12
|
+
|
|
13
|
+
# @param delegate [#export, #force_flush, #shutdown] OpenTelemetry span exporter
|
|
14
|
+
# @param config [Langfuse::Config] Live SDK configuration
|
|
15
|
+
def initialize(delegate:, config:)
|
|
16
|
+
@delegate = delegate
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Export spans only while trace export is enabled.
|
|
21
|
+
#
|
|
22
|
+
# @param span_data [Enumerable<OpenTelemetry::SDK::Trace::SpanData>]
|
|
23
|
+
# @param timeout [Numeric, nil]
|
|
24
|
+
# @return [Integer] OpenTelemetry export result code
|
|
25
|
+
def export(span_data, timeout: nil)
|
|
26
|
+
return SUCCESS unless @config.trace_export_enabled?
|
|
27
|
+
|
|
28
|
+
@delegate.export(span_data, timeout: timeout)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param timeout [Numeric, nil]
|
|
32
|
+
# @return [Object] delegate result or OpenTelemetry success
|
|
33
|
+
def force_flush(timeout: nil)
|
|
34
|
+
return SUCCESS unless @config.trace_export_enabled?
|
|
35
|
+
|
|
36
|
+
@delegate.force_flush(timeout: timeout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Release exporter resources after the guarded processor drains its queue.
|
|
40
|
+
#
|
|
41
|
+
# @param timeout [Numeric, nil]
|
|
42
|
+
# @return [Object] delegate shutdown result
|
|
43
|
+
def shutdown(timeout: nil)
|
|
44
|
+
@delegate.shutdown(timeout: timeout)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|