langfuse-ruby 0.1.7 → 0.2.1

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.
@@ -57,23 +57,7 @@ module Langfuse
57
57
  end
58
58
 
59
59
  def create_event
60
- data = {
61
- id: @id,
62
- trace_id: @trace_id,
63
- name: @name,
64
- start_time: @start_time,
65
- input: @input,
66
- output: @output,
67
- metadata: @metadata,
68
- level: @level,
69
- status_message: @status_message,
70
- parent_observation_id: @parent_observation_id,
71
- version: @version
72
- }
73
- data[:type] = @as_type if @as_type
74
- data = data.merge(@kwargs).compact
75
-
76
- @client.enqueue_event('event-create', data)
60
+ @client.enqueue_event('event-create', to_dict)
77
61
  end
78
62
  end
79
63
  end
@@ -2,13 +2,17 @@
2
2
 
3
3
  module Langfuse
4
4
  class Generation
5
+ include PartialUpdates
6
+
5
7
  attr_reader :id, :trace_id, :name, :start_time, :end_time, :completion_start_time,
6
- :model, :model_parameters, :input, :output, :usage, :metadata, :level,
8
+ :model, :model_parameters, :input, :output, :usage, :usage_details, :cost_details,
9
+ :prompt_name, :prompt_version, :metadata, :level,
7
10
  :status_message, :parent_observation_id, :version, :as_type, :client
8
11
 
9
12
  def initialize(client:, trace_id:, id: nil, name: nil, start_time: nil, end_time: nil,
10
13
  completion_start_time: nil, model: nil, model_parameters: nil, input: nil,
11
- output: nil, usage: nil, metadata: nil, level: nil, status_message: nil,
14
+ output: nil, usage: nil, usage_details: nil, cost_details: nil, prompt: nil,
15
+ metadata: nil, level: nil, status_message: nil,
12
16
  parent_observation_id: nil, version: nil, as_type: nil, **kwargs)
13
17
  @client = client
14
18
  @id = id || Utils.generate_id
@@ -22,6 +26,9 @@ module Langfuse
22
26
  @input = input
23
27
  @output = output
24
28
  @usage = usage || {}
29
+ @usage_details = usage_details || {}
30
+ @cost_details = cost_details || {}
31
+ @prompt_name, @prompt_version = extract_prompt_info(prompt)
25
32
  @metadata = metadata || {}
26
33
  @level = level
27
34
  @status_message = status_message
@@ -35,32 +42,51 @@ module Langfuse
35
42
  end
36
43
 
37
44
  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,
45
+ model_parameters: nil, input: nil, output: nil, usage: nil,
46
+ usage_details: nil, cost_details: nil, prompt: nil, metadata: nil,
39
47
  level: nil, status_message: nil, version: nil, **kwargs)
40
- @name = name if name
41
- @end_time = end_time if end_time
42
- @completion_start_time = completion_start_time if completion_start_time
43
- @model = model if model
48
+ @name = name unless name.nil?
49
+ @end_time = end_time unless end_time.nil?
50
+ @completion_start_time = completion_start_time unless completion_start_time.nil?
51
+ @model = model unless model.nil?
44
52
  @model_parameters.merge!(model_parameters) if model_parameters
45
- @input = input if input
46
- @output = output if output
53
+ @input = input unless input.nil?
54
+ @output = output unless output.nil?
47
55
  @usage.merge!(usage) if usage
56
+ @usage_details.merge!(usage_details) if usage_details
57
+ @cost_details.merge!(cost_details) if cost_details
58
+ @prompt_name, @prompt_version = extract_prompt_info(prompt) if prompt
48
59
  @metadata.merge!(metadata) if metadata
49
- @level = level if level
50
- @status_message = status_message if status_message
51
- @version = version if version
60
+ @level = level unless level.nil?
61
+ @status_message = status_message unless status_message.nil?
62
+ @version = version unless version.nil?
52
63
  @kwargs.merge!(kwargs)
53
64
 
