langfuse-rb 0.1.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.
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "opentelemetry/trace"
5
+
6
+ module Langfuse
7
+ # Client for creating and batching Langfuse scores
8
+ #
9
+ # Handles thread-safe queuing, batching, and sending of score events
10
+ # to the Langfuse ingestion API. Scores are batched and sent automatically
11
+ # based on batch_size and flush_interval configuration.
12
+ #
13
+ # @example Basic usage
14
+ # score_client = ScoreClient.new(api_client: api_client, config: config)
15
+ # score_client.create(name: "quality", value: 0.85, trace_id: "abc123...")
16
+ #
17
+ # @example With OTel integration
18
+ # Langfuse.observe("operation") do |obs|
19
+ # score_client.score_active_observation(name: "accuracy", value: 0.92)
20
+ # end
21
+ #
22
+ # @api private
23
+ # rubocop:disable Metrics/ClassLength
24
+ class ScoreClient
25
+ attr_reader :api_client, :config, :logger
26
+
27
+ # Initialize a new ScoreClient
28
+ #
29
+ # @param api_client [ApiClient] The API client for sending batches
30
+ # @param config [Config] Configuration object with batch_size and flush_interval
31
+ def initialize(api_client:, config:)
32
+ @api_client = api_client
33
+ @config = config
34
+ @logger = config.logger
35
+ @queue = Queue.new
36
+ @mutex = Mutex.new
37
+ @flush_thread = nil
38
+ @shutdown = false
39
+
40
+ start_flush_timer
41
+ end
42
+
43
+ # Create a score event and queue it for batching
44
+ #
45
+ # @param name [String] Score name (required)
46
+ # @param value [Numeric, Integer, String] Score value (type depends on data_type)
47
+ # @param trace_id [String, nil] Trace ID to associate with the score
48
+ # @param observation_id [String, nil] Observation ID to associate with the score
49
+ # @param comment [String, nil] Optional comment
50
+ # @param metadata [Hash, nil] Optional metadata hash
51
+ # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
52
+ # @return [void]
53
+ # @raise [ArgumentError] if validation fails
54
+ #
55
+ # @example Numeric score
56
+ # create(name: "quality", value: 0.85, trace_id: "abc123", data_type: :numeric)
57
+ #
58
+ # @example Boolean score
59
+ # create(name: "passed", value: true, trace_id: "abc123", data_type: :boolean)
60
+ #
61
+ # @example Categorical score
62
+ # create(name: "category", value: "high", trace_id: "abc123", data_type: :categorical)
63
+ # rubocop:disable Metrics/ParameterLists
64
+ def create(name:, value:, trace_id: nil, observation_id: nil, comment: nil, metadata: nil,
65
+ data_type: :numeric)
66
+ validate_name(name)
67
+ normalized_value = normalize_value(value, data_type)
68
+ data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
69
+
70
+ event = build_score_event(
71
+ name: name,
72
+ value: normalized_value,
73
+ trace_id: trace_id,
74
+ observation_id: observation_id,
75
+ comment: comment,
76
+ metadata: metadata,
77
+ data_type: data_type_str
78
+ )
79
+
80
+ @queue << event
81
+
82
+ # Trigger flush if batch size reached
83
+ flush if @queue.size >= config.batch_size
84
+ rescue StandardError => e
85
+ logger.error("Langfuse score creation failed: #{e.message}")
86
+ raise
87
+ end
88
+ # rubocop:enable Metrics/ParameterLists
89
+
90
+ # Create a score for the currently active observation (from OTel span)
91
+ #
92
+ # Extracts observation_id and trace_id from the active OpenTelemetry span.
93
+ #
94
+ # @param name [String] Score name (required)
95
+ # @param value [Numeric, Integer, String] Score value
96
+ # @param comment [String, nil] Optional comment
97
+ # @param metadata [Hash, nil] Optional metadata hash
98
+ # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
99
+ # @return [void]
100
+ # @raise [ArgumentError] if no active span or validation fails
101
+ #
102
+ # @example
103
+ # Langfuse.observe("operation") do |obs|
104
+ # score_client.score_active_observation(name: "accuracy", value: 0.92)
105
+ # end
106
+ def score_active_observation(name:, value:, comment: nil, metadata: nil, data_type: :numeric)
107
+ ids = extract_ids_from_active_span
108
+ raise ArgumentError, "No active OpenTelemetry span found" unless ids[:observation_id]
109
+
110
+ create(
111
+ name: name,
112
+ value: value,
113
+ trace_id: ids[:trace_id],
114
+ observation_id: ids[:observation_id],
115
+ comment: comment,
116
+ metadata: metadata,
117
+ data_type: data_type
118
+ )
119
+ end
120
+
121
+ # Create a score for the currently active trace (from OTel span)
122
+ #
123
+ # Extracts trace_id from the active OpenTelemetry span.
124
+ #
125
+ # @param name [String] Score name (required)
126
+ # @param value [Numeric, Integer, String] Score value
127
+ # @param comment [String, nil] Optional comment
128
+ # @param metadata [Hash, nil] Optional metadata hash
129
+ # @param data_type [Symbol] Data type (:numeric, :boolean, :categorical)
130
+ # @return [void]
131
+ # @raise [ArgumentError] if no active span or validation fails
132
+ #
133
+ # @example
134
+ # Langfuse.observe("operation") do |obs|
135
+ # score_client.score_active_trace(name: "overall_quality", value: 5)
136
+ # end
137
+ def score_active_trace(name:, value:, comment: nil, metadata: nil, data_type: :numeric)
138
+ ids = extract_ids_from_active_span
139
+ raise ArgumentError, "No active OpenTelemetry span found" unless ids[:trace_id]
140
+
141
+ create(
142
+ name: name,
143
+ value: value,
144
+ trace_id: ids[:trace_id],
145
+ comment: comment,
146
+ metadata: metadata,
147
+ data_type: data_type
148
+ )
149
+ end
150
+
151
+ # Force flush all queued score events
152
+ #
153
+ # Sends all queued events to the API immediately.
154
+ #
155
+ # @return [void]
156
+ def flush
157
+ return if @queue.empty?
158
+
159
+ events = []
160
+ @queue.size.times do
161
+ events << @queue.pop(true)
162
+ rescue StandardError
163
+ nil
164
+ end
165
+ events.compact!
166
+
167
+ return if events.empty?
168
+
169
+ send_batch(events)
170
+ rescue StandardError => e
171
+ logger.error("Langfuse score flush failed: #{e.message}")
172
+ # Don't raise - silent error handling for batch operations
173
+ end
174
+
175
+ # Shutdown the score client and flush remaining events
176
+ #
177
+ # Stops the flush timer thread and sends any remaining queued events.
178
+ #
179
+ # @return [void]
180
+ def shutdown
181
+ @mutex.synchronize do
182
+ return if @shutdown
183
+
184
+ @shutdown = true
185
+ stop_flush_timer
186
+ flush
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ # Build a score event hash for ingestion API
193
+ #
194
+ # @param name [String] Score name
195
+ # @param value [Object] Normalized score value
196
+ # @param trace_id [String, nil] Trace ID
197
+ # @param observation_id [String, nil] Observation ID
198
+ # @param comment [String, nil] Comment
199
+ # @param metadata [Hash, nil] Metadata
200
+ # @param data_type [String] Data type string (NUMERIC, BOOLEAN, CATEGORICAL)
201
+ # @return [Hash] Event hash
202
+ # rubocop:disable Metrics/ParameterLists
203
+ def build_score_event(name:, value:, trace_id:, observation_id:, comment:, metadata:, data_type:)
204
+ body = {
205
+ id: SecureRandom.uuid,
206
+ name: name,
207
+ value: value,
208
+ dataType: data_type
209
+ }
210
+ body[:traceId] = trace_id if trace_id
211
+ body[:observationId] = observation_id if observation_id
212
+ body[:comment] = comment if comment
213
+ body[:metadata] = metadata if metadata
214
+
215
+ {
216
+ id: SecureRandom.uuid,
217
+ type: "score-create",
218
+ timestamp: Time.now.utc.iso8601(3),
219
+ body: body
220
+ }
221
+ end
222
+ # rubocop:enable Metrics/ParameterLists
223
+
224
+ # Normalize and validate score value based on data type
225
+ #
226
+ # @param value [Object] Raw score value
227
+ # @param data_type [Symbol] Data type symbol
228
+ # @return [Object] Normalized value
229
+ # @raise [ArgumentError] if value doesn't match data type
230
+ # rubocop:disable Metrics/CyclomaticComplexity
231
+ def normalize_value(value, data_type)
232
+ case data_type
233
+ when :numeric
234
+ raise ArgumentError, "Numeric value must be Numeric, got #{value.class}" unless value.is_a?(Numeric)
235
+
236
+ value
237
+ when :boolean
238
+ case value
239
+ when true, 1
240
+ 1
241
+ when false, 0
242
+ 0
243
+ else
244
+ raise ArgumentError, "Boolean value must be true/false or 0/1, got #{value.inspect}"
245
+ end
246
+ when :categorical
247
+ raise ArgumentError, "Categorical value must be a String, got #{value.class}" unless value.is_a?(String)
248
+
249
+ value
250
+ else
251
+ raise ArgumentError, "Invalid data_type: #{data_type}"
252
+ end
253
+ end
254
+ # rubocop:enable Metrics/CyclomaticComplexity
255
+
256
+ # Validate score name
257
+ #
258
+ # @param name [String] Score name
259
+ # @raise [ArgumentError] if name is invalid
260
+ def validate_name(name)
261
+ raise ArgumentError, "name is required" if name.nil?
262
+ raise ArgumentError, "name must be a String" unless name.is_a?(String)
263
+ raise ArgumentError, "name is required" if name.empty?
264
+ end
265
+
266
+ # Extract trace_id and observation_id from active OTel span
267
+ #
268
+ # @return [Hash] Hash with :trace_id and :observation_id (may be nil)
269
+ def extract_ids_from_active_span
270
+ span = OpenTelemetry::Trace.current_span
271
+ return { trace_id: nil, observation_id: nil } unless span&.recording?
272
+
273
+ {
274
+ trace_id: span.context.trace_id.unpack1("H*"),
275
+ observation_id: span.context.span_id.unpack1("H*")
276
+ }
277
+ end
278
+
279
+ # Send a batch of events to the API
280
+ #
281
+ # @param events [Array<Hash>] Array of event hashes
282
+ # @return [void]
283
+ def send_batch(events)
284
+ api_client.send_batch(events)
285
+ rescue StandardError => e
286
+ logger.error("Langfuse score batch send failed: #{e.message}")
287
+ # Don't raise - silent error handling
288
+ end
289
+
290
+ # Start the background flush timer thread
291
+ #
292
+ # @return [void]
293
+ def start_flush_timer
294
+ return if config.flush_interval.nil? || config.flush_interval <= 0
295
+
296
+ @flush_thread = Thread.new do
297
+ loop do
298
+ sleep(config.flush_interval)
299
+ break if @shutdown
300
+
301
+ flush
302
+ rescue StandardError => e
303
+ logger.error("Langfuse score flush timer error: #{e.message}")
304
+ end
305
+ end
306
+ @flush_thread.abort_on_exception = false
307
+ @flush_thread.name = "langfuse-score-flush"
308
+ end
309
+
310
+ # Stop the flush timer thread
311
+ #
312
+ # @return [void]
313
+ def stop_flush_timer
314
+ return unless @flush_thread&.alive?
315
+
316
+ @flush_thread.kill
317
+ @flush_thread.join(1) # Wait up to 1 second for thread to finish
318
+ end
319
+ end
320
+ end
321
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ module Langfuse
6
+ # Span processor that automatically sets propagated attributes on new spans.
7
+ #
8
+ # This processor reads propagated attributes from OpenTelemetry context and
9
+ # sets them on spans when they are created. This ensures that attributes set
10
+ # via `propagate_attributes` are automatically applied to all child spans.
11
+ #
12
+ # @api private
13
+ class SpanProcessor < OpenTelemetry::SDK::Trace::SpanProcessor
14
+ # Called when a span starts
15
+ #
16
+ # @param span [OpenTelemetry::SDK::Trace::Span] The span that started
17
+ # @param parent_context [OpenTelemetry::Context] The parent context
18
+ # @return [void]
19
+ def on_start(span, parent_context)
20
+ return unless span.recording?
21
+
22
+ # Get propagated attributes from context
23
+ propagated_attrs = Propagation.get_propagated_attributes_from_context(parent_context)
24
+
25
+ # Set attributes on span
26
+ propagated_attrs.each do |key, value|
27
+ span.set_attribute(key, value)
28
+ end
29
+ end
30
+
31
+ # Called when a span ends
32
+ #
33
+ # @param span [OpenTelemetry::SDK::Trace::Span] The span that ended
34
+ # @return [void]
35
+ def on_finish(span)
36
+ # No-op - we don't need to do anything when spans finish
37
+ end
38
+
39
+ # Shutdown the processor
40
+ #
41
+ # @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
42
+ # @return [Integer] Always returns 0 (no timeout needed for no-op)
43
+ def shutdown(timeout: nil)
44
+ # No-op - nothing to clean up
45
+ # Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
46
+ _ = timeout # Suppress unused argument warning
47
+ 0
48
+ end
49
+
50
+ # Force flush (no-op for this processor)
51
+ #
52
+ # @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
53
+ # @return [Integer] Always returns 0 (no timeout needed for no-op)
54
+ def force_flush(timeout: nil)
55
+ # No-op - nothing to flush
56
+ # Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
57
+ _ = timeout # Suppress unused argument warning
58
+ 0
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mustache"
4
+
5
+ module Langfuse
6
+ # Text prompt client for compiling text prompts with variable substitution
7
+ #
8
+ # Handles text-based prompts from Langfuse, providing Mustache templating
9
+ # for variable substitution.
10
+ #
11
+ # @example Basic usage
12
+ # prompt_data = api_client.get_prompt("greeting")
13
+ # text_prompt = Langfuse::TextPromptClient.new(prompt_data)
14
+ # text_prompt.compile(variables: { name: "Alice" })
15
+ # # => "Hello Alice!"
16
+ #
17
+ # @example Accessing metadata
18
+ # text_prompt.name # => "greeting"
19
+ # text_prompt.version # => 1
20
+ # text_prompt.labels # => ["production"]
21
+ #
22
+ class TextPromptClient
23
+ attr_reader :name, :version, :labels, :tags, :config, :prompt
24
+
25
+ # Initialize a new text prompt client
26
+ #
27
+ # @param prompt_data [Hash] The prompt data from the API
28
+ # @raise [ArgumentError] if prompt data is invalid
29
+ def initialize(prompt_data)
30
+ validate_prompt_data!(prompt_data)
31
+
32
+ @name = prompt_data["name"]
33
+ @version = prompt_data["version"]
34
+ @prompt = prompt_data["prompt"]
35
+ @labels = prompt_data["labels"] || []
36
+ @tags = prompt_data["tags"] || []
37
+ @config = prompt_data["config"] || {}
38
+ end
39
+
40
+ # Compile the prompt with variable substitution
41
+ #
42
+ # @param kwargs [Hash] Variables to substitute in the template (as keyword arguments)
43
+ # @return [String] The compiled prompt text
44
+ #
45
+ # @example
46
+ # text_prompt.compile(name: "Alice", greeting: "Hi")
47
+ # # => "Hi Alice! Welcome."
48
+ def compile(**kwargs)
49
+ return prompt if kwargs.empty?
50
+
51
+ Mustache.render(prompt, kwargs)
52
+ end
53
+
54
+ private
55
+
56
+ # Validate prompt data structure
57
+ #
58
+ # @param prompt_data [Hash] The prompt data to validate
59
+ # @raise [ArgumentError] if validation fails
60
+ def validate_prompt_data!(prompt_data)
61
+ raise ArgumentError, "prompt_data must be a Hash" unless prompt_data.is_a?(Hash)
62
+ raise ArgumentError, "prompt_data must include 'prompt' field" unless prompt_data.key?("prompt")
63
+ raise ArgumentError, "prompt_data must include 'name' field" unless prompt_data.key?("name")
64
+ raise ArgumentError, "prompt_data must include 'version' field" unless prompt_data.key?("version")
65
+ end
66
+ end
67
+ end