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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/bloop/tracing.rb +127 -0
  3. data/lib/bloop.rb +123 -6
  4. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5596c4ce2d5c135c37489bc42e8f5198e08747db4d2f042b4b0d92c4da922505
4
- data.tar.gz: 83611d60a780e9b16d67b43199998984a5673174ea08b79d29b20962a29d7859
3
+ metadata.gz: 1764f66fe2c239ea3e9d45a71ac8f8102a9156d15c3059c166bac8a21e94c1cf
4
+ data.tar.gz: 69f5b714a508254bf6e63523edbdfb639f0bf771bb983b3ab11015f9f96729b8
5
5
  SHA512:
6
- metadata.gz: f16a1f358c7fd2406156c4a771b252fa186eb65cedfbf4dc5184aed896f7923805c119c5f317a914d9a0ca45d9b2f1cd1510a478bf08ef54f70132e03db1ebad
7
- data.tar.gz: 933331f141544fa8f041e883094624cae9d9dbdbfcde3f1efbeb29d5e3e9056330e33f9bb4c8b31fe5a43dcc20c18b6fd64a4e14c45dc7c063451b0ee4fe4658
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.1.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
- return if @buffer.empty?
97
-
98
- events = @buffer.dup
99
- @buffer.clear
144
+ unless @buffer.empty?
145
+ events = @buffer.dup
146
+ @buffer.clear
147
+ Thread.new { send_events(events) }
148
+ end
100
149
 
101
- Thread.new { send_events(events) }
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.1.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-13 00:00:00.000000000 Z
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