brainzlab 0.1.11 → 0.1.12

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.
@@ -4,6 +4,46 @@
4
4
  (function() {
5
5
  'use strict';
6
6
 
7
+ // ============================================
8
+ // DARK MODE SUPPORT
9
+ // ============================================
10
+ // Sync with brainzlab-theme localStorage key (used across all BrainzLab products)
11
+ function initDarkMode() {
12
+ const theme = localStorage.getItem('brainzlab-theme');
13
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
14
+
15
+ if (theme === 'dark' || (!theme && prefersDark)) {
16
+ document.documentElement.classList.add('dark');
17
+ } else {
18
+ document.documentElement.classList.remove('dark');
19
+ }
20
+ }
21
+
22
+ // Listen for theme changes from other windows/tabs
23
+ function setupThemeListener() {
24
+ window.addEventListener('storage', function(e) {
25
+ if (e.key === 'brainzlab-theme') {
26
+ initDarkMode();
27
+ }
28
+ });
29
+
30
+ // Also listen for system preference changes
31
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
32
+ // Only react if no explicit theme is set
33
+ if (!localStorage.getItem('brainzlab-theme')) {
34
+ if (e.matches) {
35
+ document.documentElement.classList.add('dark');
36
+ } else {
37
+ document.documentElement.classList.remove('dark');
38
+ }
39
+ }
40
+ });
41
+ }
42
+
43
+ // Initialize dark mode immediately
44
+ initDarkMode();
45
+ setupThemeListener();
46
+
7
47
  // Load Stimulus if not available
8
48
  let stimulusApp = null;
9
49
  let StimulusController = null;