65
+ changes = { name: name, end_time: end_time, completion_start_time: completion_start_time,
66
+ model: model, model_parameters: model_parameters, input: input, output: output,
67
+ usage: usage, usage_details: usage_details, cost_details: cost_details,
68
+ metadata: metadata, level: level, status_message: status_message,
69
+ version: version }
70
+ changes.merge!(prompt_name: @prompt_name, prompt_version: @prompt_version) if prompt
71
+
72
+ track_changes(changes, kwargs.keys)
54
73
  update_generation
55
74
  self
56
75
  end
57
76
 
58
- def end(output: nil, end_time: nil, usage: nil, **kwargs)
77
+ def end(output: nil, end_time: nil, usage: nil, usage_details: nil, cost_details: nil, **kwargs)
59
78
  @end_time = end_time || Utils.current_timestamp
60
- @output = output if output
79
+ @output = output unless output.nil?
61
80
  @usage.merge!(usage) if usage
81
+ @usage_details.merge!(usage_details) if usage_details
82
+ @cost_details.merge!(cost_details) if cost_details
62
83
  @kwargs.merge!(kwargs)
63
84
 
85
+ track_changes(
86
+ { end_time: @end_time, output: output, usage: usage,
87
+ usage_details: usage_details, cost_details: cost_details },
88
+ kwargs.keys
89
+ )
64
90
  update_generation
65
91
  self
66
92
  end
@@ -88,6 +114,7 @@ module Langfuse
88
114
  # Create a child generation
