langfuse-ruby 0.1.6 → 0.2.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.
@@ -3,12 +3,14 @@
3
3
  module Langfuse
4
4
  class Generation
5
5
  attr_reader :id, :trace_id, :name, :start_time, :end_time, :completion_start_time,
6
- :model, :model_parameters, :input, :output, :usage, :metadata, :level,
6
+ :model, :model_parameters, :input, :output, :usage, :usage_details, :cost_details,
7
+ :prompt_name, :prompt_version, :metadata, :level,
7
8
  :status_message, :parent_observation_id, :version, :as_type, :client
8
9
 
9
10
  def initialize(client:, trace_id:, id: nil, name: nil, start_time: nil, end_time: nil,
10
11
  completion_start_time: nil, model: nil, model_parameters: nil, input: nil,
11
- output: nil, usage: nil, metadata: nil, level: nil, status_message: nil,
12
+ output: nil, usage: nil, usage_details: nil, cost_details: nil, prompt: nil,
13
+ metadata: nil, level: nil, status_message: nil,
12
14
  parent_observation_id: nil, version: nil, as_type: nil, **kwargs)
13
15
  @client = client
14
16
  @id = id || Utils.generate_id
@@ -22,6 +24,9 @@ module Langfuse
22
24
  @input = input
23
25
  @output = output
24
26
  @usage = usage || {}
27
+ @usage_details = usage_details || {}
28
+ @cost_details = cost_details || {}
29
+ @prompt_name, @prompt_version = extract_prompt_info(prompt)
25
30
  @metadata = metadata || {}
26
31
  @level = level
27
32
  @status_message = status_message
@@ -35,7 +40,8 @@ module Langfuse
35
40
  end
36
41
 
37
42
  def update(name: nil, end_time: nil, completion_start_time: nil, model: nil,
38
- model_parameters: nil, input: nil, output: nil, usage: nil, metadata: nil,
43
+ model_parameters: nil, input: nil, output: nil, usage: nil,
44
+ usage_details: nil, cost_details: nil, prompt: nil, metadata: nil,
39
45
  level: nil, status_message: nil, version: nil, **kwargs)
40
46
  @name = name if name
41
47
  @end_time = end_time if end_time
@@ -45,6 +51,9 @@ module Langfuse
45
51
  @input = input if input
46
52
  @output = output if output
47
53
  @usage.merge!(usage) if usage
54
+ @usage_details.merge!(usage_details) if usage_details
55
+ @cost_details.merge!(cost_details) if cost_details
56
+ @prompt_name, @prompt_version = extract_prompt_info(prompt) if prompt
48
57
  @metadata.merge!(metadata) if metadata
49
58
  @level = level if level
50
59
  @status_message = status_message if status_message
@@ -55,10 +64,12 @@ module Langfuse
55
64
  self
56
65
  end
57
66
 
58
- def end(output: nil, end_time: nil, usage: nil, **kwargs)
67
+ def end(output: nil, end_time: nil, usage: nil, usage_details: nil, cost_details: nil, **kwargs)
59
68
  @end_time = end_time || Utils.current_timestamp
60
69
  @output = output if output
61
70
  @usage.merge!(usage) if usage
71
+ @usage_details.merge!(usage_details) if usage_details
72
+ @cost_details.merge!(cost_details) if cost_details
62
73
  @kwargs.merge!(kwargs)
63
74
 
64
75
  update_generation
@@ -261,6 +272,7 @@ module Langfuse
261
272
 
262
273
  def score(name:, value:, data_type: nil, comment: nil, **kwargs)
263
274
  @client.score(
275
+ trace_id: @trace_id,
264
276
  observation_id: @id,
265
277
  name: name,
266
278
  value: value,
@@ -293,12 +305,32 @@ module Langfuse
293
305
  parent_observation_id: @parent_observation_id,
294
306
  version: @version
295
307
  }
308
+ data[:usage_details] = @usage_details unless @usage_details.nil? || @usage_details.empty?
309
+ data[:cost_details] = @cost_details unless @cost_details.nil? || @cost_details.empty?
310
+ data[:prompt_name] = @prompt_name if @prompt_name
311
+ data[:prompt_version] = @prompt_version if @prompt_version
296
312
  data[:type] = @as_type if @as_type
