fluyenta-ruby 0.1.14

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 (121) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +11 -0
  4. data/README.md +571 -0
  5. data/lib/brainzlab/beacon/client.rb +227 -0
  6. data/lib/brainzlab/beacon/provisioner.rb +44 -0
  7. data/lib/brainzlab/beacon.rb +215 -0
  8. data/lib/brainzlab/configuration.rb +676 -0
  9. data/lib/brainzlab/context.rb +90 -0
  10. data/lib/brainzlab/cortex/cache.rb +59 -0
  11. data/lib/brainzlab/cortex/client.rb +159 -0
  12. data/lib/brainzlab/cortex/provisioner.rb +49 -0
  13. data/lib/brainzlab/cortex.rb +223 -0
  14. data/lib/brainzlab/debug.rb +305 -0
  15. data/lib/brainzlab/dendrite/client.rb +250 -0
  16. data/lib/brainzlab/dendrite/provisioner.rb +44 -0
  17. data/lib/brainzlab/dendrite.rb +195 -0
  18. data/lib/brainzlab/development/logger.rb +150 -0
  19. data/lib/brainzlab/development/store.rb +121 -0
  20. data/lib/brainzlab/development.rb +72 -0
  21. data/lib/brainzlab/devtools/assets/devtools.css +1329 -0
  22. data/lib/brainzlab/devtools/assets/devtools.js +396 -0
  23. data/lib/brainzlab/devtools/assets/logo.svg +6 -0
  24. data/lib/brainzlab/devtools/assets/templates/debug_panel.html.erb +511 -0
  25. data/lib/brainzlab/devtools/assets/templates/error_page.html.erb +1086 -0
  26. data/lib/brainzlab/devtools/data/collector.rb +248 -0
  27. data/lib/brainzlab/devtools/middleware/asset_server.rb +63 -0
  28. data/lib/brainzlab/devtools/middleware/database_handler.rb +177 -0
  29. data/lib/brainzlab/devtools/middleware/debug_panel.rb +126 -0
  30. data/lib/brainzlab/devtools/middleware/error_page.rb +377 -0
  31. data/lib/brainzlab/devtools/renderers/debug_panel_renderer.rb +159 -0
  32. data/lib/brainzlab/devtools/renderers/error_page_renderer.rb +98 -0
  33. data/lib/brainzlab/devtools.rb +75 -0
  34. data/lib/brainzlab/errors.rb +490 -0
  35. data/lib/brainzlab/flux/buffer.rb +96 -0
  36. data/lib/brainzlab/flux/client.rb +68 -0
  37. data/lib/brainzlab/flux/provisioner.rb +124 -0
  38. data/lib/brainzlab/flux.rb +184 -0
  39. data/lib/brainzlab/instrumentation/action_cable.rb +351 -0
  40. data/lib/brainzlab/instrumentation/action_controller.rb +649 -0
  41. data/lib/brainzlab/instrumentation/action_dispatch.rb +259 -0
  42. data/lib/brainzlab/instrumentation/action_mailbox.rb +197 -0
  43. data/lib/brainzlab/instrumentation/action_mailer.rb +182 -0
  44. data/lib/brainzlab/instrumentation/action_view.rb +380 -0
  45. data/lib/brainzlab/instrumentation/active_job.rb +569 -0
  46. data/lib/brainzlab/instrumentation/active_record.rb +559 -0
  47. data/lib/brainzlab/instrumentation/active_storage.rb +541 -0
  48. data/lib/brainzlab/instrumentation/active_support_cache.rb +730 -0
  49. data/lib/brainzlab/instrumentation/aws.rb +183 -0
  50. data/lib/brainzlab/instrumentation/dalli.rb +108 -0
  51. data/lib/brainzlab/instrumentation/delayed_job.rb +234 -0
  52. data/lib/brainzlab/instrumentation/elasticsearch.rb +209 -0
  53. data/lib/brainzlab/instrumentation/excon.rb +152 -0
  54. data/lib/brainzlab/instrumentation/faraday.rb +181 -0
  55. data/lib/brainzlab/instrumentation/good_job.rb +102 -0
  56. data/lib/brainzlab/instrumentation/grape.rb +293 -0
  57. data/lib/brainzlab/instrumentation/graphql.rb +252 -0
  58. data/lib/brainzlab/instrumentation/httparty.rb +193 -0
  59. data/lib/brainzlab/instrumentation/mongodb.rb +187 -0
  60. data/lib/brainzlab/instrumentation/net_http.rb +114 -0
  61. data/lib/brainzlab/instrumentation/rails_deprecation.rb +139 -0
  62. data/lib/brainzlab/instrumentation/railties.rb +134 -0
  63. data/lib/brainzlab/instrumentation/redis.rb +324 -0
  64. data/lib/brainzlab/instrumentation/resque.rb +114 -0
  65. data/lib/brainzlab/instrumentation/sidekiq.rb +265 -0
  66. data/lib/brainzlab/instrumentation/solid_queue.rb +194 -0
  67. data/lib/brainzlab/instrumentation/stripe.rb +163 -0
  68. data/lib/brainzlab/instrumentation/typhoeus.rb +106 -0
  69. data/lib/brainzlab/instrumentation.rb +360 -0
  70. data/lib/brainzlab/nerve/client.rb +235 -0
  71. data/lib/brainzlab/nerve/provisioner.rb +44 -0
  72. data/lib/brainzlab/nerve.rb +219 -0
  73. data/lib/brainzlab/pulse/client.rb +203 -0
  74. data/lib/brainzlab/pulse/instrumentation.rb +401 -0
  75. data/lib/brainzlab/pulse/propagation.rb +241 -0
  76. data/lib/brainzlab/pulse/provisioner.rb +114 -0
  77. data/lib/brainzlab/pulse/tracer.rb +111 -0
  78. data/lib/brainzlab/pulse.rb +294 -0
  79. data/lib/brainzlab/rails/log_formatter.rb +807 -0
  80. data/lib/brainzlab/rails/log_subscriber.rb +334 -0
  81. data/lib/brainzlab/rails/railtie.rb +606 -0
  82. data/lib/brainzlab/recall/buffer.rb +66 -0
  83. data/lib/brainzlab/recall/client.rb +158 -0
  84. data/lib/brainzlab/recall/logger.rb +116 -0
  85. data/lib/brainzlab/recall/provisioner.rb +130 -0
  86. data/lib/brainzlab/recall.rb +175 -0
  87. data/lib/brainzlab/reflex/breadcrumbs.rb +55 -0
  88. data/lib/brainzlab/reflex/client.rb +150 -0
  89. data/lib/brainzlab/reflex/provisioner.rb +116 -0
  90. data/lib/brainzlab/reflex.rb +421 -0
  91. data/lib/brainzlab/sentinel/client.rb +236 -0
  92. data/lib/brainzlab/sentinel/provisioner.rb +44 -0
  93. data/lib/brainzlab/sentinel.rb +165 -0
  94. data/lib/brainzlab/signal/client.rb +60 -0
  95. data/lib/brainzlab/signal/provisioner.rb +115 -0
  96. data/lib/brainzlab/signal.rb +136 -0
  97. data/lib/brainzlab/synapse/client.rb +308 -0
  98. data/lib/brainzlab/synapse/provisioner.rb +44 -0
  99. data/lib/brainzlab/synapse.rb +270 -0
  100. data/lib/brainzlab/testing/event_store.rb +377 -0
  101. data/lib/brainzlab/testing/helpers.rb +650 -0
  102. data/lib/brainzlab/testing/matchers.rb +391 -0
  103. data/lib/brainzlab/testing.rb +327 -0
  104. data/lib/brainzlab/utilities/circuit_breaker.rb +290 -0
  105. data/lib/brainzlab/utilities/health_check.rb +294 -0
  106. data/lib/brainzlab/utilities/log_formatter.rb +254 -0
  107. data/lib/brainzlab/utilities/rate_limiter.rb +230 -0
  108. data/lib/brainzlab/utilities.rb +17 -0
  109. data/lib/brainzlab/vault/cache.rb +80 -0
  110. data/lib/brainzlab/vault/client.rb +216 -0
  111. data/lib/brainzlab/vault/provisioner.rb +49 -0
  112. data/lib/brainzlab/vault.rb +262 -0
  113. data/lib/brainzlab/version.rb +5 -0
  114. data/lib/brainzlab/vision/client.rb +175 -0
  115. data/lib/brainzlab/vision/provisioner.rb +136 -0
  116. data/lib/brainzlab/vision.rb +155 -0
  117. data/lib/brainzlab-sdk.rb +3 -0
  118. data/lib/brainzlab.rb +306 -0
  119. data/lib/generators/brainzlab/install/install_generator.rb +63 -0
  120. data/lib/generators/brainzlab/install/templates/brainzlab.rb.tt +77 -0
  121. metadata +251 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module BrainzLab