@@ -0,0 +1,490 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ # Base error class for all BrainzLab SDK errors.
5
+ # Provides structured error information including hints and documentation links.
6
+ #
7
+ # @example Raising a structured error
8
+ # raise BrainzLab::Error.new(
9
+ # "Operation failed",
10
+ # hint: "Check your network connection",
11
+ # docs_url: "https://docs.brainzlab.io/troubleshooting",
12
+ # code: "operation_failed"
13
+ # )
14
+ #
15
+ # @example Catching and inspecting errors
16
+ # begin
17
+ # BrainzLab::Vault.get("secret")
18
+ # rescue BrainzLab::Error => e
19
+ # puts e.message # What went wrong
20
+ # puts e.hint # How to fix it
21
+ # puts e.docs_url # Where to learn more
22
+ # puts e.code # Machine-readable code
23
+ # end
24
+ #
25
+ class Error < StandardError
26
+ # @return [String, nil] A helpful hint on how to resolve the error
27
+ attr_reader :hint
28
+
29
+ # @return [String, nil] URL to relevant documentation
30
+ attr_reader :docs_url
31
+
32
+ # @return [String, nil] Machine-readable error code for programmatic handling
33
+ attr_reader :code
34
+
35
+ # @return [Hash, nil] Additional context about the error
36
+ attr_reader :context
37
+
38
+ DOCS_BASE_URL = 'https://docs.brainzlab.io'
39
+
40
+ # Initialize a new BrainzLab error.
41
+ #
42
+ # @param message [String] The error message describing what went wrong
43
+ # @param hint [String, nil] A helpful hint on how to resolve the error
44
+ # @param docs_url [String, nil] URL to relevant documentation
45
+ # @param code [String, nil] Machine-readable error code
46
+ # @param context [Hash, nil] Additional context about the error
47
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
48
+ @message = message
49
+ @hint = hint
50
+ @docs_url = docs_url
51
+ @code = code
52
+ @context = context
53
+ super(message)
54
+ end
55
+
56
+ # Format the error as a detailed string with hints and documentation links.
57
+ #
58
+ # @return [String] Formatted error message
59
+ def to_s
60
+ super
61
+ end
62
+
63
+ # Return a detailed formatted version of the error with hints and documentation links.
64
+ # Use this method when you want the full structured output.
65
+ #
66
+ # @return [String] Detailed formatted error message
67
+ def detailed_message(highlight: false, **_kwargs)
68
+ # Get the base message without class name duplication
69
+ base_msg = @message || super
70
+
71
+ parts = ["#{self.class.name}: #{base_msg}"]
72
+
73
+ parts << "" << "Hint: #{hint}" if hint
74
+ parts << "Docs: #{docs_url}" if docs_url
75
+ parts << "Code: #{code}" if code
76
+
77
+ if context && !context.empty?
78
+ parts << "" << "Context:"
79
+ context.each do |key, value|
80
+ parts << " #{key}: #{value}"
81
+ end
82
+ end
83
+
84
+ parts.join("\n")
85
+ end
86
+
87
+ # Inspect the error for debugging
88
+ #
89
+ # @return [String] Inspection output
90
+ def inspect
91
+ "#<#{self.class.name}: #{message}#{" (#{code})" if code}>"
92
+ end
93
+
94
+ # Return a hash representation of the error for logging/serialization.
95
+ #
96
+ # @return [Hash] Error details as a hash
97
+ def to_h
98
+ {
99
+ error_class: self.class.name,
100
+ message: message,
101
+ hint: hint,
102
+ docs_url: docs_url,
103
+ code: code,
104
+ context: context
105
+ }.compact
106
+ end
107
+
108
+ # Alias for to_h
109
+ def as_json
110
+ to_h
111
+ end
112
+ end
113
+
114
+ # Raised when the SDK is misconfigured or required configuration is missing.
115
+ #
116
+ # @example Missing API key
117
+ # raise BrainzLab::ConfigurationError.new(
118
+ # "API key is required",
119
+ # hint: "Set BRAINZLAB_SECRET_KEY environment variable or configure via BrainzLab.configure",
120
+ # code: "missing_api_key"
121
+ # )
122
+ #
123
+ class ConfigurationError < Error
124
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
125
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/configuration"
126
+ code ||= 'configuration_error'
127
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context)
128
+ end
129
+ end
130
+
131
+ # Raised when authentication fails due to invalid or expired credentials.
132
+ #
133
+ # @example Invalid API key
134
+ # raise BrainzLab::AuthenticationError.new(
135
+ # "Invalid API key",
136
+ # hint: "Check that your API key is correct and has not expired",
137
+ # code: "invalid_api_key"
138
+ # )
139
+ #
140
+ class AuthenticationError < Error
141
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
142
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/authentication"
143
+ code ||= 'authentication_error'
144
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context)
145
+ end
146
+ end
147
+
148
+ # Raised when a connection to BrainzLab services cannot be established.
149
+ #
150
+ # @example Connection timeout
151
+ # raise BrainzLab::ConnectionError.new(
152
+ # "Connection timed out",
153
+ # hint: "Check your network connection and firewall settings",
154
+ # code: "connection_timeout"
155
+ # )
156
+ #
157
+ class ConnectionError < Error
158
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
159
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/troubleshooting#connection-issues"
160
+ code ||= 'connection_error'
161
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context)
162
+ end
163
+ end
164
+
165
+ # Raised when the rate limit for API requests has been exceeded.
166
+ #
167
+ # @example Rate limit exceeded
168
+ # raise BrainzLab::RateLimitError.new(
169
+ # "Rate limit exceeded",
170
+ # hint: "Wait before retrying or consider upgrading your plan",
171
+ # code: "rate_limit_exceeded",
172
+ # context: { retry_after: 60, limit: 1000, remaining: 0 }
173
+ # )
174
+ #
175
+ class RateLimitError < Error
176
+ # @return [Integer, nil] Seconds to wait before retrying
177
+ attr_reader :retry_after
178
+
179
+ # @return [Integer, nil] The rate limit ceiling
180
+ attr_reader :limit
181
+
182
+ # @return [Integer, nil] Remaining requests in the current window
183
+ attr_reader :remaining
184
+
185
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, retry_after: nil, limit: nil, remaining: nil)
186
+ @retry_after = retry_after
187
+ @limit = limit
188
+ @remaining = remaining
189
+
190
+ hint ||= retry_after ? "Wait #{retry_after} seconds before retrying" : 'Reduce request frequency or upgrade your plan'
191
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/rate-limits"
192
+ code ||= 'rate_limit_exceeded'
193
+
194
+ context ||= {}
195
+ context[:retry_after] = retry_after if retry_after
196
+ context[:limit] = limit if limit
197
+ context[:remaining] = remaining if remaining
198
+
199
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
200
+ end
201
+ end
202
+
203
+ # Raised when request parameters or data fail validation.
204
+ #
205
+ # @example Invalid parameter
206
+ # raise BrainzLab::ValidationError.new(
207
+ # "Invalid email format",
208
+ # hint: "Provide a valid email address (e.g., user@example.com)",
209
+ # code: "invalid_email",
210
+ # context: { field: "email", value: "invalid" }
211
+ # )
212
+ #
213
+ class ValidationError < Error
214
+ # @return [String, nil] The field that failed validation
215
+ attr_reader :field
216
+
217
+ # @return [Array<Hash>, nil] List of validation errors for multiple fields
218
+ attr_reader :errors
219
+
220
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, field: nil, errors: nil)
221
+ @field = field
222
+ @errors = errors
223
+
224
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/api-reference"
225
+ code ||= 'validation_error'
226
+
227
+ context ||= {}
228
+ context[:field] = field if field
229
+ context[:errors] = errors if errors
230
+
231
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
232
+ end
233
+ end
234
+
235
+ # Raised when a requested resource is not found.
236
+ #
237
+ # @example Resource not found
238
+ # raise BrainzLab::NotFoundError.new(
239
+ # "Secret 'database_url' not found",
240
+ # hint: "Verify the secret name and environment",
241
+ # code: "secret_not_found"
242
+ # )
243
+ #
244
+ class NotFoundError < Error
245
+ # @return [String, nil] The type of resource that was not found
246
+ attr_reader :resource_type
247
+
248
+ # @return [String, nil] The identifier of the resource that was not found
249
+ attr_reader :resource_id
250
+
251
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, resource_type: nil, resource_id: nil)
252
+ @resource_type = resource_type
253
+ @resource_id = resource_id
254
+
255
+ code ||= 'not_found'
256
+
257
+ context ||= {}
258
+ context[:resource_type] = resource_type if resource_type
259
+ context[:resource_id] = resource_id if resource_id
260
+
261
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
262
+ end
263
+ end
264
+
265
+ # Raised when a server-side error occurs.
266
+ #
267
+ # @example Server error
268
+ # raise BrainzLab::ServerError.new(
269
+ # "Internal server error",
270
+ # hint: "This is a temporary issue. Please retry your request.",
271
+ # code: "internal_server_error"
272
+ # )
273
+ #
274
+ class ServerError < Error
275
+ # @return [Integer, nil] HTTP status code from the server
276
+ attr_reader :status_code
277
+
278
+ # @return [String, nil] Request ID for support reference
279
+ attr_reader :request_id
280
+
281
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, status_code: nil, request_id: nil)
282
+ @status_code = status_code
283
+ @request_id = request_id
284
+
285
+ hint ||= 'This is a temporary issue. Please retry your request. If the problem persists, contact support.'
286
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/troubleshooting#server-errors"
287
+ code ||= 'server_error'
288
+
289
+ context ||= {}
290
+ context[:status_code] = status_code if status_code
291
+ context[:request_id] = request_id if request_id
292
+
293
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
294
+ end
295
+ end
296
+
297
+ # Raised when an operation times out.
298
+ #
299
+ # @example Request timeout
300
+ # raise BrainzLab::TimeoutError.new(
301
+ # "Request timed out after 30 seconds",
302
+ # hint: "The operation took too long. Try again or increase timeout settings.",
303
+ # code: "request_timeout"
304
+ # )
305
+ #
306
+ class TimeoutError < Error
307
+ # @return [Integer, nil] Timeout duration in seconds
308
+ attr_reader :timeout_seconds
309
+
310
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, timeout_seconds: nil)
311
+ @timeout_seconds = timeout_seconds
312
+
313
+ hint ||= 'The operation took too long. Try again or increase timeout settings.'
314
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/configuration#timeouts"
315
+ code ||= 'timeout'
316
+
317
+ context ||= {}
318
+ context[:timeout_seconds] = timeout_seconds if timeout_seconds
319
+
320
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
321
+ end
322
+ end
323
+
324
+ # Raised when a service is temporarily unavailable.
325
+ #
326
+ # @example Service unavailable
327
+ # raise BrainzLab::ServiceUnavailableError.new(
328
+ # "Vault service is currently unavailable",
329
+ # hint: "The service is undergoing maintenance. Please try again later.",
330
+ # code: "vault_unavailable"
331
+ # )
332
+ #
333
+ class ServiceUnavailableError < Error
334
+ # @return [String, nil] The name of the unavailable service
335
+ attr_reader :service_name
336
+
337
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, service_name: nil)
338
+ @service_name = service_name
339
+
340
+ hint ||= 'The service is temporarily unavailable. Please try again later.'
341
+ docs_url ||= "#{DOCS_BASE_URL}/status"
342
+ code ||= 'service_unavailable'
343
+
344
+ context ||= {}
345
+ context[:service_name] = service_name if service_name
346
+
347
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
348
+ end
349
+ end
350
+
351
+ # Helper module for wrapping low-level errors into structured BrainzLab errors
352
+ module ErrorHandler
353
+ module_function
354
+
355
+ # Wrap a standard error into a structured BrainzLab error.
356
+ #
357
+ # @param error [StandardError] The original error
358
+ # @param service [String] The service name (e.g., 'Vault', 'Cortex')
359
+ # @param operation [String] The operation being performed
360
+ # @return [BrainzLab::Error] A structured BrainzLab error
361
+ def wrap(error, service:, operation:)
362
+ case error
363
+ when Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
364
+ TimeoutError.new(
365
+ "#{service} #{operation} timed out: #{error.message}",
366
+ hint: 'Check your network connection or increase timeout settings.',
367
+ code: "#{service.downcase}_timeout",
368
+ context: { service: service, operation: operation }
369
+ )
370
+ when Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH
371
+ ConnectionError.new(
372
+ "Unable to connect to #{service}: #{error.message}",
373
+ hint: 'Check that the service is running and accessible.',
374
+ code: "#{service.downcase}_connection_failed",
375
+ context: { service: service, operation: operation }
376
+ )
377
+ when SocketError
378
+ ConnectionError.new(
379
+ "DNS resolution failed for #{service}: #{error.message}",
380
+ hint: 'Check your network connection and DNS settings.',
381
+ code: "#{service.downcase}_dns_error",
382
+ context: { service: service, operation: operation }
383
+ )
384
+ when JSON::ParserError
385
+ ServerError.new(
386
+ "Invalid response from #{service}: #{error.message}",
387
+ hint: 'The server returned an unexpected response format.',
388
+ code: "#{service.downcase}_invalid_response",
389
+ context: { service: service, operation: operation }
390
+ )
391
+ when OpenSSL::SSL::SSLError
392
+ ConnectionError.new(
393
+ "SSL error connecting to #{service}: #{error.message}",
394
+ hint: 'Check SSL certificates and ensure the connection is secure.',
395
+ code: "#{service.downcase}_ssl_error",
396
+ context: { service: service, operation: operation }
397
+ )
398
+ else
399
+ Error.new(
400
+ "#{service} #{operation} failed: #{error.message}",
401
+ hint: 'An unexpected error occurred. Check the logs for more details.',
402
+ code: "#{service.downcase}_error",
403
+ context: { service: service, operation: operation, original_error: error.class.name }
404
+ )
405
+ end
406
+ end
407
+
408
+ # Convert an HTTP response to a structured error.
409
+ #
410
+ # @param response [Net::HTTPResponse] The HTTP response
411
+ # @param service [String] The service name
412
+ # @param operation [String] The operation being performed
413
+ # @return [BrainzLab::Error] A structured BrainzLab error
414
+ def from_response(response, service:, operation:)
415
+ status_code = response.code.to_i
416
+ body = parse_response_body(response)
417
+ message = body[:message] || body[:error] || "HTTP #{status_code}"
418
+ request_id = response['X-Request-Id']
419
+
420
+ case status_code
421
+ when 400
422
+ ValidationError.new(
423
+ message,
424
+ hint: body[:hint] || 'Check the request parameters.',
425
+ code: body[:code] || 'bad_request',
426
+ context: { service: service, operation: operation, status_code: status_code }
427
+ )
428
+ when 401
429
+ AuthenticationError.new(
430
+ message,
431
+ hint: body[:hint] || "Verify your #{service} API key is correct and active.",
432
+ code: body[:code] || 'unauthorized',
433
+ context: { service: service, operation: operation }
434
+ )
435
+ when 403
436
+ AuthenticationError.new(
437
+ message,
438
+ hint: body[:hint] || 'Your API key does not have permission for this operation.',
439
+ code: body[:code] || 'forbidden',
440
+ context: { service: service, operation: operation }
441
+ )
442
+ when 404
443
+ NotFoundError.new(
444
+ message,
445
+ hint: body[:hint] || 'The requested resource does not exist.',
446
+ code: body[:code] || 'not_found',
447
+ context: { service: service, operation: operation }
448
+ )
449
+ when 422
450
+ ValidationError.new(
451
+ message,
452
+ hint: body[:hint] || 'The request was well-formed but contained invalid data.',
453
+ code: body[:code] || 'unprocessable_entity',
454
+ errors: body[:errors],
455
+ context: { service: service, operation: operation, status_code: status_code }
456
+ )
457
+ when 429
458
+ RateLimitError.new(
459
+ message,
460
+ retry_after: response['Retry-After']&.to_i,
461
+ limit: response['X-RateLimit-Limit']&.to_i,
462
+ remaining: response['X-RateLimit-Remaining']&.to_i,
463
+ context: { service: service, operation: operation }
464
+ )
465
+ when 500..599
466
+ ServerError.new(
467
+ message,
468
+ hint: body[:hint] || 'A server error occurred. Please retry your request.',
469
+ code: body[:code] || "server_error_#{status_code}",
470
+ status_code: status_code,
471
+ request_id: request_id,
472
+ context: { service: service, operation: operation }
473
+ )
474
+ else
475
+ Error.new(
476
+ message,
477
+ hint: body[:hint],
478
+ code: body[:code] || "http_#{status_code}",
479
+ context: { service: service, operation: operation, status_code: status_code }
480
+ )
481
+ end
482
+ end
483
+
484
+ def parse_response_body(response)
485
+ JSON.parse(response.body, symbolize_names: true)
486
+ rescue JSON::ParserError, TypeError
487
+ {}
488
+ end
489
+ end
490
+ end
@@ -208,7 +208,27 @@ module BrainzLab
208
208
  end