297
313
  data.merge(@kwargs).compact
298
314
  end
299
315
 
300
316
  private
301
317
 
318
+ # Accepts a Langfuse::Prompt, a Hash with :name/:version, or nil.
319
+ # Returns [prompt_name, prompt_version] used to link the generation to a prompt version.
320
+ def extract_prompt_info(prompt)
321
+ return [nil, nil] if prompt.nil?
322
+
323
+ if prompt.respond_to?(:name) && prompt.respond_to?(:version)
324
+ [prompt.name, prompt.version]
325
+ elsif prompt.is_a?(Hash)
326
+ name = prompt[:name] || prompt['name']
327
+ version = prompt[:version] || prompt['version']
328
+ [name, version]
329
+ else
330
+ [nil, nil]
331
+ end
332
+ end
333
+
302
334
  def validate_as_type(type)
303
335
  return nil if type.nil?
304
336
 
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module Langfuse
7
+ # Converts batched Langfuse events into OTLP/HTTP JSON (ExportTraceServiceRequest)
8
+ # and sends them to the Langfuse OTEL endpoint for v4-compatible ingestion.
9
+ class OtelExporter
10
+ OTEL_ENDPOINT = '/api/public/otel/v1/traces'
11
+
12
+ class << self
13
+ # Convert an ID (UUID or hex string) to an OTEL 32-char hex trace ID.
14
+ # OTEL trace IDs are 16 bytes (32 hex chars). Native hex IDs pass through unchanged.
15
+ def to_otel_trace_id(id_str)
16
+ return '0' * 32 unless id_str
17
+
18
+ hex = id_str.to_s.delete('-')
19
+ hex.ljust(32, '0')[0, 32]
20
+ end
21
+
22
+ # Convert an ID (UUID or hex string) to an OTEL 16-char hex span ID.
23
+ # OTEL span IDs are 8 bytes (16 hex chars). Native hex IDs pass through unchanged.
24
+ def to_otel_span_id(id_str)
25
+ return '0' * 16 unless id_str
26
+
27
+ hex = id_str.to_s.delete('-')
28
+ hex[0, 16]
29
+ end
30
+ end
31
+
32
+ # @param connection [Faraday::Connection] HTTP connection to Langfuse host
33
+ # @param debug [Boolean] whether to print debug output
34
+ # @param logger [Logger, nil] logger for debug output
35
+ def initialize(connection:, debug: false, logger: nil)
36
+ @connection = connection
37
+ @debug = debug
38
+ @logger = logger
39
+ end
40
+
41
+ # Export a batch of Langfuse events as OTLP spans.
42
+ # Note: score-create events are not part of the OTLP mapping and are
43
+ # handled separately by the client via the ingestion API.
44
+ # @param events [Array<Hash>] array of event hashes from the event queue
45
+ # @return [Faraday::Response]
46
+ def export(events)
47
+ resource_spans = build_resource_spans(events)
48
+ payload = { resourceSpans: resource_spans }
49
+
50
+ log_debug { "OTEL export payload: #{JSON.pretty_generate(payload)}" }
51
+
52
+ @connection.post(OTEL_ENDPOINT) do |req|
53
+ req.headers['Content-Type'] = 'application/json'
54
+ req.body = JSON.generate(payload)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def log_debug(&block)
61
+ return unless @debug
62
+
63
+ if @logger
64
+ @logger.debug(block.call)
65
+ else
66
+ puts block.call
67
+ end
68
+ end
69
+
70
+ # Build the top-level resourceSpans array from events.
71
+ # Groups events by trace_id, producing one scopeSpan per trace.
72
+ def build_resource_spans(events)
73
+ grouped = group_events_by_trace(events)
74
+
75
+ scope_spans = grouped.map do |_trace_id, trace_events|
76
+ spans = trace_events.filter_map { |event| convert_event_to_span(event) }
77
+ next if spans.empty?
78
+
79
+ { scope: { name: 'langfuse-ruby', version: Langfuse::VERSION }, spans: spans }
80
+ end.compact
81
+
82
+ return [] if scope_spans.empty?
83
+
84
+ [{
85
+ resource: {
86
+ attributes: [
87
+ { key: 'service.name', value: { stringValue: 'langfuse-ruby' } },
88
+ { key: 'telemetry.sdk.name', value: { stringValue: 'langfuse-ruby' } },
89
+ { key: 'telemetry.sdk.version', value: { stringValue: Langfuse::VERSION } }
90
+ ]
91
+ },
92
+ scopeSpans: scope_spans
93
+ }]
94
+ end
95
+
96
+ # Group events by their trace ID for proper OTEL span hierarchy.
97
+ def group_events_by_trace(events)
98
+ groups = Hash.new { |h, k| h[k] = [] }
99
+
100
+ events.each do |event|
101
+ body = event[:body] || {}
102
+ trace_id = body['traceId'] || body['trace_id'] || body['id'] || 'unknown'
103
+ groups[trace_id] << event
104
+ end
105
+
106
+ groups
107
+ end
108
+
109
+ # Convert a single Langfuse event to an OTLP span hash, or nil if not convertible.
110
+ def convert_event_to_span(event)
111
+ type = event[:type]
112
+ body = event[:body] || {}
113
+
114
+ case type
115
+ when 'trace-create'
116
+ build_trace_span(body)
117
+ when 'span-create', 'span-update'
118
+ build_observation_span(body, 'span')
119
+ when 'generation-create', 'generation-update'
120
+ build_observation_span(body, 'generation')
121
+ when 'event-create'
122
+ build_event_span(body)
123
+ end
124
+ end
125
+
126
+ # Build a root OTEL span for a Langfuse trace.
127
+ def build_trace_span(body)
128
+ trace_id = to_otel_trace_id(body['id'])
129
+ span_id = to_otel_span_id(body['id'])
130
+
131
+ attributes = []
132
+ add_attr(attributes, 'langfuse.trace.name', body['name'])
133
+ add_attr(attributes, 'langfuse.user.id', body['userId'])
134
+ add_attr(attributes, 'langfuse.session.id', body['sessionId'])
135
+ add_attr(attributes, 'langfuse.release', body['release'])
136
+ add_attr(attributes, 'langfuse.version', body['version'])
137
+ add_attr(attributes, 'langfuse.environment', body['environment'])
138
+ add_attr(attributes, 'langfuse.trace.public', body['public']) unless body['public'].nil?
139
+ add_attr(attributes, 'langfuse.internal.as_root', true)
140
+ add_json_attr(attributes, 'langfuse.trace.input', body['input'])
141
+ add_json_attr(attributes, 'langfuse.trace.output', body['output'])
142
+ add_json_attr(attributes, 'langfuse.trace.metadata', body['metadata'])
143
+
144
+ tags = body['tags']
145
+ if tags.is_a?(Array) && !tags.empty?
146
+ add_array_attr(attributes, 'langfuse.trace.tags', tags)
147
+ end
148
+
149
+ {
150
+ traceId: trace_id,
151
+ spanId: span_id,
152
+ name: body['name'] || 'trace',
153
+ kind: 1, # SPAN_KIND_INTERNAL
154
+ startTimeUnixNano: to_unix_nano(body['timestamp']),
155
+ endTimeUnixNano: to_unix_nano(body['timestamp']),
156
+ attributes: attributes,
157
+ status: { code: 1 } # STATUS_CODE_OK
158
+ }
159
+ end
160
+
161
+ # Build an OTEL span for a Langfuse span or generation observation.
162
+ def build_observation_span(body, obs_type)
163
+ trace_id = to_otel_trace_id(body['traceId'])
164
+ span_id = to_otel_span_id(body['id'])
165
+
166
+ span = {
167
+ traceId: trace_id,
168
+ spanId: span_id,
169
+ name: body['name'] || obs_type,
170
+ kind: 1, # SPAN_KIND_INTERNAL
171
+ startTimeUnixNano: to_unix_nano(body['startTime']),
172
+ endTimeUnixNano: to_unix_nano(body['endTime'] || body['startTime']),
173
+ attributes: build_observation_attributes(body, obs_type),
174
+ status: { code: 1 }
175
+ }
176
+
177
+ # Set parent span ID
178
+ parent_id = body['parentObservationId']
179
+ if parent_id
180
+ span[:parentSpanId] = to_otel_span_id(parent_id)
181
+ else
182
+ # Parent is the trace root span
183
+ span[:parentSpanId] = to_otel_span_id(body['traceId'])
184
+ end
185
+
186
+ span
187
+ end
188
+
189
+ # Build an OTEL span for a Langfuse event (zero-duration span).
190
+ def build_event_span(body)
191
+ trace_id = to_otel_trace_id(body['traceId'])
192
+ span_id = to_otel_span_id(body['id'])
193
+ timestamp = to_unix_nano(body['startTime'])
194
+
195
+ attributes = []
196
+ add_attr(attributes, 'langfuse.observation.type', 'event')
197
+ add_json_attr(attributes, 'langfuse.observation.input', body['input'])
198
+ add_json_attr(attributes, 'langfuse.observation.output', body['output'])
199
+ add_json_attr(attributes, 'langfuse.observation.metadata', body['metadata'])
200
+ add_attr(attributes, 'langfuse.observation.level', body['level'])
201
+ add_attr(attributes, 'langfuse.observation.status_message', body['statusMessage'])
202
+
203
+ span = {
204
+ traceId: trace_id,
205
+ spanId: span_id,
206
+ name: body['name'] || 'event',
207
+ kind: 1,
208
+ startTimeUnixNano: timestamp,
209
+ endTimeUnixNano: timestamp,
210
+ attributes: attributes,
211
+ status: { code: 1 }
212
+ }
213
+
214
+ parent_id = body['parentObservationId']
215
+ if parent_id
216
+ span[:parentSpanId] = to_otel_span_id(parent_id)
217
+ elsif body['traceId']
218
+ span[:parentSpanId] = to_otel_span_id(body['traceId'])
219
+ end
220
+
221
+ span
222
+ end
223
+
224
+ # Build OTEL attributes for a span/generation observation.
225
+ def build_observation_attributes(body, obs_type)
226
+ attributes = []
227
+ effective_type = body['type'] || obs_type
228
+ add_attr(attributes, 'langfuse.observation.type', effective_type)
229
+ add_json_attr(attributes, 'langfuse.observation.input', body['input'])
230
+ add_json_attr(attributes, 'langfuse.observation.output', body['output'])
231
+ add_json_attr(attributes, 'langfuse.observation.metadata', body['metadata'])
232
+ add_attr(attributes, 'langfuse.observation.level', body['level'])
233
+ add_attr(attributes, 'langfuse.observation.status_message', body['statusMessage'])
234
+ add_attr(attributes, 'langfuse.environment', body['environment'])
235
+
236
+ if obs_type == 'generation'
237
+ add_generation_attributes(attributes, body)
238
+ end
239
+
240
+ attributes
241
+ end
242
+
243
+ # Add generation-specific gen_ai.* attributes.
244
+ def add_generation_attributes(attributes, body)
245
+ add_attr(attributes, 'gen_ai.request.model', body['model'])
246
+
247
+ model_params = body['modelParameters']
248
+ if model_params.is_a?(Hash)
249
+ model_params.each do |key, value|
250
+ add_attr(attributes, "gen_ai.request.#{key}", value) unless value.nil?
251
+ end
252
+ end
253
+
254
+ usage = body['usage']
255
+ if usage.is_a?(Hash)
256
+ add_attr(attributes, 'gen_ai.usage.prompt_tokens', usage['promptTokens'] || usage['prompt_tokens'])
257
+ add_attr(attributes, 'gen_ai.usage.completion_tokens', usage['completionTokens'] || usage['completion_tokens'])
258
+ total = usage['totalTokens'] || usage['total_tokens']
259
+ add_attr(attributes, 'gen_ai.usage.total_tokens', total) if total
260
+ end
261
+
262
+ add_json_attr(attributes, 'langfuse.observation.usage_details', body['usageDetails'])
263
+ add_json_attr(attributes, 'langfuse.observation.cost_details', body['costDetails'])
264
+ add_attr(attributes, 'langfuse.observation.prompt.name', body['promptName'])
265
+ add_attr(attributes, 'langfuse.observation.prompt.version', body['promptVersion'])
266
+ add_attr(attributes, 'langfuse.observation.completion_start_time', body['completionStartTime'])
267
+ end
268
+
269
+ def to_otel_trace_id(id_str)
270
+ self.class.to_otel_trace_id(id_str)
271
+ end
272
+
273
+ def to_otel_span_id(id_str)
274
+ self.class.to_otel_span_id(id_str)
275
+ end
276
+
277
+ # Convert an ISO8601 timestamp string to nanoseconds since epoch.
278
+ def to_unix_nano(timestamp_str)
279
+ return '0' unless timestamp_str
280
+
281
+ time = Time.parse(timestamp_str.to_s)
282
+ (time.to_f * 1_000_000_000).to_i.to_s
283
+ rescue ArgumentError
284
+ '0'
285
+ end
286
+
287
+ # Add a string/numeric attribute to the attributes array.
288
+ def add_attr(attributes, key, value)
289
+ return if value.nil?
290
+
291
+ otel_value = case value
292
+ when String
293
+ { stringValue: value }
294
+ when Integer
295
+ { intValue: value.to_s }
296
+ when Float
297
+ { doubleValue: value }
298
+ when TrueClass, FalseClass
299
+ { boolValue: value }
300
+ else
301
+ { stringValue: value.to_s }
302
+ end
303
+
304
+ attributes << { key: key, value: otel_value }
305
+ end
306
+
307
+ # Add a JSON-serialized attribute (for complex objects like input/output).
308
+ def add_json_attr(attributes, key, value)
309
+ return if value.nil?
310
+ return if value.is_a?(Hash) && value.empty?
311
+ return if value.is_a?(Array) && value.empty?
312
+
313
+ json_str = value.is_a?(String) ? value : JSON.generate(value)
314
+ attributes << { key: key, value: { stringValue: json_str } }
315
+ end
316
+
317
+ # Add an array attribute for tags.
318
+ def add_array_attr(attributes, key, values)
319
+ return if values.nil? || values.empty?
320
+
321
+ array_values = values.map { |v| { stringValue: v.to_s } }
322
+ attributes << { key: key, value: { arrayValue: { values: array_values } } }
323
+ end
324
+ end
325
+ end
data/lib/langfuse/span.rb CHANGED
@@ -250,6 +250,7 @@ module Langfuse
250
250
 
