langfuse-ruby 0.1.5 → 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.
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Langfuse
2
4
  class Prompt
3
5
  attr_reader :id, :name, :version, :prompt, :config, :labels, :tags, :type, :created_at, :updated_at
@@ -148,8 +150,6 @@ module Langfuse
148
150
  new(template: template, input_variables: variables)
149
151
  end
150
152
 
151
- private
152
-
153
153
  def self.extract_variables(text)
154
154
  variables = []
155
155
 
data/lib/langfuse/span.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Langfuse
2
4
  class Span
3
5
  attr_reader :id, :trace_id, :name, :start_time, :end_time, :input, :output,
@@ -286,9 +288,7 @@ module Langfuse
286
288
  return nil if type.nil?
287
289
 
288
290
  type_str = type.to_s
289
- unless ObservationType.valid?(type_str)
290
- raise ValidationError, "Invalid observation type: #{type}. Valid types are: #{ObservationType::ALL.join(', ')}"
291
- end
291
+ raise ValidationError, "Invalid observation type: #{type}. Valid types are: #{ObservationType::ALL.join(', ')}" unless ObservationType.valid?(type_str)
292
292
 
293
293
  type_str
294
294
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Langfuse
2
4
  class Trace
3
5
  attr_reader :id, :name, :user_id, :session_id, :version, :release, :input, :output,
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'securerandom'
2
4
  require 'time'
3
5
  require 'erb'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Langfuse
2
- VERSION = "0.1.5"
4
+ VERSION = '0.1.7'
3
5
  end
data/lib/langfuse.rb CHANGED
@@ -11,6 +11,8 @@ require_relative 'langfuse/prompt'
11
11
  require_relative 'langfuse/evaluation'
12
12
  require_relative 'langfuse/errors'
13
13
  require_relative 'langfuse/utils'
14
+ require_relative 'langfuse/null_objects'
15
+ require_relative 'langfuse/otel_exporter'
14
16
 
15
17
  # Ruby SDK for Langfuse - Open source LLM engineering platform
16
18
  module Langfuse
@@ -25,14 +27,139 @@ module Langfuse
25
27
  end
26
28
 
27
29
  # Create a new Langfuse client instance
28
- def new(**options)
29
- Client.new(**options)
30
+ def new(**kwargs)
31
+ Client.new(**kwargs)
32
+ end
33
+
34
+ # Get a thread-safe singleton client instance
35
+ # @return [Client] Langfuse client
36
+ def client
37
+ Thread.current[:langfuse_client] ||= Client.new
38
+ end
39
+
40
+ # Get a prompt and optionally compile it with variables
41
+ # @param prompt_name [String] prompt name
42
+ # @param variables [Hash] optional variables for compilation
43
+ # @param label [String] optional prompt label (defaults to 'production' or 'latest')
44
+ # @param version [Integer] optional prompt version
45
+ # @param cache_ttl_seconds [Integer] cache TTL in seconds (default: 60)
46
+ # @param retries [Integer] number of retries on failure (default: 2)
47
+ # @return [String, Prompt, nil] compiled prompt string if variables provided, Prompt object otherwise, nil on failure
48
+ def get_prompt(prompt_name, variables: nil, label: nil, version: nil, cache_ttl_seconds: 60, retries: 2)
49
+ attempts = 0
50
+
51
+ begin
52
+ attempts += 1
53
+ prompt = client.get_prompt(prompt_name, label: label, version: version, cache_ttl_seconds: cache_ttl_seconds)
54
+
55
+ if variables
56
+ prompt.compile(variables)
57
+ else
58
+ prompt
59
+ end
60
+ rescue StandardError => e
61
+ if attempts <= retries
62
+ sleep_time = 2**(attempts - 1) * 0.1 # Exponential backoff: 0.1s, 0.2s, 0.4s...
63
+ warn "Langfuse prompt fetch failed (#{prompt_name}), retrying in #{sleep_time}s... (attempt #{attempts}/#{retries + 1})" if configuration.debug
64
+ sleep(sleep_time)
65
+ retry
66
+ end
67
+
68
+ warn "Langfuse prompt fetch failed (#{prompt_name}): #{e.message}" if configuration.debug
69
+ nil
70
+ end
71
+ end
72
+
73
+ # Create a trace and optionally execute a block with it
74
+ # When a block is given, the trace is yielded and flush is called automatically after the block
75
+ # If trace creation fails, a NullTrace is yielded to ensure the block still executes
76
+ #
77
+ # @param name [String] trace name
78
+ # @param user_id [String] optional user identifier
79
+ # @param session_id [String] optional session identifier
80
+ # @param input [Object] optional input data
81
+ # @param output [Object] optional output data
82
+ # @param metadata [Hash] optional metadata
83
+ # @param tags [Array] optional tags
84
+ # @param version [String] optional version
85
+ # @param release [String] optional release
86
+ # @yield [Trace, NullTrace] trace object for recording observations
87
+ # @return [Object] block return value if block given, trace otherwise
88
+ #
89
+ # @example Block-based usage with automatic flush
90
+ # Langfuse.trace("my-trace", user_id: "user-1") do |trace|
91
+ # generation = trace.generation(name: "openai", model: "gpt-4", input: messages)
92
+ # response = call_openai(...)
93
+ # generation.end(output: response, usage: response.usage)
94
+ # trace.update(output: response)
95
+ # end
96
+ #
97
+ # @example Direct usage without block
98
+ # trace = Langfuse.trace("my-trace")
99
+ # # ... work with trace
100
+ # Langfuse.flush
101
+ #
102
+ def trace(name = nil, user_id: nil, session_id: nil, input: nil, output: nil,
103
+ metadata: nil, tags: nil, version: nil, release: nil, **kwargs, &block)
104
+ trace = client.trace(
105
+ name: name,
106
+ user_id: user_id,
107
+ session_id: session_id,
108
+ input: input,
109
+ output: output,
110
+ metadata: metadata,
111
+ tags: tags,
112
+ version: version,
113
+ release: release,
114
+ **kwargs
115
+ )
116
+
117
+ if block_given?
118
+ begin
119
+ result = yield(trace)
120
+ result
121
+ ensure
122
+ flush
123
+ end
124
+ else
125
+ trace
126
+ end
127
+ rescue StandardError => e
128
+ warn "Langfuse trace creation failed: #{e.message}" if configuration.debug
129
+
130
+ # If block given, execute with NullTrace to ensure code continues
131
+ if block_given?
132
+ null_trace = NullTrace.new
133
+ yield(null_trace)
134
+ else
135
+ NullTrace.new
136
+ end
137
+ end
138
+
139
+ # Flush all pending events to Langfuse
140
+ def flush
141
+ client.flush
142
+ rescue StandardError => e
143
+ warn "Langfuse flush failed: #{e.message}" if configuration.debug
144
+ end
145
+
146
+ # Shutdown the singleton client
147
+ def shutdown
148
+ client.shutdown
149
+ rescue StandardError => e
150
+ warn "Langfuse shutdown failed: #{e.message}" if configuration.debug
151
+ end
152
+
153
+ # Reset the singleton client (mainly for testing)
154
+ def reset!
155
+ Thread.current[:langfuse_client] = nil
30
156
  end