209
209
 
210
210
  def log_error(operation, error)
211
- BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{error.message}")
211
+ structured_error = ErrorHandler.wrap(error, service: 'Nerve', operation: operation)
212
+ BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{structured_error.message}")
213
+
214
+ # Call on_error callback if configured
215
+ if @config.on_error
216
+ @config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
217
+ end
218
+ end
219
+
220
+ def handle_response_error(response, operation)
221
+ return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated) || response.is_a?(Net::HTTPNoContent)
222
+
223
+ structured_error = ErrorHandler.from_response(response, service: 'Nerve', operation: operation)
224
+ BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{structured_error.message}")
225
+
226
+ # Call on_error callback if configured
227
+ if @config.on_error
228
+ @config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
229
+ end
230
+
231
+ structured_error
212
232
  end
213
233
  end
214
234
  end
@@ -84,20 +84,29 @@ module BrainzLab
84
84
 
85
85
  def post(path, body)
86
86
  uri = URI.join(@config.pulse_url, path)
87
+
88
+ # Call on_send callback if configured
89
+ invoke_on_send(:pulse, :post, path, body)
90
+
91
+ # Log debug output for request
92
+ log_debug_request(path, body)
93
+
87
94
  request = Net::HTTP::Post.new(uri)
88
95
  request['Content-Type'] = 'application/json'
