bloop-sdk 0.1.0 → 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/lib/bloop/tracing.rb +127 -0
- data/lib/bloop.rb +123 -6
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1764f66fe2c239ea3e9d45a71ac8f8102a9156d15c3059c166bac8a21e94c1cf
|
|
4
|
+
data.tar.gz: 69f5b714a508254bf6e63523edbdfb639f0bf771bb983b3ab11015f9f96729b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d94fbd7aa25b6d80b40d6b9caa611af131c82343482d7d2cab19293537a29482863b6bdb2a0c9c6ed4930570d7e599b5e1fe7ac381a544b783af1753b353647
|
|
7
|
+
data.tar.gz: e3937e140ffd35c5d7522728a29c7850663c65dcdac91084dcc54f074a4883ed73aa3533567d72df2fb6553a6d97ee1b1388f04be5cb3d8a8a99e6a6b1ed5889
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bloop
|
|
4
|
+
class Span
|
|
5
|
+
attr_reader :id, :parent_span_id, :span_type, :name, :model, :provider,
|
|
6
|
+
:started_at, :input, :metadata
|
|
7
|
+
attr_accessor :input_tokens, :output_tokens, :cost, :latency_ms,
|
|
8
|
+
:time_to_first_token_ms, :status, :error_message, :output
|
|
9
|
+
|
|
10
|
+
def initialize(span_type:, name: "", model: "", provider: "", input: nil,
|
|
11
|
+
metadata: nil, parent_span_id: nil)
|
|
12
|
+
@id = SecureRandom.uuid
|
|
13
|
+
@parent_span_id = parent_span_id
|
|
14
|
+
@span_type = span_type.to_s
|
|
15
|
+
@name = name
|
|
16
|
+
@model = model
|
|
17
|
+
@provider = provider
|
|
18
|
+
@input = input
|
|
19
|
+
@metadata = metadata
|
|
20
|
+
@started_at = (Time.now.to_f * 1000).to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def finish(status: :ok, input_tokens: nil, output_tokens: nil, cost: nil,
|
|
24
|
+
error_message: nil, output: nil, time_to_first_token_ms: nil)
|
|
25
|
+
@latency_ms = (Time.now.to_f * 1000).to_i - @started_at
|
|
26
|
+
@status = status.to_s
|
|
27
|
+
@input_tokens = input_tokens if input_tokens
|
|
28
|
+
@output_tokens = output_tokens if output_tokens
|
|
29
|
+
@cost = cost if cost
|
|
30
|
+
@error_message = error_message if error_message
|
|
31
|
+
@output = output if output
|
|
32
|
+
@time_to_first_token_ms = time_to_first_token_ms if time_to_first_token_ms
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def set_usage(input_tokens: nil, output_tokens: nil, cost: nil)
|
|
37
|
+
@input_tokens = input_tokens if input_tokens
|
|
38
|
+
@output_tokens = output_tokens if output_tokens
|
|
39
|
+
@cost = cost if cost
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
h = {
|
|
44
|
+
id: @id, span_type: @span_type, name: @name,
|
|
45
|
+
started_at: @started_at, status: @status || "ok",
|
|
46
|
+
}
|
|
47
|
+
h[:parent_span_id] = @parent_span_id if @parent_span_id
|
|
48
|
+
h[:model] = @model unless @model.empty?
|
|
49
|
+
h[:provider] = @provider unless @provider.empty?
|
|
50
|
+
h[:input_tokens] = @input_tokens if @input_tokens
|
|
51
|
+
h[:output_tokens] = @output_tokens if @output_tokens
|
|
52
|
+
h[:cost] = @cost if @cost
|
|
53
|
+
h[:latency_ms] = @latency_ms if @latency_ms
|
|
54
|
+
h[:time_to_first_token_ms] = @time_to_first_token_ms if @time_to_first_token_ms
|
|
55
|
+
h[:error_message] = @error_message if @error_message
|
|
56
|
+
h[:input] = @input if @input
|
|
57
|
+
h[:output] = @output if @output
|
|
58
|
+
h[:metadata] = @metadata if @metadata
|
|
59
|
+
h
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class Trace
|
|
64
|
+
attr_reader :id, :name, :session_id, :user_id, :started_at, :input, :metadata,
|
|
65
|
+
:prompt_name, :prompt_version, :spans
|
|
66
|
+
attr_accessor :status, :output, :ended_at
|
|
67
|
+
|
|
68
|
+
def initialize(client:, name:, session_id: nil, user_id: nil, input: nil,
|
|
69
|
+
metadata: nil, prompt_name: nil, prompt_version: nil)
|
|
70
|
+
@id = SecureRandom.uuid
|
|
71
|
+
@client = client
|
|
72
|
+
@name = name
|
|
73
|
+
@session_id = session_id
|
|
74
|
+
@user_id = user_id
|
|
75
|
+
@status = "running"
|
|
76
|
+
@input = input
|
|
77
|
+
@metadata = metadata
|
|
78
|
+
@prompt_name = prompt_name
|
|
79
|
+
@prompt_version = prompt_version
|
|
80
|
+
@started_at = (Time.now.to_f * 1000).to_i
|
|
81
|
+
@spans = []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def start_span(span_type: :custom, name: "", model: "", provider: "",
|
|
85
|
+
input: nil, metadata: nil, parent_span_id: nil)
|
|
86
|
+
span = Span.new(span_type: span_type, name: name, model: model,
|
|
87
|
+
provider: provider, input: input, metadata: metadata,
|
|
88
|
+
parent_span_id: parent_span_id)
|
|
89
|
+
@spans << span
|
|
90
|
+
span
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def with_generation(model: "", provider: "", name: "", input: nil, metadata: nil)
|
|
94
|
+
span = start_span(span_type: :generation, name: name, model: model,
|
|
95
|
+
provider: provider, input: input, metadata: metadata)
|
|
96
|
+
yield span
|
|
97
|
+
span.finish(status: :ok) unless span.status
|
|
98
|
+
span
|
|
99
|
+
rescue Exception => e
|
|
100
|
+
span.finish(status: :error, error_message: e.message) unless span.status
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def finish(status: :completed, output: nil)
|
|
105
|
+
@ended_at = (Time.now.to_f * 1000).to_i
|
|
106
|
+
@status = status.to_s
|
|
107
|
+
@output = output if output
|
|
108
|
+
@client.send(:enqueue_trace, self)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def to_h
|
|
112
|
+
h = {
|
|
113
|
+
id: @id, name: @name, status: @status, started_at: @started_at,
|
|
114
|
+
spans: @spans.map(&:to_h),
|
|
115
|
+
}
|
|
116
|
+
h[:session_id] = @session_id if @session_id
|
|
117
|
+
h[:user_id] = @user_id if @user_id
|
|
118
|
+
h[:input] = @input if @input
|
|
119
|
+
h[:output] = @output if @output
|
|
120
|
+
h[:metadata] = @metadata if @metadata
|
|
121
|
+
h[:prompt_name] = @prompt_name if @prompt_name
|
|
122
|
+
h[:prompt_version] = @prompt_version if @prompt_version
|
|
123
|
+
h[:ended_at] = @ended_at if @ended_at
|
|
124
|
+
h
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/bloop.rb
CHANGED
|
@@ -4,9 +4,11 @@ require "openssl"
|
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "json"
|
|
6
6
|
require "uri"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require_relative "bloop/tracing"
|
|
7
9
|
|
|
8
10
|
module Bloop
|
|
9
|
-
VERSION = "0.
|
|
11
|
+
VERSION = "0.2.0"
|
|
10
12
|
|
|
11
13
|
class Client
|
|
12
14
|
attr_reader :endpoint, :project_key
|
|
@@ -26,6 +28,7 @@ module Bloop
|
|
|
26
28
|
@max_buffer_size = max_buffer_size
|
|
27
29
|
|
|
28
30
|
@buffer = []
|
|
31
|
+
@trace_buffer = []
|
|
29
32
|
@mutex = Mutex.new
|
|
30
33
|
@closed = false
|
|
31
34
|
|
|
@@ -78,6 +81,18 @@ module Bloop
|
|
|
78
81
|
)
|
|
79
82
|
end
|
|
80
83
|
|
|
84
|
+
# Wrap a block and capture any raised exception, then re-raise.
|
|
85
|
+
#
|
|
86
|
+
# @param kwargs [Hash] Extra context passed to capture_exception (e.g. route_or_procedure:, metadata:)
|
|
87
|
+
# @yield The block to execute
|
|
88
|
+
# @return The block's return value
|
|
89
|
+
def with_error_capture(**kwargs, &block)
|
|
90
|
+
block.call
|
|
91
|
+
rescue Exception => e
|
|
92
|
+
capture_exception(e, **kwargs)
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
|
|
81
96
|
# Flush buffered events immediately.
|
|
82
97
|
def flush
|
|
83
98
|
@mutex.synchronize { flush_locked }
|
|
@@ -90,15 +105,49 @@ module Bloop
|
|
|
90
105
|
@flush_thread&.kill
|
|
91
106
|
end
|
|
92
107
|
|
|
108
|
+
# Start a new LLM trace for observability.
|
|
109
|
+
#
|
|
110
|
+
# @param name [String] Trace name (e.g. "chat-completion")
|
|
111
|
+
# @param session_id [String] Optional session identifier
|
|
112
|
+
# @param user_id [String] Optional user identifier
|
|
113
|
+
# @param input [Object] Optional input data
|
|
114
|
+
# @param metadata [Hash] Optional metadata
|
|
115
|
+
# @param prompt_name [String] Optional prompt template name
|
|
116
|
+
# @param prompt_version [String] Optional prompt version
|
|
117
|
+
# @return [Bloop::Trace]
|
|
118
|
+
def start_trace(name:, session_id: nil, user_id: nil, input: nil, metadata: nil,
|
|
119
|
+
prompt_name: nil, prompt_version: nil)
|
|
120
|
+
Bloop::Trace.new(client: self, name: name, session_id: session_id,
|
|
121
|
+
user_id: user_id, input: input, metadata: metadata,
|
|
122
|
+
prompt_name: prompt_name, prompt_version: prompt_version)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Wrap a block in a trace. Auto-finishes on success or error.
|
|
126
|
+
#
|
|
127
|
+
# @param name [String] Trace name
|
|
128
|
+
# @param kwargs [Hash] Extra args passed to start_trace
|
|
129
|
+
# @yield [Bloop::Trace] The trace object
|
|
130
|
+
# @return [Bloop::Trace]
|
|
131
|
+
def with_trace(name, **kwargs)
|
|
132
|
+
trace = start_trace(name: name, **kwargs)
|
|
133
|
+
yield trace
|
|
134
|
+
trace.finish(status: :completed) if trace.status == "running"
|
|
135
|
+
trace
|
|
136
|
+
rescue Exception => e
|
|
137
|
+
trace.finish(status: :error, output: e.message) if trace.status == "running"
|
|
138
|
+
raise
|
|
139
|
+
end
|
|
140
|
+
|
|
93
141
|
private
|
|
94
142
|
|
|
95
143
|
def flush_locked
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
144
|
+
unless @buffer.empty?
|
|
145
|
+
events = @buffer.dup
|
|
146
|
+
@buffer.clear
|
|
147
|
+
Thread.new { send_events(events) }
|
|
148
|
+
end
|
|
100
149
|
|
|
101
|
-
|
|
150
|
+
flush_traces_locked
|
|
102
151
|
end
|
|
103
152
|
|
|
104
153
|
def send_events(events)
|
|
@@ -129,6 +178,41 @@ module Bloop
|
|
|
129
178
|
# Fire and forget — don't crash the host app
|
|
130
179
|
end
|
|
131
180
|
|
|
181
|
+
def enqueue_trace(trace)
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
@trace_buffer << trace.to_h
|
|
184
|
+
flush_traces_locked if @trace_buffer.size >= @max_buffer_size
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def flush_traces_locked
|
|
189
|
+
return if @trace_buffer.empty?
|
|
190
|
+
|
|
191
|
+
traces = @trace_buffer.dup
|
|
192
|
+
@trace_buffer.clear
|
|
193
|
+
Thread.new { send_traces(traces) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def send_traces(traces)
|
|
197
|
+
traces.each_slice(50) do |batch|
|
|
198
|
+
body = JSON.generate({ traces: batch })
|
|
199
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", @project_key, body)
|
|
200
|
+
uri = URI("#{@endpoint}/v1/traces/batch")
|
|
201
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
202
|
+
http.use_ssl = (uri.scheme == "https")
|
|
203
|
+
http.open_timeout = 5
|
|
204
|
+
http.read_timeout = 10
|
|
205
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
206
|
+
req["Content-Type"] = "application/json"
|
|
207
|
+
req["X-Signature"] = signature
|
|
208
|
+
req["X-Project-Key"] = @project_key
|
|
209
|
+
req.body = body
|
|
210
|
+
http.request(req)
|
|
211
|
+
end
|
|
212
|
+
rescue StandardError
|
|
213
|
+
# Fire and forget
|
|
214
|
+
end
|
|
215
|
+
|
|
132
216
|
def start_flush_thread
|
|
133
217
|
@flush_thread = Thread.new do
|
|
134
218
|
loop do
|
|
@@ -146,4 +230,37 @@ module Bloop
|
|
|
146
230
|
at_exit { client.close }
|
|
147
231
|
end
|
|
148
232
|
end
|
|
233
|
+
|
|
234
|
+
# Rack middleware that captures unhandled exceptions and reports them to bloop.
|
|
235
|
+
#
|
|
236
|
+
# Works with Rails, Sinatra, Grape, and any Rack-compatible framework.
|
|
237
|
+
#
|
|
238
|
+
# @example Rails
|
|
239
|
+
# # config/application.rb
|
|
240
|
+
# config.middleware.use Bloop::RackMiddleware, client: Bloop::Client.new(...)
|
|
241
|
+
#
|
|
242
|
+
# @example Sinatra
|
|
243
|
+
# use Bloop::RackMiddleware, client: Bloop::Client.new(...)
|
|
244
|
+
class RackMiddleware
|
|
245
|
+
# @param app [#call] The Rack application
|
|
246
|
+
# @param client [Bloop::Client] A configured bloop client instance
|
|
247
|
+
def initialize(app, client:)
|
|
248
|
+
@app = app
|
|
249
|
+
@client = client
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def call(env)
|
|
253
|
+
@app.call(env)
|
|
254
|
+
rescue Exception => e
|
|
255
|
+
@client.capture_exception(e,
|
|
256
|
+
route_or_procedure: env["PATH_INFO"],
|
|
257
|
+
metadata: {
|
|
258
|
+
method: env["REQUEST_METHOD"],
|
|
259
|
+
query: env["QUERY_STRING"],
|
|
260
|
+
remote_ip: env["REMOTE_ADDR"],
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
raise
|
|
264
|
+
end
|
|
265
|
+
end
|
|
149
266
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bloop-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- bloop
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Capture and send error events to a bloop server. Zero external dependencies.
|
|
14
14
|
email:
|
|
@@ -17,6 +17,7 @@ extensions: []
|
|
|
17
17
|
extra_rdoc_files: []
|
|
18
18
|
files:
|
|
19
19
|
- lib/bloop.rb
|
|
20
|
+
- lib/bloop/tracing.rb
|
|
20
21
|
homepage: https://github.com/your-org/bloop
|
|
21
22
|
licenses:
|
|
22
23
|
- MIT
|