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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +21 -10
- data/.github/workflows/release.yml +4 -2
- data/.mise.toml +8 -0
- data/.rubocop.yml +2 -2
- data/CHANGELOG.md +34 -10
- data/CLAUDE.md +21 -9
- data/Gemfile +1 -0
- data/Gemfile.lock +15 -5
- data/Makefile +8 -4
- data/README.md +171 -9
- data/langfuse-ruby.gemspec +2 -0
- data/lib/langfuse/client.rb +384 -62
- data/lib/langfuse/generation.rb +36 -4
- data/lib/langfuse/otel_exporter.rb +325 -0
- data/lib/langfuse/span.rb +1 -0
- data/lib/langfuse/trace.rb +16 -6
- data/lib/langfuse/utils.rb +32 -0
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +17 -6
- metadata +32 -2
data/lib/langfuse/generation.rb
CHANGED
|
@@ -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, :
|
|
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,
|
|
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,
|
|
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
data/lib/langfuse/trace.rb
CHANGED
|
@@ -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,
|
|
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)
|
data/lib/langfuse/utils.rb
CHANGED
|
@@ -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)
|
data/lib/langfuse/version.rb
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|