brainzlab 0.1.11 → 0.1.20

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/README.md +210 -3
  4. data/lib/brainzlab/beacon/client.rb +21 -1
  5. data/lib/brainzlab/configuration.rb +81 -4
  6. data/lib/brainzlab/cortex/client.rb +21 -1
  7. data/lib/brainzlab/debug.rb +305 -0
  8. data/lib/brainzlab/dendrite/client.rb +21 -1
  9. data/lib/brainzlab/development/logger.rb +150 -0
  10. data/lib/brainzlab/development/store.rb +121 -0
  11. data/lib/brainzlab/development.rb +72 -0
  12. data/lib/brainzlab/devtools/assets/devtools.css +245 -109
  13. data/lib/brainzlab/devtools/assets/devtools.js +40 -0
  14. data/lib/brainzlab/devtools/middleware/asset_server.rb +1 -0
  15. data/lib/brainzlab/devtools/middleware/debug_panel.rb +1 -0
  16. data/lib/brainzlab/devtools/middleware/error_page.rb +56 -8
  17. data/lib/brainzlab/errors.rb +490 -0
  18. data/lib/brainzlab/flux/buffer.rb +2 -2
  19. data/lib/brainzlab/flux/client.rb +2 -2
  20. data/lib/brainzlab/instrumentation/active_support_cache.rb +60 -30
  21. data/lib/brainzlab/instrumentation/net_http.rb +21 -16
  22. data/lib/brainzlab/instrumentation.rb +6 -0
  23. data/lib/brainzlab/nerve/client.rb +21 -1
  24. data/lib/brainzlab/pulse/client.rb +66 -5
  25. data/lib/brainzlab/pulse.rb +24 -5
  26. data/lib/brainzlab/rails/log_formatter.rb +1 -1
  27. data/lib/brainzlab/rails/railtie.rb +18 -3
  28. data/lib/brainzlab/recall/buffer.rb +3 -1
  29. data/lib/brainzlab/recall/client.rb +74 -6
  30. data/lib/brainzlab/recall.rb +19 -2
  31. data/lib/brainzlab/reflex/client.rb +66 -5
  32. data/lib/brainzlab/reflex.rb +40 -8
  33. data/lib/brainzlab/sentinel/client.rb +21 -1
  34. data/lib/brainzlab/synapse/client.rb +21 -1
  35. data/lib/brainzlab/testing/event_store.rb +377 -0
  36. data/lib/brainzlab/testing/helpers.rb +650 -0
  37. data/lib/brainzlab/testing/matchers.rb +391 -0
  38. data/lib/brainzlab/testing.rb +327 -0
  39. data/lib/brainzlab/utilities/circuit_breaker.rb +32 -3
  40. data/lib/brainzlab/vault/client.rb +21 -1
  41. data/lib/brainzlab/version.rb +1 -1
  42. data/lib/brainzlab/vision/client.rb +53 -6
  43. data/lib/brainzlab.rb +67 -0
  44. data/lib/fluyenta-ruby.rb +3 -0
  45. metadata +34 -11
@@ -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;
@@ -18,6 +18,7 @@ module BrainzLab
18
18
 
19
19
  def call(env)
20
20
  return @app.call(env) unless DevTools.enabled?
21
+ return @app.call(env) if env['REQUEST_METHOD'] == 'OPTIONS'
21
22
 
22
23
  path = env['PATH_INFO']
23
24
  asset_prefix = DevTools.asset_path
@@ -38,6 +38,7 @@ module BrainzLab
38
38
  return false unless DevTools.debug_panel_enabled?
39
39
  return false unless DevTools.allowed_environment?
40
40
  return false unless DevTools.allowed_ip?(extract_ip(env))
41
+ return false if env['REQUEST_METHOD'] == 'OPTIONS'
41
42
  return false if asset_request?(env['PATH_INFO'])
42
43
  return false if devtools_asset_request?(env['PATH_INFO'])
43
44
  return false if turbo_stream_request?(env)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module BrainzLab
4
6
  module DevTools
5
7
  module Middleware
@@ -10,29 +12,33 @@ module BrainzLab
10
12
  end
11
13
 
12
14
  def call(env)
15
+ return @app.call(env) if env['REQUEST_METHOD'] == 'OPTIONS'
13
16
  return @app.call(env) unless should_handle?(env)
14
17
 
15
18
  begin
16
19
  status, headers, body = @app.call(env)
17
20
 
18
21
  # Check if this is an error response that we should intercept
19
- if status >= 400 && html_response?(headers) && !json_request?(env)
22
+ if status >= 400 && html_response?(headers) && !json_request?(env) && !api_path?(env)
20
23
  # Check if this looks like Rails' default error page