89
96
  request['Authorization'] = "Bearer #{@config.pulse_auth_key}"
90
97
  request['User-Agent'] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
91
98
  request.body = JSON.generate(body)
92
99
 
93
- execute_with_retry(uri, request)
100
+ execute_with_retry(uri, request, path)
94
101
  rescue StandardError => e
95
- log_error("Failed to send to Pulse: #{e.message}")
102
+ handle_error(e, context: { path: path, body_size: body.to_s.length })
96
103
  nil
97
104
  end
98
105
 
99
- def execute_with_retry(uri, request)
106
+ def execute_with_retry(uri, request, path)
100
107
  retries = 0
108
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+
101
110
  begin
102
111
  http = Net::HTTP.new(uri.host, uri.port)
103
112
  http.use_ssl = uri.scheme == 'https'
@@ -105,6 +114,10 @@ module BrainzLab
105
114
  http.read_timeout = 10
106
115
 
107
116
  response = http.request(request)
117
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
118
+
119
+ # Log debug output for response
120
+ log_debug_response(response.code.to_i, duration_ms)
108
121
 
109
122
  case response.code.to_i
110
123
  when 200..299
@@ -116,7 +129,10 @@ module BrainzLab
116
129
  when 429, 500..599
117
130
  raise RetryableError, "Server error: #{response.code}"