251
251
  def score(name:, value:, data_type: nil, comment: nil, **kwargs)
252
252
  @client.score(
253
+ trace_id: @trace_id,
253
254
  observation_id: @id,
254
255
  name: name,
255
256
  value: value,
@@ -3,11 +3,11 @@
3
3
  module Langfuse
4
4
  class Trace
5
5
  attr_reader :id, :name, :user_id, :session_id, :version, :release, :input, :output,
6
- :metadata, :tags, :timestamp, :client
6
+ :metadata, :tags, :timestamp, :public, :client
7
7
 
8
8
  def initialize(client:, id:, name: nil, user_id: nil, session_id: nil, version: nil,
9
9
  release: nil, input: nil, output: nil, metadata: nil, tags: nil,
10
- timestamp: nil, **kwargs)
10
+ timestamp: nil, public: nil, **kwargs)
11
11
  @client = client
12
12
  @id = id
13
13
  @name = name
@@ -20,6 +20,7 @@ module Langfuse
20
20
  @metadata = metadata || {}
21
21
  @tags = tags || []
22
22
  @timestamp = timestamp
23
+ @public = public
23
24
  @kwargs = kwargs
24
25
 
25
26
  # Create the trace
@@ -50,6 +51,7 @@ module Langfuse
50
51
  # Create a child generation
