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 +7 -0
- data/LICENSE +21 -0
- data/README.md +201 -0
- data/bin/console +21 -0
- data/bin/setup +8 -0
- data/lib/langfuse/api_client.rb +80 -0
- data/lib/langfuse/batch_worker.rb +80 -0
- data/lib/langfuse/client.rb +176 -0
- data/lib/langfuse/configuration.rb +20 -0
- data/lib/langfuse/models/event.rb +36 -0
- data/lib/langfuse/models/generation.rb +45 -0
- data/lib/langfuse/models/ingestion_event.rb +27 -0
- data/lib/langfuse/models/score.rb +31 -0
- data/lib/langfuse/models/span.rb +37 -0
- data/lib/langfuse/models/trace.rb +37 -0
- data/lib/langfuse/models/usage.rb +30 -0
- data/lib/langfuse/rails.rb +31 -0
- data/lib/langfuse/version.rb +3 -0
- data/lib/langfuse.rb +71 -0
- data/lib/langfuse_context.rb +33 -0
- data/lib/langfuse_helper.rb +176 -0
- metadata +243 -0
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,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
|