langfuse 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e165c60bb44cb4d93fa3bfcda49f4948078714bc80e50a37fb90501093915b3b
4
+ data.tar.gz: 5e930ae7b06502dce3c805b4b820cb1de7826b5bfc4d690f82fb1ba1b53e2053
5
+ SHA512:
6
+ metadata.gz: e0dd66aa77a3389cff7eff1ac9ce9a7be987821179f61cf0f2635136ead05c486100675905971be8f8f4b0d33bf999718a09ee0b0ccb67a90e94426cdb44f0b6
7
+ data.tar.gz: 02b1a9ae14210d8216fa180b9dda79477502fdb117ef6a023b05e0b1d183b9823d38ff56e107b19edc1b6db3f0a5cb5fe77f96ee78523275cc818488a76fd7fc
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Langfuse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # Langfuse Ruby SDK
2
+
3
+ A Ruby client for the [Langfuse](https://langfuse.com) observability platform.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'langfuse'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install langfuse
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ You need to configure the SDK with your Langfuse credentials:
28
+
29
+ ```ruby
30
+ Langfuse.configure do |config|
31
+ config.public_key = ENV['LANGFUSE_PUBLIC_KEY'] # e.g., 'pk-lf-...'
32
+ config.secret_key = ENV['LANGFUSE_SECRET_KEY'] # e.g., 'sk-lf-...'
33
+ config.host = ENV.fetch('LANGFUSE_HOST', 'https://us.cloud.langfuse.com')
34
+ config.debug = true # Enable debug logging
35
+ end
36
+ ```
37
+
38
+ ### Configuration Options
39
+
40
+ - `public_key`: Your Langfuse public key (required)
41
+ - `secret_key`: Your Langfuse secret key (required)
42
+ - `host`: Langfuse API host (default: 'https://us.cloud.langfuse.com')
43
+ - `batch_size`: Number of events to buffer before sending (default: 10)
44
+ - `flush_interval`: Seconds between automatic flushes (default: 60)
45
+ - `debug`: Enable debug logging (default: false)
46
+ - `disable_at_exit_hook`: Disable automatic flush on program exit (default: false)
47
+ - `shutdown_timeout`: Seconds to wait for flush thread to finish on shutdown (default: 5)
48
+
49
+ ## Usage
50
+
51
+ ### Creating a Trace
52
+
53
+ A trace represents a complete user interaction:
54
+
55
+ ```ruby
56
+ trace = Langfuse.trace(
57
+ name: "user-query",
58
+ user_id: "user-123",
59
+ metadata: { source: "web-app" }
60
+ )
61
+ ```
62
+
63
+ ### Creating a Span
64
+
65
+ Spans represent operations within a trace:
66
+
67
+ ```ruby
68
+ span = Langfuse.span(
69
+ name: "process-query",
70
+ trace_id: trace.id,
71
+ input: { query: "What is the weather today?" }
72
+ )
73
+
74
+ # Later, update and close the span
75
+ span.output = { processed_result: "..." }
76
+ span.end_time = Time.now.utc
77
+ Langfuse.update_span(span)
78
+ ```
79
+
80
+ ### Creating a Generation
81
+
82
+ Generations track LLM invocations:
83
+
84
+ ```ruby
85
+ generation = Langfuse.generation(
86
+ name: "llm-response",
87
+ trace_id: trace.id,
88
+ parent_observation_id: span.id, # Optional: link to parent span
89
+ model: "gpt-3.5-turbo",
90
+ model_parameters: {
91
+ temperature: 0.7,
92
+ max_tokens: 150
93
+ },
94
+ input: [
95
+ { role: "system", content: "You are a helpful assistant" },
96
+ { role: "user", content: "What is the weather today?" }
97
+ ]
98
+ )
99
+
100
+ # Later, update with the response
101
+ generation.output = "I don't have access to real-time weather data..."
102
+ generation.usage = Langfuse::Models::Usage.new(
103
+ prompt_tokens: 25,
104
+ completion_tokens: 35,
105
+ total_tokens: 60
106
+ )
107
+ Langfuse.update_generation(generation)
108
+ ```
109
+
110
+ ### Creating Events
111
+
112
+ Events capture point-in-time occurrences:
113
+
114
+ ```ruby
115
+ Langfuse.event(
116
+ name: "user-feedback",
117
+ trace_id: trace.id,
118
+ input: { feedback_type: "thumbs_up" }
119
+ )
120
+ ```
121
+
122
+ ### Adding Scores
123
+
124
+ Scores help evaluate quality:
125
+
126
+ ```ruby
127
+ Langfuse.score(
128
+ trace_id: trace.id,
129
+ name: "relevance",
130
+ value: 0.9,
131
+ comment: "Response was highly relevant"
132
+ )
133
+ ```
134
+
135
+ ### Manual Flushing
136
+
137
+ Events are automatically sent when:
138
+ - The batch size is reached
139
+ - The flush interval timer triggers
140
+ - The application exits
141
+
142
+ You can also manually flush events:
143
+
144
+ ```ruby
145
+ Langfuse.flush
146
+ ```
147
+
148
+ ## Rails Integration
149
+
150
+ ### Initializer
151
+
152
+ Create a file at `config/initializers/langfuse.rb`:
153
+
154
+ ```ruby
155
+ require 'langfuse'
156
+
157
+ Langfuse.configure do |config|
158
+ config.public_key = ENV['LANGFUSE_PUBLIC_KEY']
159
+ config.secret_key = ENV['LANGFUSE_SECRET_KEY']
160
+ config.batch_size = 20
161
+ config.flush_interval = 30 # seconds
162
+ config.debug = Rails.env.development?
163
+ end
164
+ ```
165
+
166
+ ### ActiveSupport::Notifications Integration
167
+
168
+ You can integrate with Rails' notification system:
169
+
170
+ ```ruby
171
+ # config/initializers/langfuse.rb
172
+ ActiveSupport::Notifications.subscribe(/langfuse/) do |name, start, finish, id, payload|
173
+ case name
174
+ when 'langfuse.trace'
175
+ Langfuse.trace(payload)
176
+ when 'langfuse.span'
177
+ Langfuse.span(payload)
178
+ # etc.
179
+ end
180
+ end
181
+
182
+ # In your application code
183
+ ActiveSupport::Notifications.instrument('langfuse.trace', {
184
+ name: 'user-login',
185
+ metadata: { user_id: current_user.id }
186
+ })
187
+ ```
188
+
189
+ ## Background Processing with Sidekiq
190
+
191
+ If Sidekiq is available in your application, the SDK will automatically use it for processing events in the background. This improves performance and reliability.
192
+
193
+ To enable Sidekiq integration, simply add Sidekiq to your application and it will be detected automatically.
194
+
195
+ ## Development
196
+
197
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
198
+
199
+ ## License
200
+
201
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/console ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'langfuse'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # Configure Langfuse
10
+ Langfuse.configure do |config|
11
+ config.public_key = ENV['LANGFUSE_PUBLIC_KEY'] || 'test_public_key'
12
+ config.secret_key = ENV['LANGFUSE_SECRET_KEY'] || 'test_secret_key'
13
+ config.debug = true
14
+ end
15
+
16
+ # (If you use this, don't forget to add pry to your Gemfile!)
17
+ # require "pry"
18
+ # Pry.start
19
+
20
+ require 'irb'
21
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other setup tasks here
@@ -0,0 +1,80 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module Langfuse
7
+ class ApiClient
8
+ attr_reader :config
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def ingest(events)
15
+ uri = URI.parse("#{@config.host}/api/public/ingestion")
16
+
17
+ # Build the request
18
+ request = Net::HTTP::Post.new(uri.path)
19
+ request.content_type = 'application/json'
20
+
21
+ # Set authorization header using base64 encoded credentials
22
+ auth = Base64.strict_encode64("#{@config.public_key}:#{@config.secret_key}")
23
+ # Log the encoded auth header for debugging
24
+ if @config.debug
25
+ log("Using auth header: Basic #{auth} (public_key: #{@config.public_key}, secret_key: #{@config.secret_key})")
26
+ end
27
+ request['Authorization'] = "Basic #{auth}"
28
+
29
+ # Set the payload
30
+ request.body = {
31
+ batch: events
32
+ }.to_json
33
+
34
+ # Send the request
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = uri.scheme == 'https'
37
+ http.read_timeout = 10 # 10 seconds
38
+
39
+ if @config.debug
40
+ log("Sending #{events.size} events to Langfuse API at #{@config.host}")
41
+ log("Events: #{events.inspect}")
42
+ # log("Using auth header: Basic #{auth.gsub(/.(?=.{4})/, '*')}") # Mask most of the auth token
43
+ log("Using auth header: Basic #{auth}") # Mask most of the auth token
44
+ log("Request url: #{uri}")
45
+ end
46
+
47
+ response = http.request(request)
48
+
49
+ if response.code.to_i == 207 # Partial success
50
+ log('Received 207 partial success response') if @config.debug
51
+ JSON.parse(response.body)
52
+ elsif response.code.to_i >= 200 && response.code.to_i < 300
53
+ log("Received successful response: #{response.code}") if @config.debug
54
+ JSON.parse(response.body)
55
+ else
56
+ error_msg = "API error: #{response.code} #{response.message}"
57
+ if @config.debug
58
+ log("Response body: #{response.body}", :error)
59
+ log("Request URL: #{uri}", :error)
60
+ end
61
+ log(error_msg, :error)
62
+ raise error_msg
63
+ end
64
+
65
+ log('---')
66
+ rescue StandardError => e
67
+ log("Error during API request: #{e.message}", :error)
68
+ raise
69
+ end
70
+
71
+ private
72
+
73
+ def log(message, level = :debug)
74
+ return unless @config.debug
75
+
76
+ logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
77
+ logger.send(level, "[Langfuse] #{message}")
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,80 @@
1
+ module Langfuse
2
+ class BatchWorker
3
+ # This is a placeholder class that will be defined with Sidekiq::Worker
4
+ # when Sidekiq is available.
5
+ #
6
+ # If Sidekiq is available, this will be replaced with a real worker class
7
+ # that includes Sidekiq::Worker
8
+
9
+ def self.perform_async(events)
10
+ # When Sidekiq is not available, process synchronously
11
+ new.perform(events)
12
+ end
13
+
14
+ def perform(events)
15
+ Langfuse::ApiClient.new(Langfuse.configuration).ingest(events)
16
+ end
17
+ end
18
+
19
+ # Define the real Sidekiq worker if Sidekiq is available
20
+ if defined?(Sidekiq)
21
+ class BatchWorker
22
+ include Sidekiq::Worker
23
+
24
+ sidekiq_options queue: 'langfuse', retry: 5, backtrace: true
25
+
26
+ # Custom retry delay logic (exponential backoff)
27
+ sidekiq_retry_in do |count|
28
+ 10 * (count + 1) # 10s, 20s, 30s, 40s, 50s
29
+ end
30
+
31
+ def perform(event_hashes)
32
+ api_client = ApiClient.new(Langfuse.configuration)
33
+
34
+ begin
35
+ response = api_client.ingest(event_hashes)
36
+
37
+ # Check for partial failures
38
+ if response && response['errors']&.any?
39
+ response['errors'].each do |error|
40
+ logger.error("Langfuse API error for event #{error['id']}: #{error['message']}")
41
+
42
+ # Store permanently failed events if needed
43
+ if non_retryable_error?(error['status'])
44
+ store_failed_event(event_hashes.find { |e| e[:id] == error['id'] }, error['message'])
45
+ end
46
+ end
47
+ end
48
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
49
+ # Network errors - Sidekiq will retry
50
+ logger.error("Langfuse network error: #{e.message}")
51
+ raise
52
+ rescue StandardError => e
53
+ # Other errors
54
+ logger.error("Langfuse API error: #{e.message}")
55
+
56
+ # Let Sidekiq retry
57
+ raise
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def non_retryable_error?(status)
64
+ # 4xx errors except 429 (rate limit) are not retryable
65
+ status.to_i >= 400 && status.to_i < 500 && status.to_i != 429
66
+ end
67
+
68
+ def store_failed_event(event, error)
69
+ # Store in Redis for later inspection/retry
70
+ Sidekiq.redis do |redis|
71
+ redis.rpush('langfuse:failed_events', {
72
+ event: event,
73
+ error: error,
74
+ timestamp: Time.now.utc.iso8601
75
+ }.to_json)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,176 @@
1
+ require 'singleton'
2
+ require 'concurrent'
3
+ require 'logger'
4
+
5
+ module Langfuse
6
+ class Client
7
+ include Singleton
8
+
9
+ def initialize
10
+ @config = Langfuse.configuration
11
+ @events = Concurrent::Array.new # Thread-safe array
12
+ @mutex = Mutex.new # For operations that need additional thread safety
13
+
14
+ # Start periodic flusher only in server context
15
+ schedule_periodic_flush if defined?(Rails) && Rails.server?
16
+
17
+ # Register shutdown hook
18
+ return if @config.disable_at_exit_hook
19
+
20
+ at_exit { shutdown }
21
+ end
22
+
23
+ # Creates a new trace
24
+ def trace(attributes = {})
25
+ trace = Models::Trace.new(attributes)
26
+ event = Models::IngestionEvent.new(
27
+ type: 'trace-create',
28
+ body: trace
29
+ )
30
+ enqueue_event(event)
31
+ trace
32
+ end
33
+
34
+ # Creates a new span within a trace
35
+ def span(attributes = {})
36
+ raise ArgumentError, 'trace_id is required for creating a span' unless attributes[:trace_id]
37
+
38
+ span = Models::Span.new(attributes)
39
+ event = Models::IngestionEvent.new(
40
+ type: 'span-create',
41
+ body: span
42
+ )
43
+ enqueue_event(event)
44
+ span
45
+ end
46
+
47
+ # Updates an existing span
48
+ def update_span(span)
49
+ raise ArgumentError, 'span.id and span.trace_id are required for updating a span' unless span.id && span.trace_id
50
+
51
+ event = Models::IngestionEvent.new(
52
+ type: 'span-update',
53
+ body: span
54
+ )
55
+ enqueue_event(event)
56
+ span
57
+ end
58
+
59
+ # Creates a new generation within a trace
60
+ def generation(attributes = {})
61
+ raise ArgumentError, 'trace_id is required for creating a generation' unless attributes[:trace_id]
62
+
63
+ generation = Models::Generation.new(attributes)
64
+ event = Models::IngestionEvent.new(
65
+ type: 'generation-create',
66
+ body: generation
67
+ )
68
+ enqueue_event(event)
69
+ generation
70
+ end
71
+
72
+ # Updates an existing generation
73
+ def update_generation(generation)
74
+ unless generation.id && generation.trace_id
75
+ raise ArgumentError, 'generation.id and generation.trace_id are required for updating a generation'
76
+ end
77
+
78
+ event = Models::IngestionEvent.new(
79
+ type: 'generation-update',
80
+ body: generation
81
+ )
82
+ enqueue_event(event)
83
+ generation
84
+ end
85
+
86
+ # Creates a new event within a trace
87
+ def event(attributes = {})
88
+ raise ArgumentError, 'trace_id is required for creating an event' unless attributes[:trace_id]
89
+
90
+ event_obj = Models::Event.new(attributes)
91
+ event = Models::IngestionEvent.new(
92
+ type: 'event-create',
93
+ body: event_obj
94
+ )
95
+ enqueue_event(event)
96
+ event_obj
97
+ end
98
+
99
+ # Creates a new score
100
+ def score(attributes = {})
101
+ raise ArgumentError, 'trace_id is required for creating a score' unless attributes[:trace_id]
102
+
103
+ score = Models::Score.new(attributes)
104
+ event = Models::IngestionEvent.new(
105
+ type: 'score-create',
106
+ body: score
107
+ )
108
+ enqueue_event(event)
109
+ score
110
+ end
111
+
112
+ # Flushes all pending events to the API
113
+ def flush
114
+ events_to_process = nil
115
+
116
+ # Atomically swap the events array to avoid race conditions
117
+ @mutex.synchronize do
118
+ events_to_process = @events.dup
119
+ @events.clear
120
+ end
121
+
122
+ return if events_to_process.empty?
123
+
124
+ # Convert objects to hashes for serialization
125
+ event_hashes = events_to_process.map(&:to_h)
126
+
127
+ log("Flushing #{event_hashes.size} events")
128
+
129
+ # Send to background worker
130
+ BatchWorker.perform_async(event_hashes)
131
+ end
132
+
133
+ # Gracefully shuts down the client, ensuring all events are flushed
134
+ def shutdown
135
+ log('Shutting down Langfuse client...')
136
+
137
+ # Cancel the flush timer if it's running
138
+ @flush_thread&.exit
139
+
140
+ # Flush any remaining events
141
+ flush
142
+
143
+ log('Langfuse client shut down.')
144
+ end
145
+
146
+ private
147
+
148
+ def enqueue_event(event)
149
+ @events << event
150
+
151
+ # Trigger immediate flush if batch size reached
152
+ flush if @events.size >= @config.batch_size
153
+ end
154
+
155
+ def schedule_periodic_flush
156
+ log("Starting periodic flush thread (interval: #{@config.flush_interval}s)")
157
+
158
+ @flush_thread = Thread.new do
159
+ loop do
160
+ sleep @config.flush_interval
161
+ flush
162
+ rescue StandardError => e
163
+ log("Error in Langfuse flush thread: #{e.message}", :error)
164
+ sleep 1 # Avoid tight loop on persistent errors
165
+ end
166
+ end
167
+ end
168
+
169
+ def log(message, level = :debug)
170
+ return unless @config.debug
171
+
172
+ logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
173
+ logger.send(level, "[Langfuse] #{message}")
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,20 @@
1
+ module Langfuse
2
+ class Configuration
3
+ attr_accessor :public_key, :secret_key, :host,
4
+ :batch_size, :flush_interval, :debug,
5
+ :disable_at_exit_hook, :shutdown_timeout, :logger
6
+
7
+ def initialize
8
+ # Default configuration with environment variable fallbacks
9
+ @public_key = ENV['LANGFUSE_PUBLIC_KEY']
10
+ @secret_key = ENV['LANGFUSE_SECRET_KEY']
11
+ @host = ENV.fetch('LANGFUSE_HOST', 'https://us.cloud.langfuse.com')
12
+ @batch_size = ENV.fetch('LANGFUSE_BATCH_SIZE', '10').to_i
13
+ @flush_interval = ENV.fetch('LANGFUSE_FLUSH_INTERVAL', '60').to_i
14
+ @debug = ENV.fetch('LANGFUSE_DEBUG', 'false') == 'true'
15
+ @disable_at_exit_hook = false
16
+ @shutdown_timeout = ENV.fetch('LANGFUSE_SHUTDOWN_TIMEOUT', '5').to_i
17
+ @logger = Logger.new(STDOUT)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,36 @@
1
+ require 'securerandom'
2
+
3
+ module Langfuse
4
+ module Models
5
+ class Event
6
+ attr_accessor :id, :trace_id, :name, :start_time,
7
+ :metadata, :input, :output, :level, :status_message,
8
+ :parent_observation_id, :version, :environment
9
+
10
+ def initialize(attributes = {})
11
+ attributes.each do |key, value|
12
+ send("#{key}=", value) if respond_to?("#{key}=")
13
+ end
14
+ @id ||= SecureRandom.uuid
15
+ @start_time ||= Time.now.utc
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ id: @id,
21
+ traceId: @trace_id,
22
+ name: @name,
23
+ startTime: @start_time&.iso8601(3),
24
+ metadata: @metadata,
25
+ input: @input,
26
+ output: @output,
27
+ level: @level,
28
+ statusMessage: @status_message,
29
+ parentObservationId: @parent_observation_id,
30
+ version: @version,
31
+ environment: @environment
32
+ }.compact
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,45 @@
1
+ require 'securerandom'
2
+
3
+ module Langfuse
4
+ module Models
5
+ class Generation
6
+ attr_accessor :id, :trace_id, :name, :start_time, :end_time,
7
+ :metadata, :input, :output, :level, :status_message,
8
+ :parent_observation_id, :version, :environment,
9
+ :completion_start_time, :model, :model_parameters,
10
+ :usage, :prompt_name, :prompt_version
11
+
12
+ def initialize(attributes = {})
13
+ attributes.each do |key, value|
14
+ send("#{key}=", value) if respond_to?("#{key}=")
15
+ end
16
+ @id ||= SecureRandom.uuid
17
+ @start_time ||= Time.now.utc
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ id: @id,
23
+ traceId: @trace_id,
24
+ name: @name,
25
+ startTime: @start_time&.iso8601(3),
26
+ endTime: @end_time&.iso8601(3),
27
+ metadata: @metadata,
28
+ input: @input,
29
+ output: @output,
30
+ level: @level,
31
+ statusMessage: @status_message,
32
+ parentObservationId: @parent_observation_id,
33
+ version: @version,
34
+ environment: @environment,
35
+ completionStartTime: @completion_start_time&.iso8601(3),
36
+ model: @model,
37
+ modelParameters: @model_parameters,
38
+ usage: @usage.respond_to?(:to_h) ? @usage.to_h : @usage,
39
+ promptName: @prompt_name,
40
+ promptVersion: @prompt_version
41
+ }.compact
42
+ end
43
+ end
44
+ end
45
+ end