31
157
  end
32
158
 
33
159
  # Configuration class for Langfuse client settings
34
160
  class Configuration
35
- 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
36
163
 
37
164
  def initialize
38
165
  @public_key = nil
@@ -43,6 +170,7 @@ module Langfuse
43
170
  @retries = 3
44
171
  @flush_interval = 5
45
172
  @auto_flush = true
173
+ @ingestion_mode = :legacy # :legacy or :otel
46
174
  end
47
175
  end
48
176
  end
data/scripts/release.sh CHANGED
@@ -49,7 +49,7 @@ if ! bundle exec rspec; then
49
49
  fi
50
50
 
51
51
  print_status "Running offline tests..."
52
- if ! ruby test_offline.rb; then
52
+ if ! ruby scripts/test_offline.rb; then
53
53
  print_error "Offline tests failed. Please fix them before releasing."
54
54
  exit 1
55
55
  fi
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
- require_relative 'lib/langfuse'
4
+ require_relative '../lib/langfuse'
4
5
 
5
6
  puts '🚀 Testing Langfuse Ruby SDK (Offline Mode)...'
6
7
 
@@ -223,7 +224,7 @@ begin
223
224
  )
224
225
 
225
226
  # Embedding generation
226
- embedding_gen = retrieval_span.generation(
227
+ retrieval_span.generation(
227
228
  name: 'embedding-generation',
228
229
  model: 'text-embedding-ada-002',
229
230
  input: 'quantum computing basics',
@@ -250,7 +251,7 @@ begin
250
251
  )
251
252
 
252
253
  # LLM generation
253
- llm_gen = answer_span.generation(
254
+ answer_span.generation(
254
255
  name: 'openai-completion',
255
256
  model: 'gpt-4',
256
257
  input: [
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  # Langfuse Ruby SDK Release Verification Script
4
5
  require 'net/http'
@@ -78,10 +79,10 @@ end
78
79
 
79
80
  # Run checks
80
81
  puts "\n🔍 Checking RubyGems availability..."
81
- gem_available = check_rubygems('langfuse', current_version)
82
+ gem_available = check_rubygems('langfuse-ruby', current_version)
82
83
 
83
84
  puts "\n🔍 Checking version availability..."
84
- version_available = check_version_availability('langfuse', current_version)
85
+ version_available = check_version_availability('langfuse-ruby', current_version)
85
86
 
86
87
  # Test local installation
87
88
  puts "\n🔍 Testing local gem functionality..."
@@ -94,7 +95,7 @@ begin
94
95
 
95
96
  # Test client creation (without real credentials)
96
97
  begin
97
- client = Langfuse.new(public_key: 'test', secret_key: 'test')
98
+ Langfuse.new(public_key: 'test', secret_key: 'test')
98
99
  puts '✅ Client creation successful'
99
100
  rescue Langfuse::AuthenticationError
100
101
  puts '✅ Authentication error expected (no real credentials)'
@@ -134,6 +135,6 @@ else
134
135
  end
135
136
 
136
137
  puts "\n🔗 Useful links:"
137
- puts ' - RubyGems page: https://rubygems.org/gems/langfuse'
138
+ puts ' - RubyGems page: https://rubygems.org/gems/langfuse-ruby'
138
139
  puts ' - Documentation: https://github.com/ai-firstly/langfuse-ruby'
139
140
  puts ' - Issues: https://github.com/ai-firstly/langfuse-ruby/issues'