51
52
  def generation(name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
52
53
  model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
54
+ usage_details: nil, cost_details: nil, prompt: nil,
53
55
  metadata: nil, level: nil, status_message: nil, parent_observation_id: nil,
54
56
  version: nil, **kwargs)
55
57
  @client.generation(
@@ -63,6 +65,9 @@ module Langfuse
63
65
  input: input,
64
66
  output: output,
65
67
  usage: usage,
68
+ usage_details: usage_details,
69
+ cost_details: cost_details,
70
+ prompt: prompt,
66
71
  metadata: metadata,
67
72
  level: level,
68
73
  status_message: status_message,
@@ -247,7 +252,8 @@ module Langfuse
247
252
  end
248
253
 
249
254
  def update(name: nil, user_id: nil, session_id: nil, version: nil,
250
- release: nil, input: nil, output: nil, metadata: nil, tags: nil, **kwargs)
255
+ release: nil, input: nil, output: nil, metadata: nil, tags: nil,
256
+ public: nil, **kwargs)
251
257
  # 更新实例变量
252
258
  @name = name if name
253
259
  @user_id = user_id if user_id
@@ -258,6 +264,7 @@ module Langfuse
258
264
  @output = output if output
259
265
  @metadata = metadata if metadata
260
266
  @tags = tags if tags
