rails-informant 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/Rakefile +10 -0
- data/VERSION +1 -0
- data/app/controllers/rails_informant/api/base_controller.rb +62 -0
- data/app/controllers/rails_informant/api/errors_controller.rb +72 -0
- data/app/controllers/rails_informant/api/occurrences_controller.rb +14 -0
- data/app/controllers/rails_informant/api/status_controller.rb +29 -0
- data/app/jobs/rails_informant/application_job.rb +4 -0
- data/app/jobs/rails_informant/notify_job.rb +34 -0
- data/app/jobs/rails_informant/purge_job.rb +29 -0
- data/app/models/rails_informant/application_record.rb +5 -0
- data/app/models/rails_informant/error_group.rb +175 -0
- data/app/models/rails_informant/occurrence.rb +22 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260227000000_create_informant_tables.rb +65 -0
- data/exe/informant-mcp +27 -0
- data/lib/generators/rails_informant/devin/templates/error-triage.devin.md +48 -0
- data/lib/generators/rails_informant/devin_generator.rb +12 -0
- data/lib/generators/rails_informant/install_generator.rb +20 -0
- data/lib/generators/rails_informant/skill/templates/SKILL.md +168 -0
- data/lib/generators/rails_informant/skill_generator.rb +12 -0
- data/lib/generators/rails_informant/templates/create_informant_tables.rb.erb +55 -0
- data/lib/generators/rails_informant/templates/initializer.rb.erb +33 -0
- data/lib/rails_informant/breadcrumb_buffer.rb +30 -0
- data/lib/rails_informant/breadcrumb_subscriber.rb +51 -0
- data/lib/rails_informant/configuration.rb +51 -0
- data/lib/rails_informant/context_builder.rb +142 -0
- data/lib/rails_informant/context_filter.rb +45 -0
- data/lib/rails_informant/current.rb +5 -0
- data/lib/rails_informant/engine.rb +86 -0
- data/lib/rails_informant/error_recorder.rb +47 -0
- data/lib/rails_informant/error_subscriber.rb +17 -0
- data/lib/rails_informant/fingerprint.rb +23 -0
- data/lib/rails_informant/mcp/base_tool.rb +38 -0
- data/lib/rails_informant/mcp/client.rb +123 -0
- data/lib/rails_informant/mcp/configuration.rb +90 -0
- data/lib/rails_informant/mcp/server.rb +29 -0
- data/lib/rails_informant/mcp/tools/annotate_error.rb +25 -0
- data/lib/rails_informant/mcp/tools/delete_error.rb +25 -0
- data/lib/rails_informant/mcp/tools/get_error.rb +24 -0
- data/lib/rails_informant/mcp/tools/get_informant_status.rb +22 -0
- data/lib/rails_informant/mcp/tools/ignore_error.rb +24 -0
- data/lib/rails_informant/mcp/tools/list_environments.rb +20 -0
- data/lib/rails_informant/mcp/tools/list_errors.rb +32 -0
- data/lib/rails_informant/mcp/tools/list_occurrences.rb +27 -0
- data/lib/rails_informant/mcp/tools/mark_duplicate.rb +25 -0
- data/lib/rails_informant/mcp/tools/mark_fix_pending.rb +27 -0
- data/lib/rails_informant/mcp/tools/reopen_error.rb +24 -0
- data/lib/rails_informant/mcp/tools/resolve_error.rb +24 -0
- data/lib/rails_informant/mcp.rb +22 -0
- data/lib/rails_informant/middleware/error_capture.rb +28 -0
- data/lib/rails_informant/middleware/rescued_exception_interceptor.rb +16 -0
- data/lib/rails_informant/notifiers/devin.rb +61 -0
- data/lib/rails_informant/notifiers/notification_policy.rb +85 -0
- data/lib/rails_informant/notifiers/slack.rb +77 -0
- data/lib/rails_informant/notifiers/webhook.rb +31 -0
- data/lib/rails_informant/structured_event_subscriber.rb +14 -0
- data/lib/rails_informant/version.rb +3 -0
- data/lib/rails_informant.rb +147 -0
- data/lib/tasks/rails_informant.rake +30 -0
- metadata +177 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class BreadcrumbBuffer
|
|
3
|
+
CAPACITY = 50
|
|
4
|
+
|
|
5
|
+
def self.current
|
|
6
|
+
RailsInformant::Current.breadcrumbs ||= new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@crumbs = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def record(category:, message:, metadata: {}, duration: nil)
|
|
14
|
+
@crumbs << {
|
|
15
|
+
category:,
|
|
16
|
+
message:,
|
|
17
|
+
metadata:,
|
|
18
|
+
duration:,
|
|
19
|
+
timestamp: Time.current.iso8601(3)
|
|
20
|
+
}
|
|
21
|
+
@crumbs.shift if @crumbs.size > CAPACITY
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def flush
|
|
25
|
+
result = @crumbs
|
|
26
|
+
@crumbs = []
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class BreadcrumbSubscriber
|
|
3
|
+
NEEDS_FILTERING = %w[
|
|
4
|
+
process_action.action_controller
|
|
5
|
+
redirect_to.action_controller
|
|
6
|
+
start_processing.action_controller
|
|
7
|
+
].to_set.freeze
|
|
8
|
+
|
|
9
|
+
SUBSCRIPTIONS = {
|
|
10
|
+
"cache_fetch_hit.active_support" => %i[key],
|
|
11
|
+
"cache_read.active_support" => %i[key hit],
|
|
12
|
+
"cache_write.active_support" => %i[key],
|
|
13
|
+
"deliver.action_mailer" => %i[mailer],
|
|
14
|
+
"halted_callback.action_controller" => %i[filter],
|
|
15
|
+
"instantiation.active_record" => %i[record_count class_name],
|
|
16
|
+
"perform.active_job" => %i[job],
|
|
17
|
+
"perform_action.action_cable" => %i[channel_class action],
|
|
18
|
+
"perform_start.active_job" => %i[job],
|
|
19
|
+
"process_action.action_controller" => %i[controller action method path status],
|
|
20
|
+
"redirect_to.action_controller" => %i[status location],
|
|
21
|
+
"render_collection.action_view" => %i[identifier count],
|
|
22
|
+
"render_partial.action_view" => %i[identifier],
|
|
23
|
+
"render_template.action_view" => %i[identifier],
|
|
24
|
+
"sql.active_record" => %i[name],
|
|
25
|
+
"start_processing.action_controller" => %i[action controller format method path]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.subscribe!
|
|
29
|
+
SUBSCRIPTIONS.each do |event_name, allowed_keys|
|
|
30
|
+
message = event_name.split(".").first
|
|
31
|
+
|
|
32
|
+
ActiveSupport::Notifications.subscribe(event_name) do |event|
|
|
33
|
+
next unless RailsInformant.initialized?
|
|
34
|
+
next if event_name == "sql.active_record" && (event.payload[:cached] || event.payload[:name] == "SCHEMA")
|
|
35
|
+
|
|
36
|
+
filtered = event.payload.slice(*allowed_keys)
|
|
37
|
+
filtered = ContextFilter.filter(filtered) if NEEDS_FILTERING.include?(event_name)
|
|
38
|
+
if event_name == "redirect_to.action_controller" && filtered[:location]
|
|
39
|
+
filtered[:location] = ContextBuilder.filtered_url filtered[:location]
|
|
40
|
+
end
|
|
41
|
+
BreadcrumbBuffer.current.record(
|
|
42
|
+
category: event.name,
|
|
43
|
+
message:,
|
|
44
|
+
metadata: filtered,
|
|
45
|
+
duration: event.duration.round(1)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :api_token,
|
|
4
|
+
:capture_errors,
|
|
5
|
+
:capture_user_email,
|
|
6
|
+
:devin_api_key,
|
|
7
|
+
:devin_playbook_id,
|
|
8
|
+
:ignored_exceptions,
|
|
9
|
+
:retention_days,
|
|
10
|
+
:slack_webhook_url,
|
|
11
|
+
:webhook_url
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@api_token = ENV["INFORMANT_API_TOKEN"]
|
|
15
|
+
@capture_errors = ENV.fetch("INFORMANT_CAPTURE_ERRORS", "true") != "false"
|
|
16
|
+
@capture_user_email = false
|
|
17
|
+
@custom_notifiers = []
|
|
18
|
+
@devin_api_key = ENV["INFORMANT_DEVIN_API_KEY"]
|
|
19
|
+
@devin_playbook_id = ENV["INFORMANT_DEVIN_PLAYBOOK_ID"]
|
|
20
|
+
@ignored_exceptions = ENV["INFORMANT_IGNORED_EXCEPTIONS"]&.split(",")&.map(&:strip) || []
|
|
21
|
+
@retention_days = ENV["INFORMANT_RETENTION_DAYS"]&.to_i
|
|
22
|
+
@slack_webhook_url = ENV["INFORMANT_SLACK_WEBHOOK_URL"]
|
|
23
|
+
@webhook_url = ENV["INFORMANT_WEBHOOK_URL"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns all notifiers: built-in (auto-registered from config) + custom.
|
|
27
|
+
def notifiers
|
|
28
|
+
@_notifiers ||= built_in_notifiers + @custom_notifiers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Register a custom notifier. Must respond to #notify and #should_notify?.
|
|
32
|
+
def add_notifier(notifier)
|
|
33
|
+
@custom_notifiers << notifier
|
|
34
|
+
@_notifiers = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset_notifiers!
|
|
38
|
+
@_notifiers = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def built_in_notifiers
|
|
44
|
+
[
|
|
45
|
+
(Notifiers::Devin.new if devin_api_key.present? && devin_playbook_id.present?),
|
|
46
|
+
(Notifiers::Slack.new if slack_webhook_url.present?),
|
|
47
|
+
(Notifiers::Webhook.new if webhook_url.present?)
|
|
48
|
+
].compact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class ContextBuilder
|
|
3
|
+
MAX_CAUSE_DEPTH = 5
|
|
4
|
+
SKIP_HEADERS = Set.new(%w[
|
|
5
|
+
HTTP_AUTHORIZATION
|
|
6
|
+
HTTP_COOKIE
|
|
7
|
+
HTTP_PROXY_AUTHORIZATION
|
|
8
|
+
HTTP_X_API_KEY
|
|
9
|
+
HTTP_X_AUTH_TOKEN
|
|
10
|
+
HTTP_X_CSRF_TOKEN
|
|
11
|
+
]).freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def build_exception_chain(error)
|
|
15
|
+
chain = []
|
|
16
|
+
current = error.cause
|
|
17
|
+
depth = 0
|
|
18
|
+
|
|
19
|
+
while current && depth < MAX_CAUSE_DEPTH
|
|
20
|
+
chain << {
|
|
21
|
+
class: current.class.name,
|
|
22
|
+
message: ContextFilter.filter_message(current.message),
|
|
23
|
+
backtrace: ContextFilter.filter_backtrace(current.backtrace)
|
|
24
|
+
}
|
|
25
|
+
current = current.cause
|
|
26
|
+
depth += 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
chain.presence
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_request_context(env)
|
|
33
|
+
return unless env
|
|
34
|
+
|
|
35
|
+
request = ActionDispatch::Request.new(env)
|
|
36
|
+
ctx = {
|
|
37
|
+
url: filtered_url(request.original_url),
|
|
38
|
+
method: request.request_method,
|
|
39
|
+
params: request.filtered_parameters,
|
|
40
|
+
headers: extract_headers(request),
|
|
41
|
+
ip: request.remote_ip
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ContextFilter.filter(ctx)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_user_context(env)
|
|
48
|
+
ctx = RailsInformant::Current.user_context || detect_current_user(env)
|
|
49
|
+
ContextFilter.filter(ctx)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_environment_context
|
|
53
|
+
@_static_env ||= {
|
|
54
|
+
rails_env: Rails.env.to_s,
|
|
55
|
+
ruby_version: RUBY_VERSION,
|
|
56
|
+
rails_version: Rails::VERSION::STRING,
|
|
57
|
+
hostname: Socket.gethostname
|
|
58
|
+
}.freeze
|
|
59
|
+
@_static_env.merge(pid: Process.pid)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def group_attributes(error, severity:, context:, env:, now:)
|
|
63
|
+
{
|
|
64
|
+
error_class: error.class.name, severity:,
|
|
65
|
+
message: ContextFilter.filter_message(error.message),
|
|
66
|
+
first_backtrace_line: Fingerprint.first_app_frame(error),
|
|
67
|
+
controller_action: extract_controller_action(env, context),
|
|
68
|
+
job_class: extract_job_class(context),
|
|
69
|
+
first_seen_at: now, last_seen_at: now,
|
|
70
|
+
total_occurrences: 1, created_at: now, updated_at: now
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def occurrence_attributes(error, env:, context:)
|
|
75
|
+
{
|
|
76
|
+
backtrace: ContextFilter.filter_backtrace(error.backtrace),
|
|
77
|
+
exception_chain: build_exception_chain(error),
|
|
78
|
+
request_context: build_request_context(env),
|
|
79
|
+
user_context: build_user_context(env),
|
|
80
|
+
custom_context: ContextFilter.filter(RailsInformant::Current.custom_context),
|
|
81
|
+
environment_context: build_environment_context,
|
|
82
|
+
breadcrumbs: BreadcrumbBuffer.current.flush,
|
|
83
|
+
git_sha: RailsInformant.current_git_sha
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extract_controller_action(env, context)
|
|
88
|
+
if env
|
|
89
|
+
params = env["action_dispatch.request.parameters"]
|
|
90
|
+
"#{params["controller"]}##{params["action"]}" if params&.key?("controller") && params&.key?("action")
|
|
91
|
+
elsif context[:controller] && context[:action]
|
|
92
|
+
"#{context[:controller]}##{context[:action]}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_job_class(context)
|
|
97
|
+
context.dig(:job, :class) || context[:job_class]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def filtered_url(url)
|
|
101
|
+
uri = URI.parse(url)
|
|
102
|
+
if uri.query.present?
|
|
103
|
+
params = Rack::Utils.parse_query(uri.query)
|
|
104
|
+
filtered = ContextFilter.filter(params)
|
|
105
|
+
uri.query = Rack::Utils.build_query(filtered)
|
|
106
|
+
end
|
|
107
|
+
uri.to_s
|
|
108
|
+
rescue URI::InvalidURIError
|
|
109
|
+
url.split("?").first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def detect_current_user(env)
|
|
115
|
+
if defined?(::Current) && ::Current.respond_to?(:user) && ::Current.user
|
|
116
|
+
user_context ::Current.user
|
|
117
|
+
elsif env && env["warden"]&.user
|
|
118
|
+
user_context env["warden"].user
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def extract_headers(request)
|
|
123
|
+
headers = {}
|
|
124
|
+
request.headers.each do |key, value|
|
|
125
|
+
next unless key.start_with?("HTTP_")
|
|
126
|
+
next if SKIP_HEADERS.include?(key)
|
|
127
|
+
header_name = key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
|
|
128
|
+
headers[header_name] = value
|
|
129
|
+
end
|
|
130
|
+
headers
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def user_context(user)
|
|
134
|
+
return nil unless user
|
|
135
|
+
|
|
136
|
+
ctx = { id: user.id, class: user.class.name }
|
|
137
|
+
ctx[:email] = user.email if RailsInformant.capture_user_email && user.respond_to?(:email)
|
|
138
|
+
ctx
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class ContextFilter
|
|
3
|
+
MAX_BACKTRACE_FRAMES = 200
|
|
4
|
+
MAX_MESSAGE_LENGTH = 2000
|
|
5
|
+
MAX_CONTEXT_SIZE = 64 * 1024 # 64 KB
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def reset!
|
|
9
|
+
@_parameter_filter = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def filter(context)
|
|
13
|
+
return nil unless context
|
|
14
|
+
|
|
15
|
+
filtered = parameter_filter.filter(context)
|
|
16
|
+
truncate_to_size(filtered)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def filter_backtrace(backtrace)
|
|
20
|
+
return nil unless backtrace
|
|
21
|
+
backtrace.first(MAX_BACKTRACE_FRAMES)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def filter_message(message)
|
|
25
|
+
return nil unless message
|
|
26
|
+
message.to_s.truncate(MAX_MESSAGE_LENGTH)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def parameter_filter
|
|
32
|
+
@_parameter_filter ||= ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def truncate_to_size(data)
|
|
36
|
+
return data if data.is_a?(Hash) && data.size < 20
|
|
37
|
+
|
|
38
|
+
json = data.to_json
|
|
39
|
+
return data if json.bytesize <= MAX_CONTEXT_SIZE
|
|
40
|
+
|
|
41
|
+
{ _truncated: true, _original_size: json.bytesize }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace RailsInformant
|
|
4
|
+
|
|
5
|
+
initializer "rails_informant.error_subscriber" do
|
|
6
|
+
next unless RailsInformant.capture_errors
|
|
7
|
+
|
|
8
|
+
Rails.error.subscribe RailsInformant::ErrorSubscriber.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "rails_informant.context_middleware" do
|
|
12
|
+
next unless RailsInformant.capture_errors
|
|
13
|
+
|
|
14
|
+
Rails.error.add_middleware ->(error, context) {
|
|
15
|
+
context.merge deploy_sha: RailsInformant.current_git_sha
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "rails_informant.middleware" do |app|
|
|
20
|
+
next unless RailsInformant.capture_errors
|
|
21
|
+
|
|
22
|
+
app.middleware.insert_before ActionDispatch::ShowExceptions,
|
|
23
|
+
RailsInformant::Middleware::ErrorCapture
|
|
24
|
+
app.middleware.insert_after ActionDispatch::DebugExceptions,
|
|
25
|
+
RailsInformant::Middleware::RescuedExceptionInterceptor
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
initializer "rails_informant.breadcrumbs" do
|
|
29
|
+
next unless RailsInformant.capture_errors
|
|
30
|
+
|
|
31
|
+
RailsInformant::BreadcrumbSubscriber.subscribe!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
initializer "rails_informant.structured_events" do
|
|
35
|
+
next unless RailsInformant.capture_errors
|
|
36
|
+
|
|
37
|
+
Rails.event.subscribe(
|
|
38
|
+
RailsInformant::StructuredEventSubscriber.new
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
initializer "rails_informant.detect_deploy" do
|
|
43
|
+
config.after_initialize do
|
|
44
|
+
next unless RailsInformant.server_mode?
|
|
45
|
+
|
|
46
|
+
current_sha = RailsInformant.current_git_sha
|
|
47
|
+
next unless current_sha
|
|
48
|
+
|
|
49
|
+
now = Time.current
|
|
50
|
+
RailsInformant::ErrorGroup
|
|
51
|
+
.where(status: "fix_pending")
|
|
52
|
+
.where.not(original_sha: current_sha)
|
|
53
|
+
.in_batches(of: 100)
|
|
54
|
+
.update_all(status: "resolved", resolved_at: now, fix_deployed_at: now, updated_at: now)
|
|
55
|
+
rescue ActiveRecord::StatementInvalid
|
|
56
|
+
# Table may not exist yet during initial migration
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
initializer "rails_informant.validate_api_token" do
|
|
61
|
+
config.after_initialize { RailsInformant::Engine.validate_api_token! }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
MINIMUM_TOKEN_LENGTH = 32
|
|
65
|
+
|
|
66
|
+
def self.validate_api_token!
|
|
67
|
+
return unless RailsInformant.capture_errors
|
|
68
|
+
|
|
69
|
+
token = RailsInformant.api_token
|
|
70
|
+
|
|
71
|
+
if token.nil?
|
|
72
|
+
raise <<~MSG.squish
|
|
73
|
+
RailsInformant: api_token must be configured when capture_errors is enabled.
|
|
74
|
+
Set it in your initializer: config.api_token = "your-secret-token"
|
|
75
|
+
MSG
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if token.length < MINIMUM_TOKEN_LENGTH
|
|
79
|
+
raise <<~MSG.squish
|
|
80
|
+
RailsInformant: api_token must be at least #{MINIMUM_TOKEN_LENGTH} characters.
|
|
81
|
+
Use SecureRandom.hex(32) or Rails credentials to generate a secure token.
|
|
82
|
+
MSG
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class ErrorRecorder
|
|
3
|
+
MAX_OCCURRENCES_PER_GROUP = 25
|
|
4
|
+
OCCURRENCE_COOLDOWN = 5 # seconds
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def record(error, severity: "error", context: {}, source: nil, env: nil)
|
|
8
|
+
return unless RailsInformant.initialized?
|
|
9
|
+
now = Time.current
|
|
10
|
+
attrs = ContextBuilder.group_attributes(error, severity:, context:, env:, now:)
|
|
11
|
+
group = ErrorGroup.find_or_create_for(Fingerprint.generate(error), attrs)
|
|
12
|
+
group.detect_regression!
|
|
13
|
+
store_occurrence(group, error, env:, context:) if should_store_occurrence?(group)
|
|
14
|
+
notify(group)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
Rails.logger.error "[RailsInformant] Capture failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def should_store_occurrence?(group)
|
|
22
|
+
return true if group.total_occurrences <= 1
|
|
23
|
+
return true unless group.last_occurrence_stored_at
|
|
24
|
+
group.last_occurrence_stored_at.before?(OCCURRENCE_COOLDOWN.seconds.ago)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def store_occurrence(group, error, env:, context:)
|
|
28
|
+
now = Time.current
|
|
29
|
+
ErrorGroup.transaction do
|
|
30
|
+
group.occurrences.create! ContextBuilder.occurrence_attributes(error, env:, context:)
|
|
31
|
+
ErrorGroup.where(id: group.id).update_all last_occurrence_stored_at: now, updated_at: now
|
|
32
|
+
group.last_occurrence_stored_at = now
|
|
33
|
+
end
|
|
34
|
+
trim_occurrences(group) if group.total_occurrences > MAX_OCCURRENCES_PER_GROUP
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def trim_occurrences(group)
|
|
38
|
+
keep_ids = group.occurrences.order(created_at: :desc).limit(MAX_OCCURRENCES_PER_GROUP).select(:id)
|
|
39
|
+
Occurrence.where(error_group_id: group.id).where.not(id: keep_ids).delete_all
|
|
40
|
+
end
|
|
41
|
+
def notify(group)
|
|
42
|
+
return unless RailsInformant.config.notifiers.any? { it.should_notify?(group) }
|
|
43
|
+
RailsInformant::NotifyJob.perform_later group
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
class ErrorSubscriber
|
|
3
|
+
SKIP_SOURCES = /_cache_store\.active_support\z/
|
|
4
|
+
|
|
5
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
6
|
+
return unless RailsInformant.initialized?
|
|
7
|
+
return if handled
|
|
8
|
+
return if source && SKIP_SOURCES.match?(source)
|
|
9
|
+
return if RailsInformant.ignored_exception?(error)
|
|
10
|
+
return if RailsInformant.already_captured?(error)
|
|
11
|
+
|
|
12
|
+
RailsInformant.mark_captured!(error)
|
|
13
|
+
|
|
14
|
+
ErrorRecorder.record error, severity:, context:, source:
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module RailsInformant
|
|
4
|
+
class Fingerprint
|
|
5
|
+
APP_FRAME_PATTERN = /\A(?!.*\/(gems|ruby|rubies)\/)/.freeze
|
|
6
|
+
|
|
7
|
+
def self.generate(exception)
|
|
8
|
+
return Digest::SHA256.hexdigest(exception.class.name) unless exception.backtrace
|
|
9
|
+
|
|
10
|
+
first_frame = first_app_frame(exception)
|
|
11
|
+
normalized = normalize_frame(first_frame)
|
|
12
|
+
Digest::SHA256.hexdigest "#{exception.class.name}:#{normalized}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.first_app_frame(exception)
|
|
16
|
+
exception.backtrace.find { APP_FRAME_PATTERN.match?(it) } || exception.backtrace.first
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.normalize_frame(frame)
|
|
20
|
+
frame.sub(/:(\d+)(?=:in |$)/, ":0")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module RailsInformant
|
|
2
|
+
module Mcp
|
|
3
|
+
class BaseTool < ::MCP::Tool
|
|
4
|
+
class << self
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def client_for(server_context:, environment: nil)
|
|
8
|
+
config = server_context[:config]
|
|
9
|
+
env = environment || config.default_environment
|
|
10
|
+
config.client_for(env)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def with_client(server_context:, environment: nil)
|
|
14
|
+
client = client_for(server_context:, environment:)
|
|
15
|
+
yield client
|
|
16
|
+
rescue Client::Error => e
|
|
17
|
+
error_response(e.message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def text_response(data)
|
|
21
|
+
text = data.is_a?(String) ? data : JSON.generate(data)
|
|
22
|
+
::MCP::Tool::Response.new([ { type: "text", text: } ])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def error_response(message)
|
|
26
|
+
::MCP::Tool::Response.new([ { type: "text", text: message } ], error: true)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def paginated_text_response(result)
|
|
30
|
+
response_text = JSON.generate(result["data"])
|
|
31
|
+
meta = result["meta"]
|
|
32
|
+
response_text += "\n\nPage #{meta["page"]}, per_page: #{meta["per_page"]}, has_more: #{meta["has_more"]}" if meta
|
|
33
|
+
text_response(response_text)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RailsInformant
|
|
6
|
+
module Mcp
|
|
7
|
+
class Client
|
|
8
|
+
Error = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
METHODS = {
|
|
11
|
+
delete: Net::HTTP::Delete,
|
|
12
|
+
get: Net::HTTP::Get,
|
|
13
|
+
patch: Net::HTTP::Patch,
|
|
14
|
+
post: Net::HTTP::Post
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(url:, token:, allow_insecure: false, path_prefix: "/informant")
|
|
18
|
+
@base_url = url.chomp("/")
|
|
19
|
+
@token = token
|
|
20
|
+
@allow_insecure = allow_insecure
|
|
21
|
+
@path_prefix = path_prefix.chomp("/")
|
|
22
|
+
@_base_uri = URI.parse(@base_url)
|
|
23
|
+
validate_url!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list_errors(status: nil, error_class: nil, q: nil, since: nil, page: nil, per_page: nil, controller_action: nil, job_class: nil, severity: nil, **extra)
|
|
27
|
+
params = { controller_action:, error_class:, job_class:, q:, severity:, since:, status:, page:, per_page: }
|
|
28
|
+
params[:until] = extra[:until] if extra[:until]
|
|
29
|
+
perform :get, "#{@path_prefix}/api/v1/errors", params: params.compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get_error(id)
|
|
33
|
+
perform :get, "#{@path_prefix}/api/v1/errors/#{Integer(id)}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update_error(id, params)
|
|
37
|
+
perform :patch, "#{@path_prefix}/api/v1/errors/#{Integer(id)}", body: params
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete_error(id)
|
|
41
|
+
perform :delete, "#{@path_prefix}/api/v1/errors/#{Integer(id)}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fix_pending(id, fix_sha:, original_sha:, fix_pr_url: nil)
|
|
45
|
+
perform :patch, "#{@path_prefix}/api/v1/errors/#{Integer(id)}/fix_pending", body: { fix_sha:, original_sha:, fix_pr_url: }.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def mark_duplicate(id, duplicate_of_id:)
|
|
49
|
+
perform :patch, "#{@path_prefix}/api/v1/errors/#{Integer(id)}/duplicate", body: { duplicate_of_id: }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def list_occurrences(error_group_id: nil, since: nil, page: nil, per_page: nil, **extra)
|
|
53
|
+
params = { error_group_id:, since:, page:, per_page: }
|
|
54
|
+
params[:until] = extra[:until] if extra[:until]
|
|
55
|
+
perform :get, "#{@path_prefix}/api/v1/occurrences", params: params.compact
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def status
|
|
59
|
+
perform :get, "#{@path_prefix}/api/v1/status"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def validate_url!
|
|
65
|
+
return if @allow_insecure
|
|
66
|
+
raise Error, "HTTPS required. Use --allow-insecure for local development." unless @_base_uri.scheme == "https"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def perform(method, path, params: {}, body: nil)
|
|
70
|
+
uri = build_uri(path, params)
|
|
71
|
+
klass = METHODS.fetch(method) { raise ArgumentError, "Unsupported HTTP method: #{method}" }
|
|
72
|
+
req = klass.new(uri)
|
|
73
|
+
if body
|
|
74
|
+
req.body = JSON.generate(body)
|
|
75
|
+
req["Content-Type"] = "application/json"
|
|
76
|
+
end
|
|
77
|
+
execute(req)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_uri(path, params = {})
|
|
81
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
82
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
83
|
+
uri
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def connection
|
|
87
|
+
@_connection ||= begin
|
|
88
|
+
http = Net::HTTP.new(@_base_uri.host, @_base_uri.port)
|
|
89
|
+
http.use_ssl = @_base_uri.scheme == "https"
|
|
90
|
+
http.open_timeout = 5
|
|
91
|
+
http.read_timeout = 10
|
|
92
|
+
http.start
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def execute(req)
|
|
97
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
98
|
+
|
|
99
|
+
response = connection.request(req)
|
|
100
|
+
|
|
101
|
+
case response
|
|
102
|
+
when Net::HTTPSuccess
|
|
103
|
+
response.body&.empty? ? nil : JSON.parse(response.body)
|
|
104
|
+
when Net::HTTPUnauthorized
|
|
105
|
+
raise Error, "Authentication failed. Check your API token."
|
|
106
|
+
when Net::HTTPNotFound
|
|
107
|
+
raise Error, "Not found (404)"
|
|
108
|
+
else
|
|
109
|
+
body = JSON.parse(response.body)
|
|
110
|
+
raise Error, body["error"] || "HTTP #{response.code}"
|
|
111
|
+
end
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
raise Error, "HTTP #{response.code}: #{response.body.to_s[0, 200]}"
|
|
114
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError => e
|
|
115
|
+
@_connection = nil
|
|
116
|
+
raise Error, "Connection failed: #{e.message}"
|
|
117
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
118
|
+
@_connection = nil
|
|
119
|
+
raise Error, "Request timed out: #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|