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.
@@ -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?
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
- 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,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 [String] Data type string (NUMERIC, BOOLEAN, CATEGORICAL)
218
- # @return [Hash] Event hash
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 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 = {
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,
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
- }.compact
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
- # Normalize and validate score value based on data type
246
- #
247
- # @param value [Object] Raw score value
248
- # @param data_type [Symbol] Data type symbol
249
- # @return [Object] Normalized value
250
- # @raise [ArgumentError] if value doesn't match data type
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
- value
271
- else
272
- raise ArgumentError, "Invalid data_type: #{data_type}"
273
- end
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 [void]
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
- # 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
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
- 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
@@ -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