89
115
  def generation(name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
90
116
  model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
117
+ usage_details: nil, cost_details: nil, prompt: nil,
91
118
  metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
92
119
  @client.generation(
93
120
  trace_id: @trace_id,
@@ -100,6 +127,9 @@ module Langfuse
100
127
  input: input,
101
128
  output: output,
102
129
  usage: usage,
130
+ usage_details: usage_details,
131
+ cost_details: cost_details,
132
+ prompt: prompt,
103
133
  metadata: metadata,
104
134
  level: level,
105
135
  status_message: status_message,
@@ -127,79 +157,11 @@ module Langfuse
127
157
  )
128
158
  end
129
159
 
130
- # Convenience methods for enhanced observation types
131
-
132
- # Create a child agent observation
133
- def agent(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
134
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
135
- span(
136
- name: name,
137
- start_time: start_time,
138
- end_time: end_time,
139
- input: input,
140
- output: output,
141
- metadata: metadata,
142
- level: level,
143
- status_message: status_message,
144
- version: version,
145
- as_type: ObservationType::AGENT,
146
- **kwargs
147
- )
148
- end
149
-
150
- # Create a child tool observation
151
- def tool(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
152
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
153
- span(
154
- name: name,
155
- start_time: start_time,
156
- end_time: end_time,
157
- input: input,
158
- output: output,
159
- metadata: metadata,
160
- level: level,
161
- status_message: status_message,
162
- version: version,
163
- as_type: ObservationType::TOOL,
164
- **kwargs
165
- )
166
- end
167
-
168
- # Create a child chain observation
169
- def chain(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
170
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
171
- span(
172
- name: name,
173
- start_time: start_time,
174
- end_time: end_time,
175
- input: input,
176
- output: output,
177
- metadata: metadata,
178
- level: level,
179
- status_message: status_message,
180
- version: version,
181
- as_type: ObservationType::CHAIN,
182
- **kwargs
183
- )
184
- end
185
-
186
- # Create a child retriever observation
187
- def retriever(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
188
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
189
- span(
190
- name: name,
191
- start_time: start_time,
192
- end_time: end_time,
193
- input: input,
194
- output: output,
195
- metadata: metadata,
196
- level: level,
197
- status_message: status_message,
198
- version: version,
199
- as_type: ObservationType::RETRIEVER,
200
- **kwargs
201
- )
202
- end
160
+ # Convenience methods for enhanced observation types: each is a child span
161
+ # with a fixed as_type. (embedding keeps its own definition because it folds
162
+ # model/usage into metadata first.)
163
+ extend SpanWrappers
164
+ define_span_wrappers
203
165
 
204
166
  # Create a child embedding observation
205
167
  def embedding(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
@@ -223,44 +185,9 @@ module Langfuse
223
185
  )
224
186
  end
225
187
 
226
- # Create a child evaluator observation
227
- def evaluator(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
228
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
229
- span(
230
- name: name,
231
- start_time: start_time,
232
- end_time: end_time,
233
- input: input,
234
- output: output,
235
- metadata: metadata,
236
- level: level,
237
- status_message: status_message,
238
- version: version,
239
- as_type: ObservationType::EVALUATOR,
240
- **kwargs
241
- )
242
- end
243
-
244
- # Create a child guardrail observation
245
- def guardrail(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
246
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
247
- span(
248
- name: name,
249
- start_time: start_time,
250
- end_time: end_time,
251
- input: input,
252
- output: output,
253
- metadata: metadata,
254
- level: level,
255
- status_message: status_message,
256
- version: version,
257
- as_type: ObservationType::GUARDRAIL,
258
- **kwargs
259
- )
260
- end
261
-
262
188
  def score(name:, value:, data_type: nil, comment: nil, **kwargs)
263
189
  @client.score(
190
+ trace_id: @trace_id,
264
191
  observation_id: @id,
265
192
  name: name,
266
193
  value: value,
@@ -293,12 +220,32 @@ module Langfuse
293
220
  parent_observation_id: @parent_observation_id,
294
221
  version: @version
295
222
  }
223
+ data[:usage_details] = @usage_details unless @usage_details.nil? || @usage_details.empty?
224
+ data[:cost_details] = @cost_details unless @cost_details.nil? || @cost_details.empty?
225
+ data[:prompt_name] = @prompt_name if @prompt_name
226
+ data[:prompt_version] = @prompt_version if @prompt_version
296
227
  data[:type] = @as_type if @as_type
297
228
  data.merge(@kwargs).compact
298
229
  end
299
230
 
300
231
  private
301
232
 
233
+ # Accepts a Langfuse::Prompt, a Hash with :name/:version, or nil.
234
+ # Returns [prompt_name, prompt_version] used to link the generation to a prompt version.
235
+ def extract_prompt_info(prompt)
236
+ return [nil, nil] if prompt.nil?
237
+
238
+ if prompt.respond_to?(:name) && prompt.respond_to?(:version)
239
+ [prompt.name, prompt.version]
240
+ elsif prompt.is_a?(Hash)
241
+ name = prompt[:name] || prompt['name']
242
+ version = prompt[:version] || prompt['version']
243
+ [name, version]
244
+ else
245
+ [nil, nil]
246
+ end
247
+ end
248
+
302
249
  def validate_as_type(type)
303
250
  return nil if type.nil?
304
251
 
@@ -313,7 +260,7 @@ module Langfuse
313
260
  end
314
261
 
315
262
  def update_generation
316
- @client.enqueue_event('generation-update', to_dict)
263
+ @client.enqueue_event('generation-update', update_body)
317
264
  end
318
265
  end
319
266
  end
@@ -15,6 +15,7 @@ module Langfuse
15
15
  def retriever(**) = NullSpan.new
16
16
  def embedding(**) = NullSpan.new
17
17
  def evaluator(**) = NullSpan.new
18
+ alias evaluator_obs evaluator
18
19
  def guardrail(**) = NullSpan.new
19
20
  def score(**) = nil
20
21
  def get_url = nil
@@ -36,6 +37,7 @@ module Langfuse
36
37
  def retriever(**) = NullSpan.new
37
38
  def embedding(**) = NullSpan.new
38
39
  def evaluator(**) = NullSpan.new
40
+ alias evaluator_obs evaluator
39
41
  def guardrail(**) = NullSpan.new
40
42
  def score(**) = nil
41
43
  def get_url = nil
@@ -65,10 +67,12 @@ module Langfuse
65
67
  def retriever(**) = NullSpan.new
66
68
  def embedding(**) = NullSpan.new
67
69
  def evaluator(**) = NullSpan.new
70
+ alias evaluator_obs evaluator
68
71
  def guardrail(**) = NullSpan.new
69
72
  def score(**) = nil
70
73
  def get_url = nil
71
74
  def to_dict = {}
72
75
  def id = nil
76
+ def trace_id = nil
73
77
  end
74
78
  end
@@ -9,21 +9,54 @@ module Langfuse
9
9
  class OtelExporter
10
10
  OTEL_ENDPOINT = '/api/public/otel/v1/traces'
11
11
 
12
+ # Event types that carry the full state of an observation on every emit, so a
13
+ # later event supersedes the earlier one for the same observation id.
14
+ OBSERVATION_EVENT_TYPES = %w[span-create span-update generation-create generation-update].freeze
15
+
16
+ # Token keys accepted on the legacy `usage` object, in priority order.
17
+ USAGE_INPUT_KEYS = %w[promptTokens prompt_tokens inputTokens input_tokens input].freeze
18
+ USAGE_OUTPUT_KEYS = %w[completionTokens completion_tokens outputTokens output_tokens output].freeze
19
+ USAGE_TOTAL_KEYS = %w[totalTokens total_tokens total].freeze
20
+
21
+ class << self
22
+ # Convert an ID (UUID or hex string) to an OTEL 32-char hex trace ID.
23
+ # OTEL trace IDs are 16 bytes (32 hex chars). Native hex IDs pass through unchanged.
24
+ def to_otel_trace_id(id_str)
25
+ return '0' * 32 unless id_str
26
+
27
+ hex = id_str.to_s.delete('-')
28
+ hex.ljust(32, '0')[0, 32]
29
+ end
30
+
31
+ # Convert an ID (UUID or hex string) to an OTEL 16-char hex span ID.
32
+ # OTEL span IDs are 8 bytes (16 hex chars). Native hex IDs pass through unchanged.
33
+ def to_otel_span_id(id_str)
34
+ return '0' * 16 unless id_str
35
+
36
+ hex = id_str.to_s.delete('-')
37
+ hex[0, 16]
38
+ end
39
+ end
40
+
12
41
  # @param connection [Faraday::Connection] HTTP connection to Langfuse host
13
42
  # @param debug [Boolean] whether to print debug output
14
- def initialize(connection:, debug: false)
43
+ # @param logger [Logger, nil] logger for debug output
44
+ def initialize(connection:, debug: false, logger: nil)
15
45
  @connection = connection
16
46
  @debug = debug
47
+ @logger = logger
17
48
  end
18
49
 
19
50
  # Export a batch of Langfuse events as OTLP spans.
51
+ # Note: score-create events are not part of the OTLP mapping and are
52
+ # handled separately by the client via the ingestion API.
20
53
  # @param events [Array<Hash>] array of event hashes from the event queue
21
54
  # @return [Faraday::Response]
22
55
  def export(events)
23
56
  resource_spans = build_resource_spans(events)
24
57
  payload = { resourceSpans: resource_spans }
25
58
 
26
- puts "OTEL export payload: #{JSON.pretty_generate(payload)}" if @debug
59
+ log_debug { "OTEL export payload: #{JSON.pretty_generate(payload)}" }
27
60
 
28
61
  @connection.post(OTEL_ENDPOINT) do |req|
29
62
  req.headers['Content-Type'] = 'application/json'
@@ -33,10 +66,20 @@ module Langfuse
33
66
 
34
67
  private
35
68
 
69
+ def log_debug(&block)
70
+ return unless @debug
71
+
72
+ if @logger
73
+ @logger.debug(block.call)
74
+ else
75
+ puts block.call
76
+ end
77
+ end
78
+
36
79
  # Build the top-level resourceSpans array from events.
37
80
  # Groups events by trace_id, producing one scopeSpan per trace.
38
81
  def build_resource_spans(events)
39
- grouped = group_events_by_trace(events)
82
+ grouped = group_events_by_trace(collapse_observation_events(events))
40
83
 
41
84
  scope_spans = grouped.map do |_trace_id, trace_events|
42
85
  spans = trace_events.filter_map { |event| convert_event_to_span(event) }
@@ -59,6 +102,40 @@ module Langfuse
59
102
  }]
60
103
  end
61
104
 
105
+ # Collapse the create/update events of one observation into a single event.
106
+ # The v4 data model is append-only, so exporting both would produce two
107
+ # observations sharing a span id. Bodies are merged into new hashes, leaving
108
+ # the queued events untouched for re-queueing when the export fails.
109
+ def collapse_observation_events(events)
110
+ position_by_id = {}
111
+
112
+ events.each_with_object([]) do |event, collapsed|
113
+ id = observation_event_id(event)
114
+
115
+ if id.nil?
116
+ collapsed << event
117
+ elsif (position = position_by_id[id])
118
+ previous = collapsed[position]
119
+ collapsed[position] = previous.merge(
120
+ type: event[:type],
121
+ body: previous[:body].merge(event[:body])
122
+ )
123
+ else
124
+ position_by_id[id] = collapsed.length
125
+ collapsed << event
126
+ end
127
+ end
128
+ end
129
+
130
+ def observation_event_id(event)
131
+ return nil unless OBSERVATION_EVENT_TYPES.include?(event[:type])
132
+
133
+ body = event[:body]
134
+ return nil unless body.is_a?(Hash)
135
+
136
+ body['id'] || body[:id]
137
+ end
138
+
62
139
  # Group events by their trace ID for proper OTEL span hierarchy.
63
140
  def group_events_by_trace(events)
64
141
  groups = Hash.new { |h, k| h[k] = [] }
@@ -86,8 +163,6 @@ module Langfuse
86
163
  build_observation_span(body, 'generation')
87
164
  when 'event-create'
88
165
  build_event_span(body)
89
- when 'score-create'
90
- build_score_span(body)
91
166
  end
92
167
  end
93
168
 
@@ -102,6 +177,9 @@ module Langfuse
102
177
  add_attr(attributes, 'langfuse.session.id', body['sessionId'])
103
178
  add_attr(attributes, 'langfuse.release', body['release'])
104
179
  add_attr(attributes, 'langfuse.version', body['version'])
180
+ add_attr(attributes, 'langfuse.environment', body['environment'])
181
+ add_attr(attributes, 'langfuse.trace.public', body['public']) unless body['public'].nil?
182
+ add_attr(attributes, 'langfuse.internal.as_root', true)
105
183
  add_json_attr(attributes, 'langfuse.trace.input', body['input'])
106
184
  add_json_attr(attributes, 'langfuse.trace.output', body['output'])
107
185
  add_json_attr(attributes, 'langfuse.trace.metadata', body['metadata'])
@@ -186,44 +264,6 @@ module Langfuse
186
264
  span
187
265
  end
188
266
 
189
- # Build a minimal OTEL span for a score event.
190
- def build_score_span(body)
191
- trace_id_raw = body['traceId']
192
- return nil unless trace_id_raw
193
-
194
- trace_id = to_otel_trace_id(trace_id_raw)
195
- span_id = to_otel_span_id(body['id'] || SecureRandom.uuid)
196
- timestamp = to_unix_nano(body['timestamp'] || Time.now.utc.iso8601(3))
197
-
198
- attributes = []
199
- add_attr(attributes, 'langfuse.score.name', body['name'])
200
- add_attr(attributes, 'langfuse.score.value', body['value'])
201
- add_attr(attributes, 'langfuse.score.data_type', body['dataType'])
202
- add_attr(attributes, 'langfuse.score.comment', body['comment'])
203
- add_attr(attributes, 'langfuse.observation.type', 'score')
204
-
205
- if body['observationId']
206
- add_attr(attributes, 'langfuse.score.observation_id', body['observationId'])
207
- end
208
-
209
- span = {
210
- traceId: trace_id,
211
- spanId: span_id,
212
- name: "score-#{body['name']}",
213
- kind: 1,
214
- startTimeUnixNano: timestamp,
215
- endTimeUnixNano: timestamp,
216
- attributes: attributes,
217
- status: { code: 1 }
218
- }
219
-
220
- # Parent is either the observation or the trace
221
- parent_raw = body['observationId'] || trace_id_raw
222
- span[:parentSpanId] = to_otel_span_id(parent_raw) if parent_raw
223
-
224
- span
225
- end
226
-
227
267
  # Build OTEL attributes for a span/generation observation.
228
268
  def build_observation_attributes(body, obs_type)
229
269
  attributes = []
@@ -234,6 +274,7 @@ module Langfuse
234
274
  add_json_attr(attributes, 'langfuse.observation.metadata', body['metadata'])
235
275
  add_attr(attributes, 'langfuse.observation.level', body['level'])
236
276
  add_attr(attributes, 'langfuse.observation.status_message', body['statusMessage'])
277
+ add_attr(attributes, 'langfuse.environment', body['environment'])
237
278
 
238
279
  if obs_type == 'generation'
239
280
  add_generation_attributes(attributes, body)
@@ -253,33 +294,71 @@ module Langfuse
253
294
  end
254
295
  end
255
296
 
256
- usage = body['usage']
257
- if usage.is_a?(Hash)
258
- add_attr(attributes, 'gen_ai.usage.prompt_tokens', usage['promptTokens'] || usage['prompt_tokens'])
259
- add_attr(attributes, 'gen_ai.usage.completion_tokens', usage['completionTokens'] || usage['completion_tokens'])
260
- total = usage['totalTokens'] || usage['total_tokens']
261
- add_attr(attributes, 'gen_ai.usage.total_tokens', total) if total
297
+ add_usage_attributes(attributes, body)
298
+ add_json_attr(attributes, 'langfuse.observation.cost_details', body['costDetails'])
299
+ add_attr(attributes, 'langfuse.observation.prompt.name', body['promptName'])
300
+ add_attr(attributes, 'langfuse.observation.prompt.version', body['promptVersion'])
301
+ add_attr(attributes, 'langfuse.observation.completion_start_time', body['completionStartTime'])
302
+ end
303
+
304
+ # Emit token usage both as gen_ai.* semantic conventions and as the Langfuse
305
+ # v4 usage_details model. usage_details is what v4 uses for cost, so a legacy
306
+ # `usage` object is normalized into it when no explicit usage_details exists.
307
+ def add_usage_attributes(attributes, body)
308
+ usage = normalize_legacy_usage(body['usage'])
309
+
310
+ if usage
311
+ add_attr(attributes, 'gen_ai.usage.prompt_tokens', usage[:input])
312
+ add_attr(attributes, 'gen_ai.usage.completion_tokens', usage[:output])
313
+ add_attr(attributes, 'gen_ai.usage.total_tokens', usage[:total])
314
+ end
315
+
316
+ usage_details = body['usageDetails']
317
+ usage_details = usage if blank_value?(usage_details)
318
+ add_json_attr(attributes, 'langfuse.observation.usage_details', usage_details)
319
+ end
320
+
321
+ # Accept every shape the legacy ingestion API allowed
322
+ # (promptTokens / inputTokens / input) and return {input:, output:, total:}.
323
+ # Non-token units are skipped: usage_details is token-based, so mapping them
324
+ # would produce wrong cost numbers.
325
+ def normalize_legacy_usage(usage)
326
+ return nil unless usage.is_a?(Hash) && !usage.empty?
327
+
328
+ unit = usage['unit'] || usage[:unit]
329
+ if unit && unit.to_s.upcase != 'TOKENS'
330
+ log_debug { "Skipping usage with unit #{unit}; use usage_details for non-token usage" }
331
+ return nil
262
332
  end
263
333
 
264
- add_attr(attributes, 'langfuse.observation.completion_start_time', body['completionStartTime'])
334
+ normalized = {
335
+ input: fetch_usage_value(usage, USAGE_INPUT_KEYS),
336
+ output: fetch_usage_value(usage, USAGE_OUTPUT_KEYS),
337
+ total: fetch_usage_value(usage, USAGE_TOTAL_KEYS)
338
+ }.compact
339
+
340
+ normalized.empty? ? nil : normalized
265
341
  end
266
342
 
267
- # Convert a UUID string to OTEL 32-char hex trace ID.
268
- # OTEL trace IDs are 16 bytes (32 hex chars).
269
- def to_otel_trace_id(uuid_str)
270
- return '0' * 32 unless uuid_str
343
+ def fetch_usage_value(usage, keys)
344
+ keys.each do |key|
345
+ value = usage[key]
346
+ return value unless value.nil?
347
+ end
348
+
349
+ nil
350
+ end
271
351
 
272
- hex = uuid_str.to_s.delete('-')
273
- hex.ljust(32, '0')[0, 32]
352
+ def blank_value?(value)
353
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
274
354
  end
275
355
 
276
- # Convert a UUID string to OTEL 16-char hex span ID.
277
- # OTEL span IDs are 8 bytes (16 hex chars).
278
- def to_otel_span_id(uuid_str)
279
- return '0' * 16 unless uuid_str
356
+ def to_otel_trace_id(id_str)
357
+ self.class.to_otel_trace_id(id_str)
358
+ end
280
359
 
281
- hex = uuid_str.to_s.delete('-')
282
- hex[0, 16]
360
+ def to_otel_span_id(id_str)
361
+ self.class.to_otel_span_id(id_str)
283
362
  end
284
363
 
285
364
  # Convert an ISO8601 timestamp string to nanoseconds since epoch.
@@ -287,14 +366,17 @@ module Langfuse
287
366
  return '0' unless timestamp_str
288
367
 
289
368
  time = Time.parse(timestamp_str.to_s)
290
- ((time.to_f * 1_000_000_000).to_i).to_s
369
+ ((time.to_i * 1_000_000_000) + time.nsec).to_s
291
370
  rescue ArgumentError
292
371
  '0'
293
372
  end
294
373
 
295
374
  # Add a string/numeric attribute to the attributes array.
375
+ # Structured values are JSON-encoded instead of falling back to Ruby's
376
+ # inspect format (which is not machine-readable on the Langfuse side).
296
377
  def add_attr(attributes, key, value)
297
378
  return if value.nil?
379
+ return add_json_attr(attributes, key, value) if value.is_a?(Hash) || value.is_a?(Array)
298
380
 
299
381
  otel_value = case value
300
382
  when String
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Update events carry only the fields that changed. The ingestion API merges an
5
+ # update into the existing trace/observation, so re-sending a generation's
6
+ # input on every `end` call would double the payload of every LLM call.
7
+ module PartialUpdates
8
+ # Always sent so the server can resolve (and if needed upsert) the entity.
9
+ # `to_dict#slice` ignores the keys a given class does not have.
10
+ UPDATE_IDENTITY_FIELDS = %i[id trace_id type].freeze
11
+
12
+ private
13
+
14
+ # `changes` maps a body field to the value passed to update/end; nil means
15
+ # "not provided" and is left out of the update body. `extra_keys` carries the
16
+ # caller's **kwargs, which are merged into the body by to_dict.
17
+ def track_changes(changes, extra_keys = nil)
18
+ keys = changes.compact.keys
19
+ keys.concat(extra_keys.to_a)
20
+ @changed_fields = keys
21
+ end
22
+
23
+ def update_body
24
+ # Never tracked → this is a create path; use the full body.
25
+ return to_dict if @changed_fields.nil?
26
+
27
+ to_dict.slice(*UPDATE_IDENTITY_FIELDS, *@changed_fields)
28
+ end
29
+ end
30
+ end