267
+ @public = public unless public.nil?
261
268
  @kwargs.merge!(kwargs) if kwargs.any?
262
269
  # 触发 trace-update 事件
263
270
  update_trace
@@ -279,7 +286,8 @@ module Langfuse
279
286
  output: @output,
280
287
  metadata: @metadata,
281
288
  tags: @tags,
282
- timestamp: @timestamp
289
+ timestamp: @timestamp,
290
+ public: @public
283
291
  }.merge(@kwargs).compact
284
292
  end
285
293
 
@@ -297,7 +305,8 @@ module Langfuse
297
305
  output: @output,
298
306
  metadata: @metadata,
299
307
  tags: @tags,
300
- timestamp: @timestamp
308
+ timestamp: @timestamp,
309
+ public: @public
301
310
  }.merge(@kwargs).compact
302
311
 
303
312
  @client.enqueue_event('trace-create', data)
@@ -315,7 +324,8 @@ module Langfuse
315
324
  output: @output,
316
325
  metadata: @metadata,
317
326
  tags: @tags,
318
- timestamp: @timestamp
327
+ timestamp: @timestamp,
328
+ public: @public
319
329
  }.merge(@kwargs).compact
320
330
 
321
331
  @client.enqueue_event('trace-update', data)
@@ -6,11 +6,25 @@ require 'erb'
6
6
 