8
+ module Reflex
9
+ class Client
10
+ MAX_RETRIES = 3
11
+ RETRY_DELAY = 0.5
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def send_error(payload)
18
+ return unless @config.reflex_enabled && @config.reflex_valid?
19
+
20
+ post('/api/v1/errors', payload)
21
+ end
22
+
23
+ def send_batch(payloads)
24
+ return unless @config.reflex_enabled && @config.reflex_valid?
25
+ return if payloads.empty?
26
+
27
+ post('/api/v1/errors/batch', { errors: payloads })
28
+ end
29
+
30
+ private
31
+
32
+ def post(path, body)
33
+ uri = URI.join(@config.reflex_url, path)
34
+
35
+ # Call on_send callback if configured
36
+ invoke_on_send(:reflex, :post, path, body)
37
+
38
+ # Log debug output for request
39
+ log_debug_request(path, body)
40
+
41
+ request = Net::HTTP::Post.new(uri)
42
+ request['Content-Type'] = 'application/json'
43
+ request['Authorization'] = "Bearer #{@config.reflex_auth_key}"
44
+ request['User-Agent'] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
45
+ request.body = JSON.generate(body)
46
+
47
+ execute_with_retry(uri, request, path)
48
+ rescue StandardError => e
49
+ handle_error(e, context: { path: path, body_size: body.to_s.length })
50
+ nil
51
+ end
52
+
53
+ def execute_with_retry(uri, request, path)
54
+ retries = 0
55
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+
57
+ begin
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = uri.scheme == 'https'
60
+ http.open_timeout = 5
61
+ http.read_timeout = 10
62
+
63
+ response = http.request(request)
64
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
65
+
66
+ # Log debug output for response
67
+ log_debug_response(response.code.to_i, duration_ms)
68
+
69
+ case response.code.to_i
70
+ when 200..299
71
+ begin
72
+ JSON.parse(response.body)
73
+ rescue StandardError
74
+ {}
75
+ end
76
+ when 429, 500..599
77
+ raise RetryableError, "Server error: #{response.code}"
78
+ else
79
+ handle_error(
80
+ StandardError.new("Reflex API error: #{response.code}"),
81
+ context: { path: path, status: response.code, body: response.body }
82
+ )
83
+ nil
84
+ end
85
+ rescue RetryableError, Net::OpenTimeout, Net::ReadTimeout => e
86
+ retries += 1
87
+ if retries <= MAX_RETRIES
88
+ sleep(RETRY_DELAY * retries)
89
+ retry
90
+ end
91
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
92
+ log_debug_response(0, duration_ms, error: e.message)
93
+ handle_error(e, context: { path: path, retries: retries })
94
+ nil
95
+ end
96
+ end
97
+
98
+ def log_debug_request(path, body)
99
+ return unless BrainzLab::Debug.enabled?
100
+
101
+ data = if body.is_a?(Hash) && body[:errors]
102
+ { count: body[:errors].size }
103
+ elsif body.is_a?(Hash) && body[:exception]
104
+ { exception: body[:exception][:type] }
105
+ else
106
+ {}
107
+ end
108
+
109
+ BrainzLab::Debug.log_request(:reflex, 'POST', path, data: data)
110
+ end
111
+
112
+ def log_debug_response(status, duration_ms, error: nil)
113
+ return unless BrainzLab::Debug.enabled?
114
+
115
+ BrainzLab::Debug.log_response(:reflex, status, duration_ms, error: error)
116
+ end
117
+
118
+ def invoke_on_send(service, method, path, payload)
119
+ return unless @config.on_send
120
+
121
+ @config.on_send.call(service, method, path, payload)
122
+ rescue StandardError => e
123
+ # Don't let callback errors break the SDK
124
+ log_error("on_send callback error: #{e.message}")
125
+ end
126
+
127
+ def handle_error(error, context: {})
128
+ log_error("#{error.message}")
129
+
130
+ # Call on_error callback if configured
131
+ return unless @config.on_error
132
+
133
+ @config.on_error.call(error, context.merge(service: :reflex))
134
+ rescue StandardError => e
135
+ # Don't let callback errors break the SDK
136
+ log_error("on_error callback error: #{e.message}")
137
+ end
138
+
139
+ def log_error(message)
140
+ BrainzLab::Debug.log(message, level: :error) if BrainzLab::Debug.enabled?
141
+
142
+ return unless @config.logger
143
+
144
+ @config.logger.error("[BrainzLab::Reflex] #{message}")
145
+ end
146
+
147
+ class RetryableError < StandardError; end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'fileutils'
7
+
8
+ module BrainzLab
9
+ module Reflex
10
+ class Provisioner
11
+ CACHE_DIR = ENV.fetch('BRAINZLAB_CACHE_DIR') { File.join(Dir.home, '.brainzlab') }
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def ensure_project!
18
+ return unless should_provision?
19
+
20
+ # Try cached credentials first
21
+ if (cached = load_cached_credentials)
22
+ apply_credentials(cached)
23
+ return cached
24
+ end
25
+
26
+ # Provision new project
27
+ project = provision_project
28
+ return unless project
29
+
30
+ # Cache and apply credentials
31
+ cache_credentials(project)
32
+ apply_credentials(project)
33
+
34
+ project
35
+ end
36
+
37
+ private
38
+
39
+ def should_provision?
40
+ return false unless @config.reflex_auto_provision
41
+ return false unless @config.app_name.to_s.strip.length.positive?
42
+ # Only skip if reflex_api_key is already set (not secret_key, which may be for Recall)
43
+ return false if @config.reflex_api_key.to_s.strip.length.positive?
44
+ return false unless @config.reflex_master_key.to_s.strip.length.positive?
45
+
46
+ true
47
+ end
48
+
49
+ def provision_project
50
+ uri = URI.parse("#{@config.reflex_url}/api/v1/projects/provision")
51
+ request = Net::HTTP::Post.new(uri)
52
+ request['Content-Type'] = 'application/json'
53
+ request['X-Master-Key'] = @config.reflex_master_key
54
+ request['User-Agent'] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
55
+ request.body = JSON.generate({ name: @config.app_name })
56
+
57
+ response = execute(uri, request)
58
+ return nil unless response.is_a?(Net::HTTPSuccess)
59
+
60
+ JSON.parse(response.body, symbolize_names: true)
61
+ rescue StandardError => e
62
+ log_error("Failed to provision Reflex project: #{e.message}")
63
+ nil
64
+ end
65
+
66
+ def load_cached_credentials
67
+ path = cache_file_path
68
+ return nil unless File.exist?(path)
69
+
70
+ data = JSON.parse(File.read(path), symbolize_names: true)
71
+
72
+ # Validate cached data has required keys
73
+ return nil unless data[:api_key]
74
+
75
+ data
76
+ rescue StandardError => e
77
+ log_error("Failed to load cached Reflex credentials: #{e.message}")
78
+ nil
79
+ end
80
+
81
+ def cache_credentials(project)
82
+ FileUtils.mkdir_p(CACHE_DIR)
83
+ File.write(cache_file_path, JSON.generate(project))
84
+ rescue StandardError => e
85
+ log_error("Failed to cache Reflex credentials: #{e.message}")
86
+ end
87
+
88
+ def cache_file_path
89
+ File.join(CACHE_DIR, "#{@config.app_name}.reflex.json")
90
+ end
91
+
92
+ def apply_credentials(project)
93
+ # Use reflex_api_key for Reflex if we have a separate key
94
+ # Otherwise fall back to shared secret_key
95
+ @config.reflex_api_key = project[:api_key]
96
+
97
+ # Also set service name from app_name if not already set
98
+ @config.service ||= @config.app_name
99
+ end
100
+
101
+ def execute(uri, request)
102
+ http = Net::HTTP.new(uri.host, uri.port)
103
+ http.use_ssl = uri.scheme == 'https'
104
+ http.open_timeout = 5
105
+ http.read_timeout = 10
106
+ http.request(request)
107
+ end
108
+
109
+ def log_error(message)
110
+ return unless @config.logger
111
+
112
+ @config.logger.error("[BrainzLab::Reflex] #{message}")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'reflex/client'
4
+ require_relative 'reflex/breadcrumbs'
5
+ require_relative 'reflex/provisioner'
6
+
7
+ module BrainzLab
8
+ module Reflex
9
+ FILTERED_PARAMS = %w[password password_confirmation token api_key secret credit_card cvv ssn].freeze
10
+
11
+ class << self
12
+ def capture(exception, **context)
13
+ return unless enabled?
14
+ return if capture_disabled?
15
+ return if excluded?(exception)
16
+ return if sampled_out?
17
+
18
+ # Log debug output for the operation
19
+ log_debug_capture(exception)
20
+
21
+ payload = build_payload(exception, context)
22
+ payload = run_before_send(payload, exception)
23
+ return if payload.nil?
24
+
25
+ # In development mode, log locally instead of sending to server
26
+ if BrainzLab.configuration.development_mode?
27
+ Development.record(service: :reflex, event_type: 'error', payload: payload)
28
+ return
29
+ end
30
+
31
+ # Auto-provision project on first capture if app_name is configured
32
+ ensure_provisioned!
33
+
34
+ return unless BrainzLab.configuration.reflex_valid?
35
+
36
+ client.send_error(payload)
37
+ end
38
+
39
+ def capture_message(message, level: :error, **context)
40
+ return unless enabled?
41
+ return if capture_disabled?
42
+ return if sampled_out?
43
+
44
+ # Log debug output for the operation
45
+ log_debug_message(message, level)
46
+
47
+ payload = build_message_payload(message, level, context)
48
+ payload = run_before_send(payload, nil)
49
+ return if payload.nil?
50
+
51
+ # In development mode, log locally instead of sending to server
52
+ if BrainzLab.configuration.development_mode?
53
+ Development.record(service: :reflex, event_type: 'message', payload: payload)
54
+ return
55
+ end
56
+
57
+ # Auto-provision project on first capture if app_name is configured
58
+ ensure_provisioned!
59
+
60
+ return unless BrainzLab.configuration.reflex_valid?
61
+
62
+ client.send_error(payload)
63
+ end
64
+
65
+ def ensure_provisioned!
66
+ return if @provisioned
67
+
68
+ @provisioned = true
69
+ provisioner.ensure_project!
70
+ end
71
+
72
+ def provisioner
73
+ @provisioner ||= Provisioner.new(BrainzLab.configuration)
74
+ end
75
+
76
+ # Temporarily disable capture within a block
77
+ def without_capture
78
+ previous = Thread.current[:brainzlab_capture_disabled]
79
+ Thread.current[:brainzlab_capture_disabled] = true
80
+ yield
81
+ ensure
82
+ Thread.current[:brainzlab_capture_disabled] = previous
83
+ end
84
+
85
+ def client
86
+ @client ||= Client.new(BrainzLab.configuration)
87
+ end
88
+
89
+ def reset!
90
+ @client = nil
91
+ @provisioner = nil
92
+ @provisioned = false
93
+ end
94
+
95
+ private
96
+
97
+ def enabled?
98
+ BrainzLab.configuration.reflex_effectively_enabled?
99
+ end
100
+
101
+ def capture_disabled?
102
+ Thread.current[:brainzlab_capture_disabled] == true
103
+ end
104
+
105
+ def excluded?(exception)
106
+ config = BrainzLab.configuration
107
+ config.reflex_excluded_exceptions.any? do |excluded|
108
+ case excluded
109
+ when String
110
+ exception.class.name == excluded || exception.class.to_s == excluded
111
+ when Class
112
+ exception.is_a?(excluded)
113
+ when Regexp
114
+ exception.class.name =~ excluded
115
+ else
116
+ false
117
+ end
118
+ end
119
+ end
120
+
121
+ def sampled_out?
122
+ rate = BrainzLab.configuration.reflex_sample_rate
123
+ return false if rate.nil? || rate >= 1.0
124
+
125
+ rand > rate
126
+ end
127
+
128
+ def run_before_send(payload, exception)
129
+ hook = BrainzLab.configuration.reflex_before_send
130
+ return payload unless hook
131
+
132
+ hook.call(payload, exception)
133
+ end
134
+
135
+ def build_payload(exception, context)
136
+ config = BrainzLab.configuration
137
+ ctx = Context.current
138
+
139
+ payload = {
140
+ timestamp: Time.now.utc.iso8601(3),
141
+ error_class: exception.class.name,
142
+ message: exception.message,
143
+ backtrace: format_backtrace(exception.backtrace || []),
144
+
145
+ # Environment
146
+ environment: config.environment,
147
+ commit: config.commit,
148
+ branch: config.branch,
149
+ server_name: config.host,
150
+
151
+ # Request context
152
+ request_id: ctx.request_id
153
+ }
154
+
155
+ # Add request info if available
156
+ add_request_info(payload, ctx)
157
+
158
+ # Add user info
159
+ add_user_info(payload, ctx, context)
160
+
161
+ # Add context, tags, extra
162
+ add_context_data(payload, ctx, context)
163
+
164
+ # Add breadcrumbs
165
+ payload[:breadcrumbs] = ctx.breadcrumbs.to_a
166
+
167
+ # Add fingerprint for error grouping
168
+ payload[:fingerprint] = compute_fingerprint(exception, context, ctx)
169
+
170
+ payload
171
+ end
172
+
173
+ def build_message_payload(message, level, context)
174
+ config = BrainzLab.configuration
175
+ ctx = Context.current
176
+
177
+ payload = {
178
+ timestamp: Time.now.utc.iso8601(3),
179
+ error_class: 'Message',
180
+ message: message.to_s,
181
+ level: level.to_s,
182
+
183
+ # Environment
184
+ environment: config.environment,
185
+ commit: config.commit,
186
+ branch: config.branch,
187
+ server_name: config.host,
188
+
189
+ # Request context
190
+ request_id: ctx.request_id
191
+ }
192
+
193
+ # Add request info if available
194
+ add_request_info(payload, ctx)
195
+
196
+ # Add user info
197
+ add_user_info(payload, ctx, context)
198
+
199
+ # Add context, tags, extra
200
+ add_context_data(payload, ctx, context)
201
+
202
+ # Add breadcrumbs
203
+ payload[:breadcrumbs] = ctx.breadcrumbs.to_a
204
+
205
+ payload
206
+ end
207
+
208
+ def add_request_info(payload, ctx)
209
+ return unless ctx.request_path
210
+
211
+ payload[:request] = {
212
+ method: ctx.request_method,
213
+ path: ctx.request_path,
214
+ url: ctx.request_url,
215
+ params: filter_params(ctx.request_params),
216
+ headers: ctx.request_headers,
217
+ controller: ctx.controller,
218
+ action: ctx.action
219
+ }.compact
220
+ end
221
+
222
+ def add_user_info(payload, ctx, context)
223
+ user = context[:user] || ctx.user
224
+ return if user.nil? || user.empty?
225
+
226
+ payload[:user] = {
227
+ id: user[:id]&.to_s,
228
+ email: user[:email],
229
+ name: user[:name]
230
+ }.compact
231
+
232
+ # Store additional user data
233
+ extra_user = user.except(:id, :email, :name)
234
+ payload[:user_data] = extra_user unless extra_user.empty?
235
+ end
236
+
237
+ def add_context_data(payload, ctx, context)
238
+ # Tags from context + provided tags
239
+ tags = ctx.tags.merge(context[:tags] || {})
240
+ payload[:tags] = tags unless tags.empty?
241
+
242
+ # Extra data from context + provided extra
243
+ extra = ctx.data_hash.merge(context[:extra] || {})
244
+ extra = extra.except(:user, :tags) # Remove user and tags as they're separate
245
+ payload[:extra] = extra unless extra.empty?
246
+
247
+ # General context
248
+ payload[:context] = context.except(:user, :tags, :extra) unless context.except(:user, :tags, :extra).empty?
249
+ end
250
+
251
+ def format_backtrace(backtrace)
252
+ backtrace.first(30).map do |line|
253
+ if line.is_a?(String)
254
+ parse_backtrace_line(line)
255
+ else
256
+ line
257
+ end
258
+ end
259
+ end
260
+
261
+ def parse_backtrace_line(line)
262
+ # Parse various Ruby backtrace formats:
263
+ # - "path/to/file.rb:42:in `method_name'" (backtick + single quote)
264
+ # - "path/to/file.rb:42:in 'method_name'" (single quotes)
265
+ # - "path/to/file.rb:42" (no method)
266
+ if line =~ /\A(.+):(\d+):in [`']([^']+)'?\z/
267
+ {
268
+ file: ::Regexp.last_match(1),
269
+ line: ::Regexp.last_match(2).to_i,
270
+ function: ::Regexp.last_match(3),
271
+ in_app: in_app_frame?(::Regexp.last_match(1))
272
+ }
273
+ elsif line =~ /\A(.+):(\d+)\z/
274
+ {
275
+ file: ::Regexp.last_match(1),
276
+ line: ::Regexp.last_match(2).to_i,
277
+ function: nil,
278
+ in_app: in_app_frame?(::Regexp.last_match(1))
279
+ }
280
+ else
281
+ # Still store file for display even if format is unexpected
282
+ { file: line, line: nil, function: nil, in_app: false }
283
+ end
284
+ end
285
+
286
+ def in_app_frame?(path)
287
+ return false if path.nil?
288
+ return false if path.include?('vendor/')
289
+ return false if path.include?('/gems/')
290
+ return false if path.include?('/ruby/')
291
+
292
+ # Match both relative and absolute paths containing app/ or lib/
293
+ path.start_with?('app/', 'lib/', './app/', './lib/') ||
294
+ path.include?('/app/') ||
295
+ path.include?('/lib/')
296
+ end
297
+
298
+ def filter_params(params)
299
+ return nil if params.nil?
300
+
301
+ scrub_fields = BrainzLab.configuration.scrub_fields + FILTERED_PARAMS.map(&:to_sym)
302
+ deep_filter(params, scrub_fields)
303
+ end
304
+
305
+ def deep_filter(obj, fields)
306
+ case obj
307
+ when Hash
308
+ obj.each_with_object({}) do |(key, value), result|
309
+ result[key] = if should_filter?(key, fields)
310
+ '[FILTERED]'
311
+ else
312
+ deep_filter(value, fields)
313
+ end
314
+ end
315
+ when Array
316
+ obj.map { |item| deep_filter(item, fields) }
317
+ else
318
+ obj
319
+ end
320
+ end
321
+
322
+ def should_filter?(key, fields)
323
+ key_str = key.to_s.downcase
324
+ fields.any? do |field|
325
+ case field
326
+ when Regexp
327
+ key_str.match?(field)
328
+ else
329
+ key_str == field.to_s.downcase
330
+ end
331
+ end
332
+ end
333
+
334
+ # Compute fingerprint for error grouping
335
+ # Returns an array of strings that uniquely identify the error type
336
+ def compute_fingerprint(exception, context, ctx)
337
+ custom_callback = BrainzLab.configuration.reflex_fingerprint
338
+
339
+ if custom_callback
340
+ # Call user's custom fingerprint callback
341
+ result = custom_callback.call(exception, context, ctx)
342
+
343
+ # Normalize the result
344
+ case result
345
+ when Array
346
+ result.map(&:to_s)
347
+ when String
348
+ [result]
349
+ when nil
350
+ # nil means use default fingerprinting
351
+ default_fingerprint(exception)
352
+ else
353
+ [result.to_s]
354
+ end
355
+ else
356
+ default_fingerprint(exception)
357
+ end
358
+ rescue StandardError => e
359
+ BrainzLab.debug_log("Custom fingerprint callback failed: #{e.message}")
360
+ default_fingerprint(exception)
361
+ end
362
+
363
+ # Default fingerprint: error class + first in-app frame (or first frame)
364
+ def default_fingerprint(exception)
365
+ parts = [exception.class.name]
366
+
367
+ if exception.backtrace&.any?
368
+ # Try to find the first in-app frame
369
+ in_app_frame = exception.backtrace.find { |line| in_app_line?(line) }
370
+ frame = in_app_frame || exception.backtrace.first
371
+
372
+ if frame
373
+ # Normalize the frame (remove line numbers for consistent grouping)
374
+ normalized = normalize_frame_for_fingerprint(frame)
375
+ parts << normalized if normalized
376
+ end
377
+ end
378
+
379
+ parts
380
+ end
381
+
382
+ def in_app_line?(line)
383
+ return false if line.nil?
384
+ return false if line.include?('vendor/')
385
+ return false if line.include?('/gems/')
386
+
387
+ line.start_with?('app/', 'lib/', './app/', './lib/') ||
388
+ line.include?('/app/') ||
389
+ line.include?('/lib/')
390
+ end
391
+
392
+ def normalize_frame_for_fingerprint(frame)
393
+ return nil unless frame.is_a?(String)
394
+
395
+ # Extract file and method, normalize out line numbers
396
+ # "app/models/user.rb:42:in `save'" -> "app/models/user.rb:in `save'"
397
+ if frame =~ /\A(.+):\d+:in `(.+)'\z/
398
+ "#{::Regexp.last_match(1)}:in `#{::Regexp.last_match(2)}'"
399
+ elsif frame =~ /\A(.+):\d+\z/
400
+ ::Regexp.last_match(1)
401
+ else
402
+ frame
403
+ end
404
+ end
405
+
406
+ def log_debug_capture(exception)
407
+ return unless BrainzLab::Debug.enabled?
408
+
409
+ truncated_message = exception.message.to_s.length > 40 ? "#{exception.message.to_s[0..37]}..." : exception.message.to_s
410
+ BrainzLab::Debug.log_operation(:reflex, "capture #{exception.class.name}: \"#{truncated_message}\"")
411
+ end
412
+
413
+ def log_debug_message(message, level)
414
+ return unless BrainzLab::Debug.enabled?
415
+
416
+ truncated_message = message.to_s.length > 40 ? "#{message.to_s[0..37]}..." : message.to_s
417
+ BrainzLab::Debug.log_operation(:reflex, "message [#{level.to_s.upcase}] \"#{truncated_message}\"")
418
+ end
419
+ end
420
+ end
421
+ end