Skip to content

Commit 0b6cb50

Browse files
committed
WIP - add ActionControllerSubscriber
1 parent 7cf0fbe commit 0b6cb50

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# frozen_string_literal: true
2+
3+
require "sentry/rails/log_subscriber"
4+
5+
module Sentry
6+
module Rails
7+
module LogSubscribers
8+
# LogSubscriber for ActionController events that captures HTTP request processing
9+
# and logs them using Sentry's structured logging system.
10+
#
11+
# This subscriber captures process_action.action_controller events and formats them
12+
# with relevant request information including controller, action, HTTP status,
13+
# request parameters, and performance metrics.
14+
#
15+
# @example Usage
16+
# # Enable structured logging for ActionController
17+
# Sentry.init do |config|
18+
# config.enable_logs = true
19+
# config.rails.structured_logging = true
20+
# config.rails.structured_logging.attach_to = [:action_controller]
21+
# end
22+
class ActionControllerSubscriber < Sentry::Rails::LogSubscriber
23+
# Handle process_action.action_controller events
24+
#
25+
# @param event [ActiveSupport::Notifications::Event] The controller action event
26+
def process_action(event)
27+
return if excluded_event?(event)
28+
29+
payload = event.payload
30+
duration = event.time.round(2)
31+
32+
controller = payload[:controller]
33+
action = payload[:action]
34+
35+
status = extract_status(payload)
36+
37+
attributes = {
38+
controller: controller,
39+
action: action,
40+
status: status,
41+
duration_ms: duration,
42+
method: payload[:method],
43+
path: payload[:path],
44+
format: payload[:format]
45+
}
46+
47+
if payload[:view_runtime]
48+
attributes[:view_runtime_ms] = payload[:view_runtime].round(2)
49+
end
50+
51+
if payload[:db_runtime]
52+
attributes[:db_runtime_ms] = payload[:db_runtime].round(2)
53+
end
54+
55+
if Sentry.configuration.send_default_pii && payload[:params]
56+
filtered_params = filter_sensitive_params(payload[:params])
57+
attributes[:params] = filtered_params unless filtered_params.empty?
58+
end
59+
60+
level = level_for_request(payload)
61+
message = "#{controller}##{action}"
62+
63+
log_structured_event(
64+
message: message,
65+
level: level,
66+
attributes: attributes
67+
)
68+
end
69+
70+
private
71+
72+
def extract_status(payload)
73+
if payload[:status]
74+
payload[:status]
75+
elsif payload[:exception]
76+
case payload[:exception].first
77+
when "ActionController::RoutingError"
78+
404
79+
when "ActionController::BadRequest"
80+
400
81+
else
82+
500
83+
end
84+
end
85+
end
86+
87+
def level_for_request(payload)
88+
status = payload[:status]
89+
90+
# In Rails < 6.0 status is not set when an action raised an exception
91+
if status.nil? && payload[:exception]
92+
case payload[:exception].first
93+
when "ActionController::RoutingError"
94+
:warn
95+
when "ActionController::BadRequest"
96+
:warn
97+
else
98+
:error
99+
end
100+
elsif status >= 200 && status < 400
101+
:info
102+
elsif status >= 400 && status < 500
103+
:warn
104+
elsif status >= 500
105+
:error
106+
else
107+
:info
108+
end
109+
end
110+
111+
def filter_sensitive_params(params)
112+
return {} unless params.is_a?(Hash)
113+
114+
sensitive_keys = %w[
115+
password password_confirmation
116+
secret token api_key
117+
credit_card ssn social_security_number
118+
authorization auth
119+
]
120+
121+
params.reject do |key, _value|
122+
key_str = key.to_s.downcase
123+
sensitive_keys.any? { |sensitive| key_str.include?(sensitive) }
124+
end
125+
end
126+
end
127+
end
128+
end
129+
end
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "sentry/rails/log_subscribers/action_controller_subscriber"
5+
6+
RSpec.describe Sentry::Rails::LogSubscribers::ActionControllerSubscriber, type: :request do
7+
before do
8+
make_basic_app do |config|
9+
config.enable_logs = true
10+
config.rails.structured_logging.enabled = true
11+
config.rails.structured_logging.attach_to = [:action_controller]
12+
end
13+
end
14+
15+
describe "integration with ActiveSupport::Notifications" do
16+
it "logs controller action events when requests are processed" do
17+
sentry_transport.events.clear
18+
sentry_transport.envelopes.clear
19+
20+
get "/world"
21+
22+
Sentry.get_current_client.log_event_buffer.flush
23+
24+
expect(sentry_logs).not_to be_empty
25+
26+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#world" }
27+
expect(log_event).not_to be_nil
28+
expect(log_event[:level]).to eq("info")
29+
expect(log_event[:attributes][:controller][:value]).to eq("HelloController")
30+
expect(log_event[:attributes][:action][:value]).to eq("world")
31+
expect(log_event[:attributes][:status][:value]).to eq(200)
32+
expect(log_event[:attributes][:duration_ms][:value]).to be > 0
33+
expect(log_event[:attributes][:method][:value]).to eq("GET")
34+
expect(log_event[:attributes][:path][:value]).to eq("/world")
35+
expect(log_event[:attributes][:format][:value]).to eq(:html)
36+
end
37+
38+
it "logs bad requests appropriately" do
39+
sentry_transport.events.clear
40+
sentry_transport.envelopes.clear
41+
42+
# FIXME: in our setup this actually raises BadRequests which is confusing
43+
get "/not_found"
44+
45+
Sentry.get_current_client.log_event_buffer.flush
46+
47+
expect(sentry_logs).not_to be_empty
48+
49+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#not_found" }
50+
expect(log_event).not_to be_nil
51+
expect(log_event[:level].to_sym).to be(:warn)
52+
expect(log_event[:attributes][:status][:value]).to eq(400)
53+
end
54+
55+
it "logs error status codes with error level" do
56+
sentry_transport.events.clear
57+
sentry_transport.envelopes.clear
58+
59+
get "/exception"
60+
61+
Sentry.get_current_client.log_event_buffer.flush
62+
63+
expect(sentry_logs).not_to be_empty
64+
65+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#exception" }
66+
expect(log_event).not_to be_nil
67+
expect(log_event[:level]).to eq("error")
68+
expect(log_event[:attributes][:status][:value]).to eq(500)
69+
end
70+
71+
it "includes view runtime when available" do
72+
sentry_transport.events.clear
73+
sentry_transport.envelopes.clear
74+
75+
get "/view"
76+
77+
Sentry.get_current_client.log_event_buffer.flush
78+
79+
expect(sentry_logs).not_to be_empty
80+
81+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#view" }
82+
expect(log_event).not_to be_nil
83+
expect(log_event[:attributes][:view_runtime_ms]).to be_present
84+
expect(log_event[:attributes][:view_runtime_ms][:value]).to be >= 0
85+
end
86+
87+
it "includes database runtime when available" do
88+
sentry_transport.events.clear
89+
sentry_transport.envelopes.clear
90+
91+
Post.create!
92+
get "/posts"
93+
94+
Sentry.get_current_client.log_event_buffer.flush
95+
96+
expect(sentry_logs).not_to be_empty
97+
98+
log_event = sentry_logs.find { |log| log[:body] == "PostsController#index" }
99+
expect(log_event).not_to be_nil
100+
101+
if Rails.version.to_f >= 6.0
102+
expect(log_event[:attributes][:db_runtime_ms]).to be_present
103+
expect(log_event[:attributes][:db_runtime_ms][:value]).to be >= 0
104+
else
105+
if log_event[:attributes][:db_runtime_ms]
106+
expect(log_event[:attributes][:db_runtime_ms][:value]).to be >= 0
107+
end
108+
end
109+
end
110+
111+
context "when send_default_pii is enabled" do
112+
before do
113+
Sentry.configuration.send_default_pii = true
114+
end
115+
116+
after do
117+
Sentry.configuration.send_default_pii = false
118+
end
119+
120+
it "includes filtered request parameters" do
121+
sentry_transport.events.clear
122+
sentry_transport.envelopes.clear
123+
124+
get "/world", params: { safe_param: "value", password: "secret" }
125+
126+
Sentry.get_current_client.log_event_buffer.flush
127+
128+
expect(sentry_logs).not_to be_empty
129+
130+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#world" }
131+
expect(log_event).not_to be_nil
132+
expect(log_event[:attributes][:params]).to be_present
133+
expect(log_event[:attributes][:params][:value]).to include("safe_param" => "value")
134+
expect(log_event[:attributes][:params][:value]).not_to include("password")
135+
end
136+
137+
it "filters sensitive parameter names" do
138+
sentry_transport.events.clear
139+
sentry_transport.envelopes.clear
140+
141+
get "/world", params: {
142+
normal_param: "value",
143+
password: "secret",
144+
api_key: "key123",
145+
credit_card: "1234567890",
146+
authorization: "Bearer token"
147+
}
148+
149+
Sentry.get_current_client.log_event_buffer.flush
150+
151+
expect(sentry_logs).not_to be_empty
152+
153+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#world" }
154+
expect(log_event).not_to be_nil
155+
156+
params = log_event[:attributes][:params][:value]
157+
expect(params).to include("normal_param" => "value")
158+
expect(params).not_to have_key("password")
159+
expect(params).not_to have_key("api_key")
160+
expect(params).not_to have_key("credit_card")
161+
expect(params).not_to have_key("authorization")
162+
end
163+
end
164+
165+
context "when send_default_pii is disabled" do
166+
it "does not include request parameters" do
167+
sentry_transport.events.clear
168+
sentry_transport.envelopes.clear
169+
170+
get "/world", params: { param: "value" }
171+
172+
Sentry.get_current_client.log_event_buffer.flush
173+
174+
expect(sentry_logs).not_to be_empty
175+
176+
log_event = sentry_logs.find { |log| log[:body] == "HelloController#world" }
177+
expect(log_event).not_to be_nil
178+
expect(log_event[:attributes]).not_to have_key(:params)
179+
end
180+
end
181+
end
182+
183+
describe "when logging is disabled" do
184+
before do
185+
make_basic_app do |config|
186+
config.enable_logs = false
187+
config.rails.structured_logging.enabled = true
188+
config.rails.structured_logging.attach_to = [:action_controller]
189+
end
190+
end
191+
192+
it "does not log events when logging is disabled" do
193+
initial_log_count = sentry_logs.count
194+
195+
get "/world"
196+
197+
if Sentry.get_current_client&.log_event_buffer
198+
Sentry.get_current_client.log_event_buffer.flush
199+
end
200+
201+
expect(sentry_logs.count).to eq(initial_log_count)
202+
end
203+
end
204+
end

0 commit comments

Comments
 (0)