7
7
  module Langfuse
8
8
  module Utils
9
+ # Body keys whose values must be passed through verbatim (user data),
10
+ # everything else nested under them keeps its original key format.
11
+ VERBATIM_BODY_KEYS = %w[input output metadata usageDetails costDetails modelParameters].freeze
12
+
9
13
  class << self
10
14
  def generate_id
11
15
  SecureRandom.uuid
12
16
  end
13
17
 
18
+ # W3C-compatible 32-char hex trace ID (16 bytes), used for OTel ingestion
19
+ def generate_hex_trace_id
20
+ SecureRandom.hex(16)
21
+ end
22
+
23
+ # W3C-compatible 16-char hex span/observation ID (8 bytes), used for OTel ingestion
24
+ def generate_hex_span_id
25
+ SecureRandom.hex(8)
26
+ end
27
+
14
28
  def current_timestamp
15
29
  Time.now.utc.iso8601(3)
16
30
  end
@@ -39,6 +53,24 @@ module Langfuse
39
53
  end
40
54
  end
41
55
 
56
+ # Prepare an event body for the ingestion API:
57
+ # - top-level keys are camelized (snake_case -> camelCase)
58
+ # - user data values (input/output/metadata/usageDetails/costDetails/modelParameters)
59
+ # are passed through verbatim so user-provided keys are not mangled
60
+ # - the legacy `usage` object keys are camelized (prompt_tokens -> promptTokens)
61
+ def prepare_event_body(body)
62
+ return body unless body.is_a?(Hash)
63
+
64
+ body.each_with_object({}) do |(key, value), result|
65
+ new_key = camelize_key(key.to_s)
66
+ result[new_key] = if !VERBATIM_BODY_KEYS.include?(new_key) && value.is_a?(Hash)
67
+ deep_stringify_keys(value)
68
+ else
69
+ value
70
+ end
71
+ end
72
+ end
73
+
42
74
  # 将哈希的键名转换为小驼峰格式
