langfuse-ruby 0.1.6 → 0.1.7
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/release.yml +2 -0
- data/Gemfile.lock +1 -1
- data/Makefile +3 -3
- data/README.md +30 -0
- data/lib/langfuse/client.rb +55 -4
- data/lib/langfuse/otel_exporter.rb +333 -0
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +4 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98dc87bcfe8834aa485a810f1d955216a0c17e74a6b058f42f76539350b5c542
|
|
4
|
+
data.tar.gz: b826b87fc097c83637969f81a0aa0ff93e520c63a73519c78bd84cee3258af16
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80b9e696b8157772731a519712087f19e751caf72009a87995c5684793a9fba1d507ab38eaedccd31ce5fac9280634d07ce7646454a7854383312891a076c525
|
|
7
|
+
data.tar.gz: a05b595c3cc2e437511dbafa44df1331b5d64802f2008a51797419cb1eba9691ec7edef68435f55a972dff4b699b449f57603ef1161a7e73127d22a7c346a799
|
data/Gemfile.lock
CHANGED
data/Makefile
CHANGED
|
@@ -26,7 +26,7 @@ lint-fix: ## Run RuboCop with auto-correct
|
|
|
26
26
|
build: ## Build the gem
|
|
27
27
|
bundle exec rake build
|
|
28
28
|
|
|
29
|
-
release: tag
|
|
29
|
+
release: tag ## Release: tag + build + push to RubyGems
|
|
30
30
|
bundle exec rake release_gem
|
|
31
31
|
|
|
32
32
|
clean: ## Remove built gem files
|
|
@@ -39,7 +39,7 @@ console: ## Start an IRB console with the gem loaded
|
|
|
39
39
|
tag: ## Create and push a version tag. Usage: make tag [VERSION=x.y.z]
|
|
40
40
|
@git fetch --tags; \
|
|
41
41
|
if [ -z "$(VERSION)" ]; then \
|
|
42
|
-
LATEST=$$(git tag -l 'v*' --sort=-v:refname | head -n1); \
|
|
42
|
+
LATEST=$$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1); \
|
|
43
43
|
if [ -z "$$LATEST" ]; then \
|
|
44
44
|
NEW_TAG="v0.0.1"; \
|
|
45
45
|
else \
|
|
@@ -51,7 +51,7 @@ tag: ## Create and push a version tag. Usage: make tag [VERSION=x.y.z]
|
|
|
51
51
|
fi; \
|
|
52
52
|
else \
|
|
53
53
|
NEW_TAG="v$(VERSION)"; \
|
|
54
|
-
LATEST=$$(git tag -l 'v*' --sort=-v:refname | head -n1); \
|
|
54
|
+
LATEST=$$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1); \
|
|
55
55
|
if [ "$$LATEST" = "$$NEW_TAG" ]; then \
|
|
56
56
|
echo "Tag $$NEW_TAG already exists on remote, deleting and re-pushing..."; \
|
|
57
57
|
git tag -d "$$NEW_TAG" 2>/dev/null || true; \
|
data/README.md
CHANGED
|
@@ -58,6 +58,36 @@ end
|
|
|
58
58
|
client = Langfuse.new
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
### OpenTelemetry (OTEL) Ingestion Mode
|
|
62
|
+
|
|
63
|
+
Langfuse v4 introduces a faster data model powered by OpenTelemetry. To use
|
|
64
|
+
it, enable the OTEL ingestion mode. This sends data via the OTLP/HTTP JSON
|
|
65
|
+
endpoint (`/api/public/otel/v1/traces`) with the `x-langfuse-ingestion-version: 4`
|
|
66
|
+
header for real-time ingestion and observation-level online evaluators.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# Via constructor
|
|
70
|
+
client = Langfuse.new(
|
|
71
|
+
public_key: "pk-lf-...",
|
|
72
|
+
secret_key: "sk-lf-...",
|
|
73
|
+
ingestion_mode: :otel
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Via global configuration
|
|
77
|
+
Langfuse.configure do |config|
|
|
78
|
+
config.public_key = "pk-lf-..."
|
|
79
|
+
config.secret_key = "sk-lf-..."
|
|
80
|
+
config.ingestion_mode = :otel
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Via environment variable
|
|
84
|
+
# LANGFUSE_INGESTION_MODE=otel
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
All existing tracing APIs work unchanged. The SDK maps Langfuse events to
|
|
88
|
+
OpenTelemetry spans with the appropriate `langfuse.*` and `gen_ai.*` attributes.
|
|
89
|
+
No additional dependencies are required.
|
|
90
|
+
|
|
61
91
|
### 2. Basic Tracing
|
|
62
92
|
|
|
63
93
|
```ruby
|
data/lib/langfuse/client.rb
CHANGED
|
@@ -9,10 +9,11 @@ require 'concurrent'
|
|
|
9
9
|
|
|
10
10
|
module Langfuse
|
|
11
11
|
class Client
|
|
12
|
-
attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush
|
|
12
|
+
attr_reader :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
|
|
13
|
+
:ingestion_mode
|
|
13
14
|
|
|
14
15
|
def initialize(public_key: nil, secret_key: nil, host: nil, debug: false, timeout: 30, retries: 3,
|
|
15
|
-
flush_interval: nil, auto_flush: nil)
|
|
16
|
+
flush_interval: nil, auto_flush: nil, ingestion_mode: nil)
|
|
16
17
|
@public_key = public_key || ENV['LANGFUSE_PUBLIC_KEY'] || Langfuse.configuration.public_key
|
|
17
18
|
@secret_key = secret_key || ENV['LANGFUSE_SECRET_KEY'] || Langfuse.configuration.secret_key
|
|
18
19
|
@host = host || ENV['LANGFUSE_HOST'] || Langfuse.configuration.host
|
|
@@ -25,11 +26,14 @@ module Langfuse
|
|
|
25
26
|
else
|
|
26
27
|
auto_flush
|
|
27
28
|
end
|
|
29
|
+
@ingestion_mode = resolve_ingestion_mode(ingestion_mode)
|
|
28
30
|
|
|
29
31
|
raise AuthenticationError, 'Public key is required' unless @public_key
|
|
30
32
|
raise AuthenticationError, 'Secret key is required' unless @secret_key
|
|
31
33
|
|
|
32
34
|
@connection = build_connection
|
|
35
|
+
@otel_connection = build_otel_connection if @ingestion_mode == :otel
|
|
36
|
+
@otel_exporter = OtelExporter.new(connection: @otel_connection, debug: @debug) if @ingestion_mode == :otel
|
|
33
37
|
@event_queue = Concurrent::Array.new
|
|
34
38
|
@flush_thread = start_flush_thread if @auto_flush
|
|
35
39
|
end
|
|
@@ -452,16 +456,38 @@ module Langfuse
|
|
|
452
456
|
return
|
|
453
457
|
end
|
|
454
458
|
|
|
459
|
+
if @ingestion_mode == :otel
|
|
460
|
+
send_batch_otel(valid_events)
|
|
461
|
+
else
|
|
462
|
+
send_batch_legacy(valid_events)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def send_batch_legacy(valid_events)
|
|
455
467
|
batch_data = build_batch_data(valid_events)
|
|
456
468
|
puts "Sending batch data: #{batch_data}" if @debug
|
|
457
469
|
|
|
458
470
|
begin
|
|
459
471
|
response = post('/api/public/ingestion', batch_data)
|
|
460
|
-
puts "Flushed #{valid_events.length} events" if @debug
|
|
472
|
+
puts "Flushed #{valid_events.length} events (legacy)" if @debug
|
|
461
473
|
response
|
|
462
474
|
rescue StandardError => e
|
|
463
475
|
puts "Failed to flush events: #{e.message}" if @debug
|
|
464
|
-
|
|
476
|
+
valid_events.each { |event| @event_queue << event }
|
|
477
|
+
raise
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def send_batch_otel(valid_events)
|
|
482
|
+
puts "Sending #{valid_events.length} events via OTEL" if @debug
|
|
483
|
+
|
|
484
|
+
begin
|
|
485
|
+
response = @otel_exporter.export(valid_events)
|
|
486
|
+
handle_response(response)
|
|
487
|
+
puts "Flushed #{valid_events.length} events (otel)" if @debug
|
|
488
|
+
response
|
|
489
|
+
rescue StandardError => e
|
|
490
|
+
puts "Failed to flush OTEL events: #{e.message}" if @debug
|
|
465
491
|
valid_events.each { |event| @event_queue << event }
|
|
466
492
|
raise
|
|
467
493
|
end
|
|
@@ -493,6 +519,15 @@ module Langfuse
|
|
|
493
519
|
end
|
|
494
520
|
end
|
|
495
521
|
|
|
522
|
+
def resolve_ingestion_mode(explicit_mode)
|
|
523
|
+
return explicit_mode.to_sym if explicit_mode
|
|
524
|
+
|
|
525
|
+
env_mode = ENV['LANGFUSE_INGESTION_MODE']
|
|
526
|
+
return env_mode.to_sym if env_mode && !env_mode.empty?
|
|
527
|
+
|
|
528
|
+
Langfuse.configuration.ingestion_mode || :legacy
|
|
529
|
+
end
|
|
530
|
+
|
|
496
531
|
def build_connection
|
|
497
532
|
Faraday.new(url: @host) do |conn|
|
|
498
533
|
# 配置请求和响应处理
|
|
@@ -516,6 +551,22 @@ module Langfuse
|
|
|
516
551
|
end
|
|
517
552
|
end
|
|
518
553
|
|
|
554
|
+
# Build a separate Faraday connection for OTEL with the v4 ingestion header.
|
|
555
|
+
def build_otel_connection
|
|
556
|
+
Faraday.new(url: @host) do |conn|
|
|
557
|
+
conn.response :json, content_type: /\bjson$/
|
|
558
|
+
|
|
559
|
+
conn.headers['User-Agent'] = "langfuse-ruby/#{Langfuse::VERSION}"
|
|
560
|
+
conn.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
|
|
561
|
+
conn.headers['x-langfuse-ingestion-version'] = '4'
|
|
562
|
+
conn.headers['Content-Type'] = 'application/json'
|
|
563
|
+
|
|
564
|
+
conn.options.timeout = @timeout
|
|
565
|
+
conn.response :logger if @debug
|
|
566
|
+
conn.adapter Faraday.default_adapter
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
519
570
|
# HTTP methods
|
|
520
571
|
def get(path, params = {})
|
|
521
572
|
request(:get, path, params: params)
|
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
# @param connection [Faraday::Connection] HTTP connection to Langfuse host
|
|
13
|
+
# @param debug [Boolean] whether to print debug output
|
|
14
|
+
def initialize(connection:, debug: false)
|
|
15
|
+
@connection = connection
|
|
16
|
+
@debug = debug
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Export a batch of Langfuse events as OTLP spans.
|
|
20
|
+
# @param events [Array<Hash>] array of event hashes from the event queue
|
|
21
|
+
# @return [Faraday::Response]
|
|
22
|
+
def export(events)
|
|
23
|
+
resource_spans = build_resource_spans(events)
|
|
24
|
+
payload = { resourceSpans: resource_spans }
|
|
25
|
+
|
|
26
|
+
puts "OTEL export payload: #{JSON.pretty_generate(payload)}" if @debug
|
|
27
|
+
|
|
28
|
+
@connection.post(OTEL_ENDPOINT) do |req|
|
|
29
|
+
req.headers['Content-Type'] = 'application/json'
|
|
30
|
+
req.body = JSON.generate(payload)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Build the top-level resourceSpans array from events.
|
|
37
|
+
# Groups events by trace_id, producing one scopeSpan per trace.
|
|
38
|
+
def build_resource_spans(events)
|
|
39
|
+
grouped = group_events_by_trace(events)
|
|
40
|
+
|
|
41
|
+
scope_spans = grouped.map do |_trace_id, trace_events|
|
|
42
|
+
spans = trace_events.filter_map { |event| convert_event_to_span(event) }
|
|
43
|
+
next if spans.empty?
|
|
44
|
+
|
|
45
|
+
{ scope: { name: 'langfuse-ruby', version: Langfuse::VERSION }, spans: spans }
|
|
46
|
+
end.compact
|
|
47
|
+
|
|
48
|
+
return [] if scope_spans.empty?
|
|
49
|
+
|
|
50
|
+
[{
|
|
51
|
+
resource: {
|
|
52
|
+
attributes: [
|
|
53
|
+
{ key: 'service.name', value: { stringValue: 'langfuse-ruby' } },
|
|
54
|
+
{ key: 'telemetry.sdk.name', value: { stringValue: 'langfuse-ruby' } },
|
|
55
|
+
{ key: 'telemetry.sdk.version', value: { stringValue: Langfuse::VERSION } }
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
scopeSpans: scope_spans
|
|
59
|
+
}]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Group events by their trace ID for proper OTEL span hierarchy.
|
|
63
|
+
def group_events_by_trace(events)
|
|
64
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
65
|
+
|
|
66
|
+
events.each do |event|
|
|
67
|
+
body = event[:body] || {}
|
|
68
|
+
trace_id = body['traceId'] || body['trace_id'] || body['id'] || 'unknown'
|
|
69
|
+
groups[trace_id] << event
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
groups
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Convert a single Langfuse event to an OTLP span hash, or nil if not convertible.
|
|
76
|
+
def convert_event_to_span(event)
|
|
77
|
+
type = event[:type]
|
|
78
|
+
body = event[:body] || {}
|
|
79
|
+
|
|
80
|
+
case type
|
|
81
|
+
when 'trace-create'
|
|
82
|
+
build_trace_span(body)
|
|
83
|
+
when 'span-create', 'span-update'
|
|
84
|
+
build_observation_span(body, 'span')
|
|
85
|
+
when 'generation-create', 'generation-update'
|
|
86
|
+
build_observation_span(body, 'generation')
|
|
87
|
+
when 'event-create'
|
|
88
|
+
build_event_span(body)
|
|
89
|
+
when 'score-create'
|
|
90
|
+
build_score_span(body)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build a root OTEL span for a Langfuse trace.
|
|
95
|
+
def build_trace_span(body)
|
|
96
|
+
trace_id = to_otel_trace_id(body['id'])
|
|
97
|
+
span_id = to_otel_span_id(body['id'])
|
|
98
|
+
|
|
99
|
+
attributes = []
|
|
100
|
+
add_attr(attributes, 'langfuse.trace.name', body['name'])
|
|
101
|
+
add_attr(attributes, 'langfuse.user.id', body['userId'])
|
|
102
|
+
add_attr(attributes, 'langfuse.session.id', body['sessionId'])
|
|
103
|
+
add_attr(attributes, 'langfuse.release', body['release'])
|
|
104
|
+
add_attr(attributes, 'langfuse.version', body['version'])
|
|
105
|
+
add_json_attr(attributes, 'langfuse.trace.input', body['input'])
|
|
106
|
+
add_json_attr(attributes, 'langfuse.trace.output', body['output'])
|
|
107
|
+
add_json_attr(attributes, 'langfuse.trace.metadata', body['metadata'])
|
|
108
|
+
|
|
109
|
+
tags = body['tags']
|
|
110
|
+
if tags.is_a?(Array) && !tags.empty?
|
|
111
|
+
add_array_attr(attributes, 'langfuse.trace.tags', tags)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
traceId: trace_id,
|
|
116
|
+
spanId: span_id,
|
|
117
|
+
name: body['name'] || 'trace',
|
|
118
|
+
kind: 1, # SPAN_KIND_INTERNAL
|
|
119
|
+
startTimeUnixNano: to_unix_nano(body['timestamp']),
|
|
120
|
+
endTimeUnixNano: to_unix_nano(body['timestamp']),
|
|
121
|
+
attributes: attributes,
|
|
122
|
+
status: { code: 1 } # STATUS_CODE_OK
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build an OTEL span for a Langfuse span or generation observation.
|
|
127
|
+
def build_observation_span(body, obs_type)
|
|
128
|
+
trace_id = to_otel_trace_id(body['traceId'])
|
|
129
|
+
span_id = to_otel_span_id(body['id'])
|
|
130
|
+
|
|
131
|
+
span = {
|
|
132
|
+
traceId: trace_id,
|
|
133
|
+
spanId: span_id,
|
|
134
|
+
name: body['name'] || obs_type,
|
|
135
|
+
kind: 1, # SPAN_KIND_INTERNAL
|
|
136
|
+
startTimeUnixNano: to_unix_nano(body['startTime']),
|
|
137
|
+
endTimeUnixNano: to_unix_nano(body['endTime'] || body['startTime']),
|
|
138
|
+
attributes: build_observation_attributes(body, obs_type),
|
|
139
|
+
status: { code: 1 }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Set parent span ID
|
|
143
|
+
parent_id = body['parentObservationId']
|
|
144
|
+
if parent_id
|
|
145
|
+
span[:parentSpanId] = to_otel_span_id(parent_id)
|
|
146
|
+
else
|
|
147
|
+
# Parent is the trace root span
|
|
148
|
+
span[:parentSpanId] = to_otel_span_id(body['traceId'])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
span
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Build an OTEL span for a Langfuse event (zero-duration span).
|
|
155
|
+
def build_event_span(body)
|
|
156
|
+
trace_id = to_otel_trace_id(body['traceId'])
|
|
157
|
+
span_id = to_otel_span_id(body['id'])
|
|
158
|
+
timestamp = to_unix_nano(body['startTime'])
|
|
159
|
+
|
|
160
|
+
attributes = []
|
|
161
|
+
add_attr(attributes, 'langfuse.observation.type', 'event')
|
|
162
|
+
add_json_attr(attributes, 'langfuse.observation.input', body['input'])
|
|
163
|
+
add_json_attr(attributes, 'langfuse.observation.output', body['output'])
|
|
164
|
+
add_json_attr(attributes, 'langfuse.observation.metadata', body['metadata'])
|
|
165
|
+
add_attr(attributes, 'langfuse.observation.level', body['level'])
|
|
166
|
+
add_attr(attributes, 'langfuse.observation.status_message', body['statusMessage'])
|
|
167
|
+
|
|
168
|
+
span = {
|
|
169
|
+
traceId: trace_id,
|
|
170
|
+
spanId: span_id,
|
|
171
|
+
name: body['name'] || 'event',
|
|
172
|
+
kind: 1,
|
|
173
|
+
startTimeUnixNano: timestamp,
|
|
174
|
+
endTimeUnixNano: timestamp,
|
|
175
|
+
attributes: attributes,
|
|
176
|
+
status: { code: 1 }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
parent_id = body['parentObservationId']
|
|
180
|
+
if parent_id
|
|
181
|
+
span[:parentSpanId] = to_otel_span_id(parent_id)
|
|
182
|
+
elsif body['traceId']
|
|
183
|
+
span[:parentSpanId] = to_otel_span_id(body['traceId'])
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
span
|
|
187
|
+
end
|
|
188
|
+
|
|
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
|
+
# Build OTEL attributes for a span/generation observation.
|
|
228
|
+
def build_observation_attributes(body, obs_type)
|
|
229
|
+
attributes = []
|
|
230
|
+
effective_type = body['type'] || obs_type
|
|
231
|
+
add_attr(attributes, 'langfuse.observation.type', effective_type)
|
|
232
|
+
add_json_attr(attributes, 'langfuse.observation.input', body['input'])
|
|
233
|
+
add_json_attr(attributes, 'langfuse.observation.output', body['output'])
|
|
234
|
+
add_json_attr(attributes, 'langfuse.observation.metadata', body['metadata'])
|
|
235
|
+
add_attr(attributes, 'langfuse.observation.level', body['level'])
|
|
236
|
+
add_attr(attributes, 'langfuse.observation.status_message', body['statusMessage'])
|
|
237
|
+
|
|
238
|
+
if obs_type == 'generation'
|
|
239
|
+
add_generation_attributes(attributes, body)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
attributes
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Add generation-specific gen_ai.* attributes.
|
|
246
|
+
def add_generation_attributes(attributes, body)
|
|
247
|
+
add_attr(attributes, 'gen_ai.request.model', body['model'])
|
|
248
|
+
|
|
249
|
+
model_params = body['modelParameters']
|
|
250
|
+
if model_params.is_a?(Hash)
|
|
251
|
+
model_params.each do |key, value|
|
|
252
|
+
add_attr(attributes, "gen_ai.request.#{key}", value) unless value.nil?
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
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
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
add_attr(attributes, 'langfuse.observation.completion_start_time', body['completionStartTime'])
|
|
265
|
+
end
|
|
266
|
+
|
|
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
|
|
271
|
+
|
|
272
|
+
hex = uuid_str.to_s.delete('-')
|
|
273
|
+
hex.ljust(32, '0')[0, 32]
|
|
274
|
+
end
|
|
275
|
+
|
|
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
|
|
280
|
+
|
|
281
|
+
hex = uuid_str.to_s.delete('-')
|
|
282
|
+
hex[0, 16]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Convert an ISO8601 timestamp string to nanoseconds since epoch.
|
|
286
|
+
def to_unix_nano(timestamp_str)
|
|
287
|
+
return '0' unless timestamp_str
|
|
288
|
+
|
|
289
|
+
time = Time.parse(timestamp_str.to_s)
|
|
290
|
+
((time.to_f * 1_000_000_000).to_i).to_s
|
|
291
|
+
rescue ArgumentError
|
|
292
|
+
'0'
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Add a string/numeric attribute to the attributes array.
|
|
296
|
+
def add_attr(attributes, key, value)
|
|
297
|
+
return if value.nil?
|
|
298
|
+
|
|
299
|
+
otel_value = case value
|
|
300
|
+
when String
|
|
301
|
+
{ stringValue: value }
|
|
302
|
+
when Integer
|
|
303
|
+
{ intValue: value.to_s }
|
|
304
|
+
when Float
|
|
305
|
+
{ doubleValue: value }
|
|
306
|
+
when TrueClass, FalseClass
|
|
307
|
+
{ boolValue: value }
|
|
308
|
+
else
|
|
309
|
+
{ stringValue: value.to_s }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
attributes << { key: key, value: otel_value }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Add a JSON-serialized attribute (for complex objects like input/output).
|
|
316
|
+
def add_json_attr(attributes, key, value)
|
|
317
|
+
return if value.nil?
|
|
318
|
+
return if value.is_a?(Hash) && value.empty?
|
|
319
|
+
return if value.is_a?(Array) && value.empty?
|
|
320
|
+
|
|
321
|
+
json_str = value.is_a?(String) ? value : JSON.generate(value)
|
|
322
|
+
attributes << { key: key, value: { stringValue: json_str } }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Add an array attribute for tags.
|
|
326
|
+
def add_array_attr(attributes, key, values)
|
|
327
|
+
return if values.nil? || values.empty?
|
|
328
|
+
|
|
329
|
+
array_values = values.map { |v| { stringValue: v.to_s } }
|
|
330
|
+
attributes << { key: key, value: { arrayValue: { values: array_values } } }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
data/lib/langfuse/version.rb
CHANGED
data/lib/langfuse.rb
CHANGED
|
@@ -12,6 +12,7 @@ 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
|
|
@@ -157,7 +158,8 @@ module Langfuse
|
|
|
157
158
|
|
|
158
159
|
# Configuration class for Langfuse client settings
|
|
159
160
|
class Configuration
|
|
160
|
-
attr_accessor :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush
|
|
161
|
+
attr_accessor :public_key, :secret_key, :host, :debug, :timeout, :retries, :flush_interval, :auto_flush,
|
|
162
|
+
:ingestion_mode
|
|
161
163
|
|
|
162
164
|
def initialize
|
|
163
165
|
@public_key = nil
|
|
@@ -168,6 +170,7 @@ module Langfuse
|
|
|
168
170
|
@retries = 3
|
|
169
171
|
@flush_interval = 5
|
|
170
172
|
@auto_flush = true
|
|
173
|
+
@ingestion_mode = :legacy # :legacy or :otel
|
|
171
174
|
end
|
|
172
175
|
end
|
|
173
176
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: langfuse-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Richard Sun
|
|
@@ -229,6 +229,7 @@ files:
|
|
|
229
229
|
- lib/langfuse/generation.rb
|
|
230
230
|
- lib/langfuse/null_objects.rb
|
|
231
231
|
- lib/langfuse/observation_types.rb
|
|
232
|
+
- lib/langfuse/otel_exporter.rb
|
|
232
233
|
- lib/langfuse/prompt.rb
|
|
233
234
|
- lib/langfuse/span.rb
|
|
234
235
|
- lib/langfuse/trace.rb
|