sciosano 0.1.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.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ module Integrations
5
+ # Rack middleware for capturing exceptions in Rack applications
6
+ class RackMiddleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ # Add request breadcrumb
13
+ add_request_breadcrumb(env)
14
+
15
+ # Set request context
16
+ Sciosano.client&.with_context(request: build_request_context(env)) do
17
+ @app.call(env)
18
+ end
19
+ rescue Exception => e # rubocop:disable Lint/RescueException
20
+ # Capture exception but re-raise
21
+ capture_exception(e, env)
22
+ raise
23
+ end
24
+
25
+ private
26
+
27
+ def add_request_breadcrumb(env)
28
+ return unless Sciosano.client
29
+
30
+ method = env["REQUEST_METHOD"]
31
+ path = env["PATH_INFO"]
32
+
33
+ Sciosano.add_breadcrumb(
34
+ type: :http,
35
+ category: "request",
36
+ message: "#{method} #{path}",
37
+ data: {
38
+ method: method,
39
+ path: path,
40
+ query_string: env["QUERY_STRING"]
41
+ }.compact
42
+ )
43
+ end
44
+
45
+ def build_request_context(env)
46
+ request = Rack::Request.new(env)
47
+
48
+ {
49
+ url: request.url,
50
+ route: env["PATH_INFO"],
51
+ method: request.request_method,
52
+ query_string: request.query_string,
53
+ remote_ip: request.ip,
54
+ user_agent: request.user_agent,
55
+ referer: request.referer,
56
+ headers: extract_headers(env)
57
+ }.compact
58
+ end
59
+
60
+ def extract_headers(env)
61
+ headers = {}
62
+ env.each do |key, value|
63
+ next unless key.start_with?("HTTP_")
64
+ next if sensitive_header?(key)
65
+
66
+ header_name = key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
67
+ headers[header_name] = value
68
+ end
69
+ headers
70
+ end
71
+
72
+ def sensitive_header?(key)
73
+ %w[HTTP_COOKIE HTTP_AUTHORIZATION HTTP_X_API_KEY].include?(key)
74
+ end
75
+
76
+ def capture_exception(exception, env)
77
+ return unless Sciosano.client
78
+
79
+ # Add additional request context
80
+ request = Rack::Request.new(env)
81
+
82
+ Sciosano.capture_exception(exception, {
83
+ request: {
84
+ url: request.url,
85
+ method: request.request_method,
86
+ params: filtered_params(request.params)
87
+ }
88
+ })
89
+ end
90
+
91
+ def filtered_params(params)
92
+ return {} unless params.is_a?(Hash)
93
+
94
+ Sciosano.configuration.filter_parameters.each do |filter|
95
+ params = filter_hash(params, filter)
96
+ end
97
+
98
+ params
99
+ end
100
+
101
+ def filter_hash(hash, filter)
102
+ hash.transform_values do |value|
103
+ case value
104
+ when Hash
105
+ filter_hash(value, filter)
106
+ else
107
+ value
108
+ end
109
+ end.transform_keys do |key|
110
+ if matches_filter?(key, filter)
111
+ "[FILTERED]"
112
+ else
113
+ key
114
+ end
115
+ end
116
+ end
117
+
118
+ def matches_filter?(key, filter)
119
+ case filter
120
+ when Regexp
121
+ filter.match?(key.to_s)
122
+ else
123
+ key.to_s.downcase.include?(filter.to_s.downcase)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ module Integrations
5
+ # Rails integration via Railtie
6
+ class Railtie < Rails::Railtie
7
+ initializer "Sciosano.configure_rails_initialization", before: :build_middleware_stack do |app|
8
+ # Insert Rack middleware BEFORE ShowExceptions so we can capture errors
9
+ # before Rails renders error pages
10
+ app.middleware.insert_before(
11
+ ActionDispatch::ShowExceptions,
12
+ Sciosano::Integrations::RackMiddleware
13
+ )
14
+ end
15
+
16
+ # Subscribe to ActiveSupport notifications for breadcrumbs
17
+ initializer "Sciosano.subscribe_to_notifications" do
18
+ # Subscribe to SQL queries
19
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
20
+ event = ActiveSupport::Notifications::Event.new(*args)
21
+
22
+ # Skip SCHEMA queries and very fast queries
23
+ next if event.payload[:name] == "SCHEMA"
24
+ next if event.duration < 1 # Skip queries under 1ms
25
+
26
+ Sciosano.add_breadcrumb(
27
+ type: :default,
28
+ category: "query",
29
+ message: event.payload[:name] || "SQL Query",
30
+ data: {
31
+ sql: event.payload[:sql]&.slice(0, 500),
32
+ duration: event.duration.round(2),
33
+ cached: event.payload[:cached]
34
+ }.compact
35
+ )
36
+ end
37
+
38
+ # Subscribe to controller actions
39
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
40
+ event = ActiveSupport::Notifications::Event.new(*args)
41
+
42
+ Sciosano.add_breadcrumb(
43
+ type: :default,
44
+ category: "controller",
45
+ message: "#{event.payload[:controller]}##{event.payload[:action]}",
46
+ data: {
47
+ controller: event.payload[:controller],
48
+ action: event.payload[:action],
49
+ method: event.payload[:method],
50
+ path: event.payload[:path],
51
+ status: event.payload[:status],
52
+ duration: event.duration.round(2)
53
+ }.compact
54
+ )
55
+ end
56
+
57
+ # Subscribe to view rendering
58
+ ActiveSupport::Notifications.subscribe("render_template.action_view") do |*args|
59
+ event = ActiveSupport::Notifications::Event.new(*args)
60
+
61
+ # Only track significant renders (over 5ms)
62
+ next if event.duration < 5
63
+
64
+ Sciosano.add_breadcrumb(
65
+ type: :default,
66
+ category: "view",
67
+ message: "Render #{event.payload[:identifier]}",
68
+ data: {
69
+ template: event.payload[:identifier],
70
+ duration: event.duration.round(2)
71
+ }
72
+ )
73
+ end
74
+
75
+ # Subscribe to cache operations
76
+ ActiveSupport::Notifications.subscribe(/cache_(read|write|delete)\.active_support/) do |*args|
77
+ event = ActiveSupport::Notifications::Event.new(*args)
78
+
79
+ Sciosano.add_breadcrumb(
80
+ type: :default,
81
+ category: "cache",
82
+ message: "Cache #{event.name.split('.').first.sub('cache_', '')}",
83
+ data: {
84
+ key: event.payload[:key]&.to_s&.slice(0, 100),
85
+ hit: event.payload[:hit]
86
+ }.compact
87
+ )
88
+ end
89
+
90
+ # Subscribe to mailer events
91
+ ActiveSupport::Notifications.subscribe("deliver.action_mailer") do |*args|
92
+ event = ActiveSupport::Notifications::Event.new(*args)
93
+
94
+ Sciosano.add_breadcrumb(
95
+ type: :default,
96
+ category: "mailer",
97
+ message: "Send #{event.payload[:mailer]}",
98
+ data: {
99
+ mailer: event.payload[:mailer],
100
+ to: event.payload[:to]&.first,
101
+ subject: event.payload[:subject]&.slice(0, 100)
102
+ }.compact
103
+ )
104
+ end
105
+ end
106
+
107
+ # Set user from controller
108
+ initializer "Sciosano.controller_extensions" do
109
+ ActiveSupport.on_load(:action_controller) do
110
+ include Sciosano::Integrations::ControllerExtensions
111
+ end
112
+ end
113
+
114
+ config.after_initialize do
115
+ # Auto-configure from Rails
116
+ if Sciosano.configuration.api_key && !Sciosano.client
117
+ Sciosano.configure do |config|
118
+ config.environment = Rails.env
119
+ config.release ||= Rails.application.class.module_parent_name
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # Controller extensions for setting user context
126
+ module ControllerExtensions
127
+ extend ActiveSupport::Concern
128
+
129
+ included do
130
+ around_action :sciosano_set_context, if: -> { Sciosano.client }
131
+ end
132
+
133
+ private
134
+
135
+ def sciosano_set_context
136
+ # Set user if method exists
137
+ if respond_to?(:current_user, true) && current_user
138
+ Sciosano.set_user(
139
+ id: current_user.try(:id),
140
+ email: current_user.try(:email),
141
+ name: current_user.try(:name) || current_user.try(:full_name)
142
+ )
143
+ end
144
+
145
+ yield
146
+ ensure
147
+ Sciosano.clear_user
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ module Integrations
5
+ # Sidekiq middleware for capturing job errors
6
+ module Sidekiq
7
+ # Server middleware (runs on job worker)
8
+ class ServerMiddleware
9
+ def call(worker, job, queue)
10
+ # Add job breadcrumb
11
+ add_job_breadcrumb(worker, job, queue)
12
+
13
+ # Set job context
14
+ Sciosano.client&.with_context(
15
+ extra: {
16
+ sidekiq_job: job["class"],
17
+ sidekiq_queue: queue,
18
+ sidekiq_jid: job["jid"],
19
+ sidekiq_retry_count: job["retry_count"]
20
+ }
21
+ ) do
22
+ yield
23
+ end
24
+ rescue Exception => e # rubocop:disable Lint/RescueException
25
+ capture_exception(e, worker, job, queue)
26
+ raise
27
+ end
28
+
29
+ private
30
+
31
+ def add_job_breadcrumb(worker, job, queue)
32
+ return unless Sciosano.client
33
+
34
+ Sciosano.add_breadcrumb(
35
+ type: :default,
36
+ category: "sidekiq",
37
+ message: "Processing #{job['class']}",
38
+ data: {
39
+ job_class: job["class"],
40
+ queue: queue,
41
+ jid: job["jid"],
42
+ args_count: job["args"]&.size || 0
43
+ }
44
+ )
45
+ end
46
+
47
+ def capture_exception(exception, worker, job, queue)
48
+ return unless Sciosano.client
49
+
50
+ # Include job context in report
51
+ Sciosano.capture_exception(exception, {
52
+ tags: {
53
+ "sidekiq.queue" => queue,
54
+ "sidekiq.job" => job["class"]
55
+ },
56
+ extra: {
57
+ sidekiq: {
58
+ job_class: job["class"],
59
+ queue: queue,
60
+ jid: job["jid"],
61
+ args: sanitize_args(job["args"]),
62
+ retry_count: job["retry_count"],
63
+ created_at: job["created_at"],
64
+ enqueued_at: job["enqueued_at"]
65
+ }
66
+ }
67
+ })
68
+ end
69
+
70
+ def sanitize_args(args)
71
+ return [] unless args.is_a?(Array)
72
+
73
+ args.map do |arg|
74
+ case arg
75
+ when Hash
76
+ filter_sensitive_data(arg)
77
+ when String
78
+ arg.length > 200 ? "#{arg[0..200]}..." : arg
79
+ else
80
+ arg
81
+ end
82
+ end
83
+ end
84
+
85
+ def filter_sensitive_data(hash)
86
+ hash.transform_values do |value|
87
+ case value
88
+ when Hash
89
+ filter_sensitive_data(value)
90
+ else
91
+ value
92
+ end
93
+ end.transform_keys do |key|
94
+ if sensitive_key?(key)
95
+ "[FILTERED]"
96
+ else
97
+ key
98
+ end
99
+ end
100
+ end
101
+
102
+ def sensitive_key?(key)
103
+ key.to_s.downcase.match?(/password|secret|token|key|auth|credit|card/)
104
+ end
105
+ end
106
+
107
+ # Client middleware (runs when job is enqueued)
108
+ class ClientMiddleware
109
+ def call(worker_class, job, queue, redis_pool)
110
+ # Add enqueue breadcrumb
111
+ Sciosano.add_breadcrumb(
112
+ type: :default,
113
+ category: "sidekiq",
114
+ message: "Enqueued #{worker_class}",
115
+ data: {
116
+ job_class: worker_class.to_s,
117
+ queue: queue,
118
+ jid: job["jid"]
119
+ }
120
+ ) if Sciosano.client
121
+
122
+ yield
123
+ end
124
+ end
125
+
126
+ # Register Sidekiq middleware
127
+ def self.setup
128
+ ::Sidekiq.configure_server do |config|
129
+ config.server_middleware do |chain|
130
+ chain.add ServerMiddleware
131
+ end
132
+
133
+ config.client_middleware do |chain|
134
+ chain.add ClientMiddleware
135
+ end
136
+
137
+ # Also register as error handler for guaranteed capture
138
+ # This catches errors even if middleware chain has timing issues
139
+ config.error_handlers << proc do |exception, context|
140
+ next unless Sciosano.client
141
+
142
+ Sciosano.capture_exception(exception, {
143
+ tags: {
144
+ "sidekiq.error_handler" => "true"
145
+ },
146
+ extra: {
147
+ sidekiq_context: context
148
+ }
149
+ })
150
+ end
151
+ end
152
+
153
+ ::Sidekiq.configure_client do |config|
154
+ config.client_middleware do |chain|
155
+ chain.add ClientMiddleware
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ # Auto-setup when loaded
164
+ Sciosano::Integrations::Sidekiq.setup if defined?(::Sidekiq)
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ # Report represents an error or message to be sent to sciosano
5
+ class Report
6
+ attr_reader :id, :report_type, :source, :error, :user_description,
7
+ :context, :environment, :breadcrumbs, :tags, :timestamp
8
+
9
+ def initialize(attrs = {})
10
+ @id = attrs[:id] || SecureRandom.uuid
11
+ @report_type = attrs[:report_type] || "error"
12
+ @source = attrs[:source] || "backend"
13
+ @error = attrs[:error]
14
+ @user_description = attrs[:user_description]
15
+ @context = attrs[:context] || {}
16
+ @environment = attrs[:environment] || {}
17
+ @breadcrumbs = attrs[:breadcrumbs] || []
18
+ @tags = attrs[:tags] || {}
19
+ @timestamp = attrs[:timestamp] || Time.now.utc.iso8601(3)
20
+ end
21
+
22
+ # Build report from an exception
23
+ #
24
+ # @param exception [Exception]
25
+ # @param context [Context]
26
+ # @param breadcrumbs [Array<Breadcrumb>]
27
+ # @param configuration [Configuration]
28
+ # @return [Report]
29
+ def self.build_from_exception(exception, context:, breadcrumbs:, configuration:)
30
+ new(
31
+ report_type: "error",
32
+ source: "backend",
33
+ error: build_error_hash(exception),
34
+ context: build_context_hash(context, configuration),
35
+ environment: build_environment_hash(configuration),
36
+ breadcrumbs: breadcrumbs.map(&:to_h),
37
+ tags: context.tags
38
+ )
39
+ end
40
+
41
+ # Build report from a message
42
+ #
43
+ # @param message [String]
44
+ # @param level [Symbol]
45
+ # @param context [Context]
46
+ # @param breadcrumbs [Array<Breadcrumb>]
47
+ # @param configuration [Configuration]
48
+ # @return [Report]
49
+ def self.build_from_message(message, level:, context:, breadcrumbs:, configuration:)
50
+ new(
51
+ report_type: "feedback",
52
+ source: "backend",
53
+ user_description: message,
54
+ context: build_context_hash(context, configuration),
55
+ environment: build_environment_hash(configuration),
56
+ breadcrumbs: breadcrumbs.map(&:to_h),
57
+ tags: context.tags.merge("level" => level.to_s)
58
+ )
59
+ end
60
+
61
+ # Convert to JSON for API
62
+ #
63
+ # @return [String]
64
+ def to_json(*_args)
65
+ JSON.generate(to_h)
66
+ end
67
+
68
+ # Convert to hash
69
+ #
70
+ # @return [Hash]
71
+ def to_h
72
+ {
73
+ reportType: report_type,
74
+ source: source,
75
+ error: error,
76
+ userDescription: user_description,
77
+ context: context,
78
+ environment: environment,
79
+ breadcrumbs: breadcrumbs,
80
+ tags: tags,
81
+ timestamp: timestamp
82
+ }.compact
83
+ end
84
+
85
+ class << self
86
+ private
87
+
88
+ def build_error_hash(exception)
89
+ hash = {
90
+ name: exception.class.name,
91
+ message: exception.message,
92
+ file: extract_file(exception.backtrace),
93
+ line: extract_line(exception.backtrace)
94
+ }.compact
95
+
96
+ # stackTrace is required, always include it (even if empty string)
97
+ hash[:stackTrace] = format_backtrace(exception.backtrace)
98
+ hash
99
+ end
100
+
101
+ def format_backtrace(backtrace)
102
+ return "" unless backtrace
103
+
104
+ backtrace.first(50).join("\n")
105
+ end
106
+
107
+ def extract_file(backtrace)
108
+ return nil unless backtrace&.first
109
+
110
+ match = backtrace.first.match(/^(.+):(\d+)/)
111
+ match&.[](1)
112
+ end
113
+
114
+ def extract_line(backtrace)
115
+ return nil unless backtrace&.first
116
+
117
+ match = backtrace.first.match(/^(.+):(\d+)/)
118
+ match&.[](2)&.to_i
119
+ end
120
+
121
+ def build_context_hash(context, configuration)
122
+ hash = {
123
+ userId: context.user&.dig(:id)&.to_s,
124
+ userEmail: context.user&.dig(:email),
125
+ custom: filter_params(context.extra, configuration.filter_parameters),
126
+ url: context.request&.dig(:url) || ""
127
+ }.compact
128
+
129
+ # url is required, ensure it's always present even after compact
130
+ hash[:url] ||= ""
131
+
132
+ # Add route if available
133
+ if context.request&.dig(:route)
134
+ hash[:route] = context.request[:route]
135
+ end
136
+
137
+ hash
138
+ end
139
+
140
+ def build_environment_hash(configuration)
141
+ {
142
+ rubyVersion: RUBY_VERSION,
143
+ rubyPlatform: RUBY_PLATFORM,
144
+ hostname: Socket.gethostname,
145
+ nodeEnv: configuration.environment,
146
+ appVersion: configuration.release,
147
+ railsVersion: defined?(Rails) ? Rails.version : nil,
148
+ sidekiqVersion: defined?(Sidekiq) ? Sidekiq::VERSION : nil
149
+ }.compact
150
+ end
151
+
152
+ def filter_params(hash, filters)
153
+ return hash if filters.empty?
154
+
155
+ hash.transform_values do |value|
156
+ case value
157
+ when Hash
158
+ filter_params(value, filters)
159
+ else
160
+ value
161
+ end
162
+ end.reject do |key, _|
163
+ filters.any? do |filter|
164
+ case filter
165
+ when Regexp
166
+ filter.match?(key.to_s)
167
+ else
168
+ key.to_s.downcase.include?(filter.to_s.downcase)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sciosano
4
+ VERSION = "0.1.0"
5
+ end