bitfab 0.9.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: 78b34044dcc028414fb2f6fbe020f98bf0ce1a953507c8939bc12d8c15630b09
4
+ data.tar.gz: 174aa2eb61c2aca0a11078f2361b4392d7612f2a582e3dd4046608cbbb539a27
5
+ SHA512:
6
+ metadata.gz: d2eeec89057ecdee5f7f5eae5342ad9218aa190fc472aa01d3dbf0323262b33165faed2d61d6ae0be9bf7da3af9a7e88902e0fa426344b53751b78d0c1f5cf18
7
+ data.tar.gz: 9726da71f354723007ff6b9bf38466544e93a1ae21f0b02e2b258827697d4996caea781d1c062c7386dd5508eddad097fbc85f4382798e07cd11a0535a6e1a7a
data/README.md ADDED
@@ -0,0 +1,342 @@
1
+ # Bitfab Ruby SDK
2
+
3
+ Ruby client library for [Bitfab](https://bitfab.ai) - trace and monitor your Ruby application's function execution with nested span support.
4
+
5
+ ## Installation
6
+
7
+ Add to your `Gemfile`:
8
+
9
+ ```ruby
10
+ gem 'bitfab'
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install bitfab
17
+ ```
18
+
19
+ ## Requirements
20
+
21
+ - Ruby >= 3.4
22
+ - No external runtime dependencies (uses stdlib only)
23
+
24
+ ## Quick Start
25
+
26
+ ```ruby
27
+ require 'bitfab'
28
+
29
+ # Configure once at application startup
30
+ Bitfab.configure(
31
+ api_key: ENV.fetch('BITFAB_API_KEY')
32
+ )
33
+
34
+ # Add tracing to your classes
35
+ class OrderService
36
+ include Bitfab::Traceable
37
+ bitfab_function "order-processing"
38
+
39
+ bitfab_span :process_order, type: "function"
40
+ def process_order(order_id)
41
+ # Your code here
42
+ { status: "completed" }
43
+ end
44
+ end
45
+
46
+ # Use your code normally - spans are sent automatically
47
+ service = OrderService.new
48
+ service.process_order("order-123")
49
+
50
+ # Flush traces before exit (automatic via at_exit hook)
51
+ Bitfab.flush_traces
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ ### Basic Configuration
57
+
58
+ ```ruby
59
+ Bitfab.configure(
60
+ api_key: "your-api-key"
61
+ )
62
+ ```
63
+
64
+ ### Custom Service URL
65
+
66
+ ```ruby
67
+ Bitfab.configure(
68
+ api_key: "your-api-key",
69
+ service_url: "https://custom.example.com"
70
+ )
71
+ ```
72
+
73
+ ### Disabling Span Sending
74
+
75
+ The `enabled` option controls whether spans are sent to Bitfab. When disabled, your code executes normally but no spans are created or sent.
76
+
77
+ ```ruby
78
+ # Disable tracing (useful for development/test environments)
79
+ Bitfab.configure(
80
+ api_key: ENV.fetch('BITFAB_API_KEY', 'dummy-key'),
81
+ enabled: false
82
+ )
83
+ ```
84
+
85
+ **Common patterns:**
86
+
87
+ ```ruby
88
+ # Rails: Enable only in production
89
+ Bitfab.configure(
90
+ api_key: ENV.fetch('BITFAB_API_KEY', 'dummy-key'),
91
+ enabled: Rails.env.production?
92
+ )
93
+
94
+ # Environment variable control
95
+ Bitfab.configure(
96
+ api_key: ENV.fetch('BITFAB_API_KEY', 'dummy-key'),
97
+ enabled: ENV.fetch('BITFAB_ENABLED', 'false') == 'true'
98
+ )
99
+
100
+ # Multi-environment control
101
+ Bitfab.configure(
102
+ api_key: ENV.fetch('BITFAB_API_KEY', 'dummy-key'),
103
+ enabled: ['production', 'staging'].include?(ENV['RACK_ENV'])
104
+ )
105
+ ```
106
+
107
+ When `enabled: false`:
108
+ - ✅ Code executes normally with no performance impact
109
+ - ✅ Return values and errors work as expected
110
+ - ✅ Nested spans are properly skipped
111
+ - ❌ No HTTP requests are made
112
+ - ❌ No span data is collected or sent
113
+
114
+ ## Usage
115
+
116
+ ### Class-Level Trace Function Key
117
+
118
+ All spans in the class share the same trace function key:
119
+
120
+ ```ruby
121
+ class PaymentService
122
+ include Bitfab::Traceable
123
+ bitfab_function "payment-processing"
124
+
125
+ bitfab_span :charge_card, type: "function"
126
+ def charge_card(amount)
127
+ # Traced automatically
128
+ end
129
+
130
+ bitfab_span :refund, type: "function"
131
+ def refund(transaction_id)
132
+ # Also uses "payment-processing" key
133
+ end
134
+ end
135
+ ```
136
+
137
+ ### Per-Span Trace Function Key
138
+
139
+ Each span can declare its own key:
140
+
141
+ ```ruby
142
+ class NotificationService
143
+ include Bitfab::Traceable
144
+
145
+ bitfab_span :send_email, trace_function_key: "email-notifications", type: "function"
146
+ def send_email(to, subject)
147
+ # Uses "email-notifications" key
148
+ end
149
+
150
+ bitfab_span :send_sms, trace_function_key: "sms-notifications", type: "function"
151
+ def send_sms(to, message)
152
+ # Uses "sms-notifications" key
153
+ end
154
+ end
155
+ ```
156
+
157
+ ### Span Types
158
+
159
+ Bitfab supports the following span types:
160
+
161
+ - `"llm"` - LLM API calls
162
+ - `"agent"` - Agent decision loops
163
+ - `"function"` - Business logic functions
164
+ - `"guardrail"` - Validation and safety checks
165
+ - `"handoff"` - Human-in-the-loop interactions
166
+ - `"custom"` - Custom span types (default)
167
+
168
+ ```ruby
169
+ bitfab_span :validate_input, type: "guardrail"
170
+ bitfab_span :call_openai, type: "llm"
171
+ bitfab_span :agent_loop, type: "agent"
172
+ ```
173
+
174
+ ### Custom Span Names
175
+
176
+ By default, the method name is used as the span name. Override it:
177
+
178
+ ```ruby
179
+ bitfab_span :process_order, name: "ProcessOrderV2", type: "function"
180
+ def process_order(order_id)
181
+ # Span will be named "ProcessOrderV2"
182
+ end
183
+ ```
184
+
185
+ ### Nested Spans
186
+
187
+ Spans automatically track parent-child relationships:
188
+
189
+ ```ruby
190
+ class OrderPipeline
191
+ include Bitfab::Traceable
192
+ bitfab_function "order-pipeline"
193
+
194
+ bitfab_span :process, type: "function"
195
+ def process(order_id)
196
+ validate(order_id)
197
+ # More processing
198
+ end
199
+
200
+ bitfab_span :validate, type: "guardrail"
201
+ def validate(order_id)
202
+ check_fraud(order_id)
203
+ # More validation
204
+ end
205
+
206
+ bitfab_span :check_fraud, type: "guardrail"
207
+ def check_fraud(order_id)
208
+ # Fraud checking logic
209
+ end
210
+ end
211
+
212
+ # Creates 3 nested spans:
213
+ # process (parent)
214
+ # └─ validate (child)
215
+ # └─ check_fraud (grandchild)
216
+ ```
217
+
218
+ ### Input/Output Capture
219
+
220
+ Positional and keyword arguments are automatically captured:
221
+
222
+ ```ruby
223
+ bitfab_span :process_order, type: "function"
224
+ def process_order(order_id, priority: :normal)
225
+ # Input captured: { "order_id" => "123", "priority" => "normal" }
226
+ { status: "completed" }
227
+ # Output captured: { "status" => "completed" }
228
+ end
229
+ ```
230
+
231
+ ### Metadata
232
+
233
+ Add custom metadata to spans:
234
+
235
+ ```ruby
236
+ # Definition-time metadata
237
+ bitfab_span :process_order,
238
+ type: "function",
239
+ metadata: { "region" => "us-east", "version" => "v2" }
240
+ def process_order(order_id)
241
+ # ...
242
+ end
243
+
244
+ # Runtime metadata (inside a span)
245
+ bitfab_span :process_order, type: "function"
246
+ def process_order(order_id)
247
+ Bitfab.current_span.add_metadata(
248
+ "user_id" => current_user.id,
249
+ "request_id" => request.id
250
+ )
251
+ # Runtime metadata merges with definition-time metadata
252
+ end
253
+ ```
254
+
255
+ ### Wrapping External Code
256
+
257
+ Wrap third-party library methods without modifying them:
258
+
259
+ ```ruby
260
+ class ExternalHttpClient
261
+ def get(url)
262
+ # Third-party code
263
+ end
264
+ end
265
+
266
+ # Add tracing via wrap
267
+ Bitfab::Traceable.wrap(
268
+ ExternalHttpClient,
269
+ :get,
270
+ trace_function_key: "http-client",
271
+ type: "function"
272
+ )
273
+
274
+ # Now traced automatically
275
+ client = ExternalHttpClient.new
276
+ client.get("https://api.example.com")
277
+ ```
278
+
279
+ ### Error Handling
280
+
281
+ Errors are automatically captured and re-raised:
282
+
283
+ ```ruby
284
+ bitfab_span :risky_operation, type: "function"
285
+ def risky_operation
286
+ raise StandardError, "Something went wrong"
287
+ end
288
+
289
+ begin
290
+ service.risky_operation
291
+ rescue StandardError => e
292
+ # Error is captured in span and re-raised
293
+ puts "Caught: #{e.message}"
294
+ end
295
+ ```
296
+
297
+ ## Lifecycle
298
+
299
+ ### Automatic Flush
300
+
301
+ Spans are sent in background threads and automatically flushed on exit via `at_exit` hook.
302
+
303
+ ### Manual Flush
304
+
305
+ Wait for all pending spans to be sent:
306
+
307
+ ```ruby
308
+ Bitfab.flush_traces
309
+ # Blocks until all background threads complete
310
+ ```
311
+
312
+ ### Reset Client
313
+
314
+ Clear the global client (useful for testing):
315
+
316
+ ```ruby
317
+ Bitfab.reset!
318
+ ```
319
+
320
+ ## Thread Safety
321
+
322
+ - Each thread has its own span context stack (using `Thread.current`)
323
+ - Nested spans only work within the same thread
324
+ - Background span sending is thread-safe
325
+
326
+ ## Examples
327
+
328
+ See [bitfab-ruby-example/](../bitfab-ruby-example/) for complete working examples:
329
+
330
+ - [test_span.rb](../bitfab-ruby-example/scripts/test_span.rb) - Basic span creation
331
+ - [test_nested_spans.rb](../bitfab-ruby-example/scripts/test_nested_spans.rb) - Nested span hierarchies
332
+ - [test_wrap.rb](../bitfab-ruby-example/scripts/test_wrap.rb) - Wrapping external code
333
+ - [test_enabled.rb](../bitfab-ruby-example/scripts/test_enabled.rb) - Using the enabled flag
334
+ - [test_metadata.rb](../bitfab-ruby-example/scripts/test_metadata.rb) - Custom metadata
335
+
336
+ ## Development
337
+
338
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for development setup, testing, and publishing instructions.
339
+
340
+ ## License
341
+
342
+ MIT
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ require_relative "constants"
7
+ require_relative "http_client"
8
+ require_relative "replay"
9
+ require_relative "span_context"
10
+ require_relative "serialize"
11
+
12
+ module Bitfab
13
+ class Client
14
+ SPAN_TYPES = %w[llm agent function guardrail handoff custom].freeze
15
+
16
+ attr_reader :api_key, :service_url, :enabled
17
+
18
+ def initialize(api_key:, service_url: nil, enabled: true)
19
+ @api_key = api_key
20
+ @service_url = service_url || DEFAULT_SERVICE_URL
21
+ @enabled = enabled
22
+ if @enabled && (@api_key.nil? || @api_key.to_s.strip.empty?)
23
+ warn "Bitfab: api_key is empty — tracing is disabled. Provide a valid API key to enable tracing."
24
+ @enabled = false
25
+ end
26
+ @http_client = HttpClient.new(api_key:, service_url: @service_url)
27
+ @pending_span_threads = {}
28
+ @pending_span_mutex = Mutex.new
29
+ end
30
+
31
+ # Replay historical traces through a method and create a test run.
32
+ #
33
+ # @param receiver [Object, Class] an instance for instance methods, or a Class for class methods
34
+ # @param method_name [Symbol] the method to replay
35
+ # @param trace_function_key [String] the trace function key for this method
36
+ # @param limit [Integer] maximum number of traces to replay (default: 5)
37
+ # @param trace_ids [Array<String>, nil] optional list of trace IDs to filter
38
+ # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
39
+ # @return [Hash] with :items, :test_run_id, :test_run_url
40
+ def replay(receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10)
41
+ Replay.run(self, receiver, method_name, trace_function_key:, limit:, trace_ids:, max_concurrency:)
42
+ end
43
+
44
+ # Execute a block inside a span context, sending trace data on completion.
45
+ # Called by Traceable — not intended for direct use.
46
+ def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:)
47
+ return yield unless @enabled
48
+
49
+ parent = SpanContext.current
50
+ trace_id = parent ? parent[:trace_id] : SecureRandom.uuid
51
+ span_id = SecureRandom.uuid
52
+ parent_span_id = parent&.dig(:span_id)
53
+ is_root_span = parent_span_id.nil?
54
+ started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
55
+
56
+ # Register trace state for root spans
57
+ if is_root_span && !TraceState.get(trace_id)
58
+ TraceState.create(trace_id)
59
+ end
60
+
61
+ if is_root_span
62
+ @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
63
+ end
64
+
65
+ result = nil
66
+ error = nil
67
+ span_contexts = nil
68
+ span_prompt = nil
69
+
70
+ begin
71
+ SpanContext.with_span(trace_id:, span_id:) do
72
+ result = yield
73
+ ensure
74
+ # Capture contexts before the span context is popped
75
+ span_contexts = SpanContext.current&.dig(:contexts)
76
+ span_prompt = SpanContext.current&.dig(:prompt)
77
+ end
78
+ rescue => e
79
+ error = e.message
80
+ raise
81
+ ensure
82
+ # Never crash the host app due to span building/sending
83
+ begin
84
+ ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
85
+
86
+ replay_ctx = ReplayContext.current
87
+ resolved_test_run_id = replay_ctx&.dig(:test_run_id)
88
+ resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
89
+
90
+ span_thread = send_span(
91
+ trace_function_key:,
92
+ trace_id:,
93
+ span_id:,
94
+ parent_span_id:,
95
+ span_name:,
96
+ span_type:,
97
+ function_name:,
98
+ contexts: span_contexts,
99
+ prompt: span_prompt,
100
+ args:,
101
+ kwargs:,
102
+ result:,
103
+ error:,
104
+ started_at:,
105
+ ended_at:,
106
+ test_run_id: resolved_test_run_id,
107
+ input_source_span_id: resolved_input_source_span_id
108
+ )
109
+
110
+ if is_root_span
111
+ pending = @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) || [] }
112
+ pending << span_thread if span_thread
113
+ pending.each { |t| t.join(5) }
114
+
115
+ send_trace_completion(
116
+ trace_function_key:,
117
+ trace_id:,
118
+ started_at:,
119
+ ended_at:
120
+ )
121
+ else
122
+ @pending_span_mutex.synchronize do
123
+ @pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
124
+ end
125
+ end
126
+ rescue Exception # rubocop:disable Lint/RescueException
127
+ # Silently ignore — user's result/exception takes priority
128
+ # Catches Exception (not just StandardError) to handle SystemStackError
129
+ # from deeply nested serialization
130
+ end
131
+ end
132
+
133
+ result
134
+ end
135
+
136
+ private
137
+
138
+ def validate_span_type!(type)
139
+ return if SPAN_TYPES.include?(type.to_s)
140
+
141
+ raise ArgumentError, "Invalid span type '#{type}'. Must be one of: #{SPAN_TYPES.join(", ")}"
142
+ end
143
+
144
+ def send_trace_completion(trace_function_key:, trace_id:, started_at:, ended_at:)
145
+ trace_state = TraceState.get(trace_id)
146
+ trace_started_at = trace_state&.dig(:started_at) || started_at
147
+
148
+ raw_trace = {
149
+ "id" => trace_id,
150
+ "started_at" => trace_started_at,
151
+ "ended_at" => ended_at
152
+ }
153
+
154
+ if trace_state&.dig(:metadata)
155
+ raw_trace["metadata"] = trace_state[:metadata]
156
+ end
157
+ if trace_state&.dig(:contexts)
158
+ raw_trace["contexts"] = trace_state[:contexts]
159
+ end
160
+
161
+ payload = {
162
+ "type" => "sdk-function",
163
+ "source" => "ruby-sdk-function",
164
+ "traceFunctionKey" => trace_function_key,
165
+ "externalTrace" => raw_trace,
166
+ "completed" => true
167
+ }
168
+
169
+ if trace_state&.dig(:session_id)
170
+ payload["sessionId"] = trace_state[:session_id]
171
+ end
172
+
173
+ @http_client.send_external_trace(payload)
174
+
175
+ # Clean up trace state
176
+ TraceState.delete(trace_id)
177
+ end
178
+
179
+ def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
180
+ span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
181
+ started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil)
182
+ # Human-readable JSON (input/output fields)
183
+ human_inputs = Serialize.serialize_inputs(args, kwargs)
184
+ human_output = Serialize.serialize_value(result)
185
+
186
+ # Marshal + Base64 for full Ruby-to-Ruby object reconstruction
187
+ raw_input = (args.length == 1 && kwargs.empty?) ? args[0] : {args:, kwargs:}
188
+ marshalled_input = Serialize.marshal_value(raw_input)
189
+ marshalled_output = Serialize.marshal_value(result)
190
+
191
+ span_data = {
192
+ "name" => span_name,
193
+ "type" => span_type,
194
+ "input" => human_inputs,
195
+ "output" => human_output,
196
+ "function_name" => function_name
197
+ }
198
+ span_data["input_serialized"] = marshalled_input if marshalled_input
199
+ span_data["output_serialized"] = marshalled_output if marshalled_output
200
+ span_data["error"] = error if error
201
+ span_data["contexts"] = contexts if contexts&.any?
202
+ span_data["prompt"] = prompt if prompt
203
+
204
+ raw_span = {
205
+ "id" => span_id,
206
+ "trace_id" => trace_id,
207
+ "started_at" => started_at,
208
+ "ended_at" => ended_at,
209
+ "span_data" => span_data
210
+ }
211
+ raw_span["parent_id"] = parent_span_id if parent_span_id
212
+ raw_span["input_source_span_id"] = input_source_span_id if input_source_span_id
213
+
214
+ payload = {
215
+ "type" => "sdk-function",
216
+ "source" => "ruby-sdk-function",
217
+ "sourceTraceId" => trace_id,
218
+ "traceFunctionKey" => trace_function_key,
219
+ "rawSpan" => raw_span
220
+ }
221
+ payload["testRunId"] = test_run_id if test_run_id
222
+
223
+ @http_client.send_external_span(payload) # Returns the background thread
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ DEFAULT_SERVICE_URL = "https://bitfab.ai"
5
+ REPLAY_CONTEXT_KEY = :__bitfab_replay_context
6
+ end