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,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 = Queue.new
45
- @mutex = Mutex.new
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
- validate_name(name)
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
- return unless enqueue_trace_linked_score?(trace_id)
88
-
89
- event = build_score_event(
90
- name: name, value: normalized_value, id: id, trace_id: trace_id,
91
- session_id: session_id, observation_id: observation_id, comment: comment,
92
- metadata: metadata, environment: environment, data_type: data_type_str,
93
- dataset_run_id: dataset_run_id, config_id: config_id
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
- @queue << event
97
- flush if @queue.size >= config.batch_size
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 if @queue.empty?
244
+ return unless config.telemetry_enabled?
172
245
 
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?
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
- @mutex.synchronize do
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
- # Build a score event hash for ingestion API
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 [Object] Normalized score 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 [String] Data type string (NUMERIC, BOOLEAN, CATEGORICAL)
218
- # @return [Hash] Event hash
219
- # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
220
- def build_score_event(name:, value:, id:, trace_id:, session_id:, observation_id:, comment:, metadata:,
221
- environment:, data_type:, dataset_run_id: nil, config_id: nil)
222
- body = {
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: value,
226
- dataType: data_type
227
- }
228
- body[:traceId] = trace_id if trace_id
229
- body[:sessionId] = session_id if session_id
230
- body[:observationId] = observation_id if observation_id
231
- body[:comment] = comment if comment
232
- body[:metadata] = metadata if metadata
233
- body[:environment] = environment if environment
234
- body[:datasetRunId] = dataset_run_id if dataset_run_id
235
- body[:configId] = config_id if config_id
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
- id: SecureRandom.uuid,
239
- type: "score-create",
240
- timestamp: Time.now.utc.iso8601(3),
241
- body: body
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
- # Normalize and validate score value based on data type
247
- #
248
- # @param value [Object] Raw score value
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
- value
272
- else
273
- raise ArgumentError, "Invalid data_type: #{data_type}"
274
- end
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 [void]
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
- # Don't raise - silent error handling
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
- return unless should_export_span?(span)
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
- true
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(variables: { name: "Alice" })
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 string
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")