43
75
  def deep_camelize_keys(hash)
44
76
  return hash unless hash.is_a?(Hash)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langfuse
4
- VERSION = '0.1.6'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/langfuse.rb CHANGED
@@ -12,9 +12,12 @@ require_relative 'langfuse/evaluation'
12
12
  require_relative 'langfuse/errors'
13
13
  require_relative 'langfuse/utils'
14
14
  require_relative 'langfuse/null_objects'
15
+ require_relative 'langfuse/otel_exporter'
15
16
 
16
17
  # Ruby SDK for Langfuse - Open source LLM engineering platform
17
18
  module Langfuse
19
+ CLIENT_MUTEX = Mutex.new
20
+
18
21
  class << self
19
22
  # Configure the Langfuse client with default settings
20
23
  def configure
@@ -30,10 +33,10 @@ module Langfuse
30
33
  Client.new(**kwargs)
31
34
  end
32
35
 
33
- # Get a thread-safe singleton client instance
36
+ # Get a process-wide, thread-safe singleton client instance
34
37
  # @return [Client] Langfuse client
35
38
  def client
36
- Thread.current[:langfuse_client] ||= Client.new
39
+ @client || CLIENT_MUTEX.synchronize { @client ||= Client.new }
37
40
  end
38
41
 
39
42
  # Get a prompt and optionally compile it with variables
@@ -58,7 +61,7 @@ module Langfuse
58
61
  end
59
62
  rescue StandardError => e
60
63
  if attempts <= retries
61
- sleep_time = 2**(attempts - 1) * 0.1 # Exponential backoff: 0.1s, 0.2s, 0.4s...
64
+ sleep_time = (2**(attempts - 1)) * 0.1 # Exponential backoff: 0.1s, 0.2s, 0.4s...
62
65
  warn "Langfuse prompt fetch failed (#{prompt_name}), retrying in #{sleep_time}s... (attempt #{attempts}/#{retries + 1})" if configuration.debug
63
66
  sleep(sleep_time)
64
67
  retry
@@ -99,7 +102,7 @@ module Langfuse
99
102
  # Langfuse.flush
100
103
  #
101
104
  def trace(name = nil, user_id: nil, session_id: nil, input: nil, output: nil,
102
- metadata: nil, tags: nil, version: nil, release: nil, **kwargs, &block)
105
+ metadata: nil, tags: nil, version: nil, release: nil, **kwargs)
103
106
  trace = client.trace(
104
107
  name: name,
105
108
  user_id: user_id,
@@ -151,13 +154,14 @@ module Langfuse
151
154
 
152
155
  # Reset the singleton client (mainly for testing)
153
156
  def reset!
154
- Thread.current[:langfuse_client] = nil
157
+ CLIENT_MUTEX.synchronize { @client = nil }
155
158
  end
156
159
  end
157
160
 
158
161
  # Configuration class for Langfuse client settings
159
162
  class Configuration
160
- attr_accessor :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush
163
+ attr_accessor :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
164
+ :ingestion_mode, :environment, :sample_rate, :mask, :flush_at, :logger, :shutdown_on_exit
161
165
 
162
166
  def initialize
163
167
  @public_key = nil
@@ -168,6 +172,13 @@ module Langfuse
168
172
  @retries = 3
169
173
  @flush_interval = 5
170
174
  @auto_flush = true
175
+ @ingestion_mode = :legacy # :legacy or :otel
176
+ @environment = nil # default tracing environment for all events
177
+ @sample_rate = nil # 0.0..1.0, nil disables sampling
178
+ @mask = nil # callable applied to input/output/metadata before sending
179
+ @flush_at = 15 # flush as soon as this many events are queued
180
+ @logger = nil # custom Logger instance
181
+ @shutdown_on_exit = true # register an at_exit hook that flushes pending events
171
182
  end
172
183
  end
173
184
  end