118
131
  else
119
- log_error("Pulse API error: #{response.code} - #{response.body}")
132
+ handle_error(
133
+ StandardError.new("Pulse API error: #{response.code}"),
134
+ context: { path: path, status: response.code, body: response.body }
135
+ )
120
136
  nil
121
137
  end
122
138
  rescue RetryableError, Net::OpenTimeout, Net::ReadTimeout => e
@@ -125,12 +141,57 @@ module BrainzLab
125
141
  sleep(RETRY_DELAY * retries)
126
142
  retry
127
143
  end
128
- log_error("Failed after #{MAX_RETRIES} retries: #{e.message}")
144
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
145
+ log_debug_response(0, duration_ms, error: e.message)
146
+ handle_error(e, context: { path: path, retries: retries })
129
147
  nil
130
148
  end
131
149
  end
132
150
 
151
+ def log_debug_request(path, body)
152
+ return unless BrainzLab::Debug.enabled?
153
+
154
+ data = if body.is_a?(Hash) && body[:traces]
155
+ { count: body[:traces].size }
156
+ elsif body.is_a?(Hash) && body[:name]
157
+ { name: body[:name] }
158
+ else
159
+ {}
160
+ end
161
+
162
+ BrainzLab::Debug.log_request(:pulse, 'POST', path, data: data)
163
+ end
164
+
165
+ def log_debug_response(status, duration_ms, error: nil)
166
+ return unless BrainzLab::Debug.enabled?
167
+
168
+ BrainzLab::Debug.log_response(:pulse, status, duration_ms, error: error)
169
+ end
170
+
171
+ def invoke_on_send(service, method, path, payload)
172
+ return unless @config.on_send
173
+
174
+ @config.on_send.call(service, method, path, payload)
175
+ rescue StandardError => e
176
+ # Don't let callback errors break the SDK
177
+ log_error("on_send callback error: #{e.message}")
178
+ end
179
+
180
+ def handle_error(error, context: {})
181
+ log_error("#{error.message}")
182
+
183
+ # Call on_error callback if configured
184
+ return unless @config.on_error
185
+
186
+ @config.on_error.call(error, context.merge(service: :pulse))
187
+ rescue StandardError => e
188
+ # Don't let callback errors break the SDK
189
+ log_error("on_error callback error: #{e.message}")
190
+ end
191
+
133
192
  def log_error(message)
193
+ BrainzLab::Debug.log(message, level: :error) if BrainzLab::Debug.enabled?
194
+
134
195
  return unless @config.logger
135
196
 
136
197
  @config.logger.error("[BrainzLab::Pulse] #{message}")