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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +21 -10
- data/.github/workflows/release.yml +2 -2
- data/.mise.toml +8 -0
- data/.rubocop.yml +2 -2
- data/CHANGELOG.md +78 -10
- data/CLAUDE.md +54 -16
- data/Gemfile +1 -0
- data/Gemfile.lock +15 -5
- data/Makefile +6 -5
- data/README.md +338 -89
- data/Rakefile +0 -6
- data/docs/FINAL_SUMMARY.md +12 -185
- data/docs/PUBLISH_GUIDE.md +36 -272
- data/docs/README.md +12 -22
- data/docs/RELEASE_CHECKLIST.md +20 -145
- data/docs/V4.md +159 -0
- data/examples/basic_tracing.rb +11 -10
- data/examples/simplified_usage.rb +7 -6
- data/examples/v4_otel_tracing.rb +70 -0
- data/langfuse-ruby.gemspec +2 -0
- data/lib/langfuse/client.rb +757 -276
- data/lib/langfuse/event.rb +1 -17
- data/lib/langfuse/generation.rb +71 -124
- data/lib/langfuse/null_objects.rb +4 -0
- data/lib/langfuse/otel_exporter.rb +145 -63
- data/lib/langfuse/partial_updates.rb +30 -0
- data/lib/langfuse/prompt.rb +9 -83
- data/lib/langfuse/prompt_cache.rb +65 -0
- data/lib/langfuse/span.rb +29 -153
- data/lib/langfuse/span_wrappers.rb +32 -0
- data/lib/langfuse/template_compiler.rb +56 -0
- data/lib/langfuse/trace.rb +38 -164
- data/lib/langfuse/utils.rb +54 -20
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +61 -56
- data/scripts/release.sh +12 -12
- metadata +37 -2
data/lib/langfuse/event.rb
CHANGED
|
@@ -57,23 +57,7 @@ module Langfuse
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def create_event
|
|
60
|
-
|
|
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
|
data/lib/langfuse/generation.rb
CHANGED
|
@@ -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, :
|
|
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,
|
|
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,
|
|
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
|
|
41
|
-
@end_time = end_time
|
|
42
|
-
@completion_start_time = completion_start_time
|
|
43
|
-
@model = 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
|
|
46
|
-
@output = 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
|
|
50
|
-
@status_message = status_message
|
|
51
|
-
@version = 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
|
|
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
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
352
|
+
def blank_value?(value)
|
|
353
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
274
354
|
end
|
|
275
355
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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.
|
|
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
|