21
24
  body_content = collect_body(body)
22
25
  if body_content.include?('Action Controller: Exception caught') || body_content.include?('background: #C00')
23
26
  # Extract exception info from the page
24
27
  exception_info = extract_exception_from_html(body_content)
25
28
  if exception_info
26
- data = collect_debug_data_from_info(env, exception_info)
27
- return render_error_page_from_info(exception_info, data)
29
+ data = collect_debug_data_from_info(env, exception_info, status)
30
+ return render_error_page_from_info(exception_info, data, status)
28
31
  end
29
32
  end
30
33
  end
31
34
 
32
35
  [status, headers, body]
33
36
  rescue Exception => e
34
- # Don't intercept if request wants JSON
35
- return raise_exception(e) if json_request?(env)
37
+ # For JSON/API requests, return a proper JSON error response
38
+ if json_request?(env) || api_path?(env)
39
+ capture_to_reflex(e)
40
+ return json_error_response(e)
41
+ end
36
42
 
37
43
  # Still capture to Reflex if available
38
44
  capture_to_reflex(e)
@@ -87,7 +93,7 @@ module BrainzLab
87
93
  .gsub(' ', ' ')
88
94
  end
89
95
 
90
- def collect_debug_data_from_info(env, info)
96
+ def collect_debug_data_from_info(env, info, status = 500)
91
97
  context = defined?(BrainzLab::Context) ? BrainzLab::Context.current : nil
92
98
  collector_data = Data::Collector.get_request_data
93
99
 
@@ -113,7 +119,7 @@ module BrainzLab
113
119
  }
114
120
  end
115
121
 
116
- def render_error_page_from_info(info, data)
122
+ def render_error_page_from_info(info, data, status = 500)
117
123
  # Create a simple exception-like object
118
124
  exception = StandardError.new(info[:message])
119
125
  exception.define_singleton_method(:class) do
@@ -126,7 +132,7 @@ module BrainzLab
126
132
  html = @renderer.render(exception, data)
127
133
 
128
134
  [
129
- 500,
135
+ status,
130
136
  {
131
137
  'Content-Type' => 'text/html; charset=utf-8',
132
138
  'Content-Length' => html.bytesize.to_s,
@@ -169,6 +175,48 @@ module BrainzLab
169
175
  env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
170
176
  end
171
177
 
178
+ def api_path?(env)
179
+ path = env['PATH_INFO'] || ''
180
+ path.start_with?('/api/')
181
+ end
182
+
183
+ def exception_to_status(exception)
184
+ case exception.class.name
185
+ when 'ActionController::RoutingError', 'AbstractController::ActionNotFound'
186
+ 404
187
+ when 'ActionController::MethodNotAllowed'
188
+ 405
189
+ when 'ActionController::BadRequest', 'ActionDispatch::Http::Parameters::ParseError'
190
+ 400
191
+ when 'ActionController::UnknownFormat'
192
+ 406
193
+ else
194
+ 500
195
+ end
196
+ end
197
+
198
+ def json_error_response(exception)
199
+ status_code = exception_to_status(exception)
200
+ message = case status_code
201
+ when 400 then 'Bad request'
202
+ when 404 then 'Not found'
203
+ when 405 then 'Method not allowed'
204
+ when 406 then 'Not acceptable'
205
+ else 'Internal server error'
206
+ end
207
+
208
+ body = JSON.generate({ error: message })
209
+ [
210
+ status_code,
211
+ {
212
+ 'Content-Type' => 'application/json; charset=utf-8',
213
+ 'Content-Length' => body.bytesize.to_s,
214
+ 'X-Content-Type-Options' => 'nosniff'
215
+ },
216
+ [body]
217
+ ]
218
+ end
219
+
172
220
  def capture_to_reflex(exception)
173
221
  return unless defined?(BrainzLab::Reflex)
174
222
 
@@ -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
@@ -76,7 +76,7 @@ module BrainzLab
76
76
 
77
77
  @client.send_batch(events: events, metrics: metrics)
78
78
  rescue StandardError => e
79
- BrainzLab.debug("[Flux] Batch send failed: #{e.message}")
79
+ BrainzLab.debug_log("[Flux] Batch send failed: #{e.message}")
80
80
  end
81
81
 
82
82
  def start_flush_thread
@@ -86,7 +86,7 @@ module BrainzLab
86
86
  begin
87
87
  flush! if size.positive?
88
88
  rescue StandardError => e
89
- BrainzLab.debug("[Flux] Flush thread error: #{e.message}")
89
+ BrainzLab.debug_log("[Flux] Flush thread error: #{e.message}")
90
90
  end
91
91
  end
92
92
  end