userpattern 0.2.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +518 -0
- data/app/assets/stylesheets/user_pattern/dashboard.css +169 -0
- data/app/controllers/user_pattern/dashboard_controller.rb +58 -0
- data/app/models/user_pattern/request_event.rb +11 -0
- data/app/models/user_pattern/violation.rb +9 -0
- data/app/views/user_pattern/dashboard/index.html.erb +116 -0
- data/app/views/user_pattern/dashboard/violations.html.erb +79 -0
- data/config/routes.rb +7 -0
- data/lib/generators/userpattern/install_generator.rb +46 -0
- data/lib/generators/userpattern/templates/create_userpattern_request_events.rb.erb +23 -0
- data/lib/generators/userpattern/templates/create_userpattern_violations.rb.erb +22 -0
- data/lib/generators/userpattern/templates/initializer.rb +83 -0
- data/lib/tasks/userpattern.rake +9 -0
- data/lib/userpattern/anonymizer.rb +53 -0
- data/lib/userpattern/buffer.rb +70 -0
- data/lib/userpattern/configuration.rb +93 -0
- data/lib/userpattern/controller_tracking.rb +102 -0
- data/lib/userpattern/engine.rb +40 -0
- data/lib/userpattern/path_normalizer.rb +60 -0
- data/lib/userpattern/rate_limiter.rb +68 -0
- data/lib/userpattern/request_event_cleanup.rb +10 -0
- data/lib/userpattern/stats_calculator.rb +102 -0
- data/lib/userpattern/threshold_cache.rb +73 -0
- data/lib/userpattern/threshold_exceeded.rb +25 -0
- data/lib/userpattern/version.rb +5 -0
- data/lib/userpattern/violation_recorder.rb +28 -0
- data/lib/userpattern.rb +66 -0
- metadata +97 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
body {
|
|
4
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
5
|
+
background: #f5f7fa;
|
|
6
|
+
color: #1a1a2e;
|
|
7
|
+
line-height: 1.5;
|
|
8
|
+
padding: 2rem;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
12
|
+
|
|
13
|
+
header {
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: baseline;
|
|
16
|
+
gap: 1rem;
|
|
17
|
+
margin-bottom: 2rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
header h1 { font-size: 1.6rem; font-weight: 700; }
|
|
21
|
+
header .version { color: #888; font-size: 0.85rem; }
|
|
22
|
+
|
|
23
|
+
.mode-badge {
|
|
24
|
+
font-size: 0.75rem;
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
padding: 0.2rem 0.6rem;
|
|
27
|
+
border-radius: 4px;
|
|
28
|
+
text-transform: uppercase;
|
|
29
|
+
letter-spacing: 0.03em;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.mode-badge.collection { background: #dbeafe; color: #1d4ed8; }
|
|
33
|
+
.mode-badge.alert { background: #fee2e2; color: #dc2626; }
|
|
34
|
+
|
|
35
|
+
.page-tabs {
|
|
36
|
+
display: flex;
|
|
37
|
+
gap: 0.5rem;
|
|
38
|
+
margin-bottom: 1.5rem;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.page-tabs a {
|
|
42
|
+
display: inline-block;
|
|
43
|
+
padding: 0.5rem 1.25rem;
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
color: #555;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
background: #e2e8f0;
|
|
48
|
+
border-radius: 6px 6px 0 0;
|
|
49
|
+
transition: color 0.15s, background 0.15s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.page-tabs a:hover { background: #cbd5e1; color: #1a1a2e; }
|
|
53
|
+
.page-tabs a.active { background: #fff; color: #2563eb; }
|
|
54
|
+
|
|
55
|
+
.tabs {
|
|
56
|
+
display: flex;
|
|
57
|
+
gap: 0.25rem;
|
|
58
|
+
margin-bottom: 1.5rem;
|
|
59
|
+
border-bottom: 2px solid #e0e0e0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.tabs a {
|
|
63
|
+
display: inline-block;
|
|
64
|
+
padding: 0.5rem 1.25rem;
|
|
65
|
+
text-decoration: none;
|
|
66
|
+
color: #555;
|
|
67
|
+
font-weight: 500;
|
|
68
|
+
border-bottom: 2px solid transparent;
|
|
69
|
+
margin-bottom: -2px;
|
|
70
|
+
transition: color 0.15s, border-color 0.15s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.tabs a:hover { color: #1a1a2e; }
|
|
74
|
+
.tabs a.active { color: #2563eb; border-bottom-color: #2563eb; }
|
|
75
|
+
|
|
76
|
+
.filter-bar {
|
|
77
|
+
display: flex;
|
|
78
|
+
gap: 0.5rem;
|
|
79
|
+
margin-bottom: 1.5rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.filter-bar a {
|
|
83
|
+
display: inline-block;
|
|
84
|
+
padding: 0.35rem 0.9rem;
|
|
85
|
+
text-decoration: none;
|
|
86
|
+
color: #555;
|
|
87
|
+
font-size: 0.85rem;
|
|
88
|
+
font-weight: 500;
|
|
89
|
+
background: #e2e8f0;
|
|
90
|
+
border-radius: 4px;
|
|
91
|
+
transition: color 0.15s, background 0.15s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.filter-bar a:hover { background: #cbd5e1; }
|
|
95
|
+
.filter-bar a.active { background: #2563eb; color: #fff; }
|
|
96
|
+
|
|
97
|
+
.card {
|
|
98
|
+
background: #fff;
|
|
99
|
+
border-radius: 8px;
|
|
100
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
101
|
+
overflow-x: auto;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
table {
|
|
105
|
+
width: 100%;
|
|
106
|
+
border-collapse: collapse;
|
|
107
|
+
font-size: 0.9rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
thead { background: #f8fafc; }
|
|
111
|
+
|
|
112
|
+
th, td {
|
|
113
|
+
padding: 0.75rem 1rem;
|
|
114
|
+
text-align: left;
|
|
115
|
+
border-bottom: 1px solid #eee;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
th {
|
|
120
|
+
font-weight: 600;
|
|
121
|
+
color: #475569;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
user-select: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
th:hover { color: #2563eb; }
|
|
127
|
+
|
|
128
|
+
th .arrow {
|
|
129
|
+
display: inline-block;
|
|
130
|
+
margin-left: 4px;
|
|
131
|
+
font-size: 0.7rem;
|
|
132
|
+
color: #94a3b8;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
th.sorted-asc .arrow::after { content: "\25B2"; color: #2563eb; }
|
|
136
|
+
th.sorted-desc .arrow::after { content: "\25BC"; color: #2563eb; }
|
|
137
|
+
th:not(.sorted-asc):not(.sorted-desc) .arrow::after { content: "\25B4\25BE"; }
|
|
138
|
+
|
|
139
|
+
td.endpoint { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; font-size: 0.85rem; }
|
|
140
|
+
td.mono { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; font-size: 0.85rem; color: #64748b; }
|
|
141
|
+
|
|
142
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
143
|
+
th.num { text-align: right; }
|
|
144
|
+
|
|
145
|
+
td.threshold { color: #9333ea; font-weight: 500; }
|
|
146
|
+
td.violation-exceeded { color: #dc2626; font-weight: 600; }
|
|
147
|
+
|
|
148
|
+
tbody tr:hover { background: #f1f5f9; }
|
|
149
|
+
tr.violation-row:hover { background: #fef2f2; }
|
|
150
|
+
|
|
151
|
+
.empty-state {
|
|
152
|
+
text-align: center;
|
|
153
|
+
padding: 4rem 2rem;
|
|
154
|
+
color: #94a3b8;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.empty-state code {
|
|
158
|
+
background: #f1f5f9;
|
|
159
|
+
padding: 0.15rem 0.4rem;
|
|
160
|
+
border-radius: 3px;
|
|
161
|
+
font-size: 0.85rem;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.footer {
|
|
165
|
+
margin-top: 1.5rem;
|
|
166
|
+
font-size: 0.8rem;
|
|
167
|
+
color: #94a3b8;
|
|
168
|
+
text-align: center;
|
|
169
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'userpattern/stats_calculator'
|
|
4
|
+
|
|
5
|
+
module UserPattern
|
|
6
|
+
class DashboardController < ActionController::Base
|
|
7
|
+
before_action :authenticate_dashboard!
|
|
8
|
+
layout false
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
UserPattern.buffer.flush
|
|
12
|
+
load_stats
|
|
13
|
+
apply_sort!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def violations
|
|
17
|
+
@violations = Violation
|
|
18
|
+
.recent(params[:days]&.to_i || 7)
|
|
19
|
+
.order(occurred_at: :desc)
|
|
20
|
+
@violations = @violations.where(model_type: params[:model_type]) if params[:model_type].present?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stylesheet
|
|
24
|
+
css_path = UserPattern::Engine.root.join('app', 'assets', 'stylesheets', 'user_pattern', 'dashboard.css')
|
|
25
|
+
expires_in 1.hour, public: true
|
|
26
|
+
render plain: css_path.read, content_type: 'text/css'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def load_stats
|
|
32
|
+
@stats = UserPattern::StatsCalculator.compute_all
|
|
33
|
+
@model_types = @stats.map { |s| s[:model_type] }.uniq.sort
|
|
34
|
+
@selected_model = params[:model_type].presence || @model_types.first
|
|
35
|
+
@filtered_stats = @stats.select { |s| s[:model_type] == @selected_model }
|
|
36
|
+
@alert_mode = UserPattern.configuration.alert_mode?
|
|
37
|
+
@threshold_limits = load_threshold_limits
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_threshold_limits
|
|
41
|
+
return {} unless @alert_mode && UserPattern.threshold_cache
|
|
42
|
+
|
|
43
|
+
UserPattern.threshold_cache.all_limits
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def authenticate_dashboard!
|
|
47
|
+
instance_exec(&UserPattern.configuration.dashboard_auth)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply_sort!
|
|
51
|
+
sort_key = params[:sort]&.to_sym
|
|
52
|
+
return unless sort_key && @filtered_stats.first&.key?(sort_key)
|
|
53
|
+
|
|
54
|
+
@filtered_stats.sort_by! { |s| s[sort_key] || 0 }
|
|
55
|
+
@filtered_stats.reverse! unless params[:dir] == 'asc'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UserPattern
|
|
4
|
+
class RequestEvent < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'userpattern_request_events'
|
|
6
|
+
|
|
7
|
+
scope :expired, lambda {
|
|
8
|
+
where('recorded_at < ?', UserPattern.configuration.retention_period.days.ago)
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>UserPattern — Usage Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="<%= user_pattern.stylesheet_path %>">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container">
|
|
11
|
+
<header>
|
|
12
|
+
<h1>UserPattern</h1>
|
|
13
|
+
<span class="version">v<%= UserPattern::VERSION %></span>
|
|
14
|
+
<% if @alert_mode %>
|
|
15
|
+
<span class="mode-badge alert">Alert Mode</span>
|
|
16
|
+
<% else %>
|
|
17
|
+
<span class="mode-badge collection">Collection Mode</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<nav class="page-tabs">
|
|
22
|
+
<a href="<%= user_pattern.root_path %>" class="active">Usage</a>
|
|
23
|
+
<a href="<%= user_pattern.violations_path %>">Violations</a>
|
|
24
|
+
</nav>
|
|
25
|
+
|
|
26
|
+
<% if @model_types.any? %>
|
|
27
|
+
<nav class="tabs">
|
|
28
|
+
<% @model_types.each do |mt| %>
|
|
29
|
+
<a href="<%= user_pattern.root_path(model_type: mt) %>"
|
|
30
|
+
class="<%= 'active' if mt == @selected_model %>">
|
|
31
|
+
<%= mt %>
|
|
32
|
+
</a>
|
|
33
|
+
<% end %>
|
|
34
|
+
</nav>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<div class="card">
|
|
38
|
+
<% if @filtered_stats.present? %>
|
|
39
|
+
<table id="stats-table">
|
|
40
|
+
<thead>
|
|
41
|
+
<tr>
|
|
42
|
+
<%
|
|
43
|
+
columns = [
|
|
44
|
+
{ key: "endpoint", label: "Endpoint", numeric: false },
|
|
45
|
+
{ key: "total_requests", label: "Total Reqs", numeric: true },
|
|
46
|
+
{ key: "total_sessions", label: "Sessions", numeric: true },
|
|
47
|
+
{ key: "avg_per_session", label: "Avg / Session", numeric: true },
|
|
48
|
+
{ key: "avg_per_minute", label: "Avg / Min", numeric: true },
|
|
49
|
+
{ key: "max_per_minute", label: "Max / Min", numeric: true },
|
|
50
|
+
{ key: "max_per_hour", label: "Max / Hour", numeric: true },
|
|
51
|
+
{ key: "max_per_day", label: "Max / Day", numeric: true },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
current_sort = params[:sort]
|
|
55
|
+
current_dir = params[:dir] || "desc"
|
|
56
|
+
%>
|
|
57
|
+
<% columns.each do |col| %>
|
|
58
|
+
<%
|
|
59
|
+
is_sorted = current_sort == col[:key]
|
|
60
|
+
next_dir = is_sorted && current_dir == "desc" ? "asc" : "desc"
|
|
61
|
+
css_class = []
|
|
62
|
+
css_class << "num" if col[:numeric]
|
|
63
|
+
css_class << "sorted-#{current_dir}" if is_sorted
|
|
64
|
+
%>
|
|
65
|
+
<th class="<%= css_class.join(' ') %>">
|
|
66
|
+
<a href="<%= user_pattern.root_path(model_type: @selected_model, sort: col[:key], dir: next_dir) %>"
|
|
67
|
+
style="text-decoration:none; color:inherit;">
|
|
68
|
+
<%= col[:label] %><span class="arrow"></span>
|
|
69
|
+
</a>
|
|
70
|
+
</th>
|
|
71
|
+
<% end %>
|
|
72
|
+
<% if @alert_mode %>
|
|
73
|
+
<th class="num">Limit / Min</th>
|
|
74
|
+
<th class="num">Limit / Hour</th>
|
|
75
|
+
<th class="num">Limit / Day</th>
|
|
76
|
+
<% end %>
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
<% @filtered_stats.each do |stat| %>
|
|
81
|
+
<% limits = @threshold_limits[[@selected_model, stat[:endpoint]]] || {} %>
|
|
82
|
+
<tr>
|
|
83
|
+
<td class="endpoint"><%= stat[:endpoint] %></td>
|
|
84
|
+
<td class="num"><%= stat[:total_requests] %></td>
|
|
85
|
+
<td class="num"><%= stat[:total_sessions] %></td>
|
|
86
|
+
<td class="num"><%= stat[:avg_per_session] %></td>
|
|
87
|
+
<td class="num"><%= stat[:avg_per_minute] %></td>
|
|
88
|
+
<td class="num"><%= stat[:max_per_minute] %></td>
|
|
89
|
+
<td class="num"><%= stat[:max_per_hour] %></td>
|
|
90
|
+
<td class="num"><%= stat[:max_per_day] %></td>
|
|
91
|
+
<% if @alert_mode %>
|
|
92
|
+
<td class="num threshold"><%= limits[:per_minute] || "—" %></td>
|
|
93
|
+
<td class="num threshold"><%= limits[:per_hour] || "—" %></td>
|
|
94
|
+
<td class="num threshold"><%= limits[:per_day] || "—" %></td>
|
|
95
|
+
<% end %>
|
|
96
|
+
</tr>
|
|
97
|
+
<% end %>
|
|
98
|
+
</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
<% else %>
|
|
101
|
+
<div class="empty-state">
|
|
102
|
+
<p>No data collected yet for <strong><%= @selected_model || "any model" %></strong>.</p>
|
|
103
|
+
<p>Requests from logged-in users will appear here automatically.</p>
|
|
104
|
+
</div>
|
|
105
|
+
<% end %>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<p class="footer">
|
|
109
|
+
Data retained for <%= UserPattern.configuration.retention_period %> days
|
|
110
|
+
·
|
|
111
|
+
<% total_events = UserPattern::RequestEvent.count %>
|
|
112
|
+
<%= total_events %> event<%= total_events == 1 ? "" : "s" %> in store
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
</body>
|
|
116
|
+
</html>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>UserPattern — Violations</title>
|
|
7
|
+
<link rel="stylesheet" href="<%= user_pattern.stylesheet_path %>">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container">
|
|
11
|
+
<header>
|
|
12
|
+
<h1>UserPattern</h1>
|
|
13
|
+
<span class="version">v<%= UserPattern::VERSION %></span>
|
|
14
|
+
<% if UserPattern.configuration.alert_mode? %>
|
|
15
|
+
<span class="mode-badge alert">Alert Mode</span>
|
|
16
|
+
<% else %>
|
|
17
|
+
<span class="mode-badge collection">Collection Mode</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<nav class="page-tabs">
|
|
22
|
+
<a href="<%= user_pattern.root_path %>">Usage</a>
|
|
23
|
+
<a href="<%= user_pattern.violations_path %>" class="active">Violations</a>
|
|
24
|
+
</nav>
|
|
25
|
+
|
|
26
|
+
<div class="filter-bar">
|
|
27
|
+
<% [7, 14, 30].each do |days| %>
|
|
28
|
+
<a href="<%= user_pattern.violations_path(days: days) %>"
|
|
29
|
+
class="<%= 'active' if (params[:days]&.to_i || 7) == days %>">
|
|
30
|
+
Last <%= days %> days
|
|
31
|
+
</a>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="card">
|
|
36
|
+
<% if @violations.any? %>
|
|
37
|
+
<table id="violations-table">
|
|
38
|
+
<thead>
|
|
39
|
+
<tr>
|
|
40
|
+
<th>Endpoint</th>
|
|
41
|
+
<th>Model</th>
|
|
42
|
+
<th>Period</th>
|
|
43
|
+
<th class="num">Count</th>
|
|
44
|
+
<th class="num">Limit</th>
|
|
45
|
+
<th>User (hashed)</th>
|
|
46
|
+
<th>Occurred At</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody>
|
|
50
|
+
<% @violations.each do |v| %>
|
|
51
|
+
<tr class="violation-row">
|
|
52
|
+
<td class="endpoint"><%= v.endpoint %></td>
|
|
53
|
+
<td><%= v.model_type %></td>
|
|
54
|
+
<td><%= v.period %></td>
|
|
55
|
+
<td class="num violation-exceeded"><%= v.count %></td>
|
|
56
|
+
<td class="num"><%= v.limit %></td>
|
|
57
|
+
<td class="mono"><%= v.user_identifier[0, 8] %>…</td>
|
|
58
|
+
<td><%= v.occurred_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
59
|
+
</tr>
|
|
60
|
+
<% end %>
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
<% else %>
|
|
64
|
+
<div class="empty-state">
|
|
65
|
+
<p>No violations recorded in the selected period.</p>
|
|
66
|
+
<% unless UserPattern.configuration.alert_mode? %>
|
|
67
|
+
<p>Switch to <strong>alert mode</strong> and enable <code>:record</code> in <code>violation_actions</code> to capture violations.</p>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<p class="footer">
|
|
74
|
+
<% total_violations = UserPattern::Violation.count %>
|
|
75
|
+
<%= total_violations %> total violation<%= total_violations == 1 ? "" : "s" %> on record
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module Userpattern
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include ActiveRecord::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path('templates', __dir__)
|
|
11
|
+
|
|
12
|
+
desc 'Install UserPattern: creates the initializer and migrations.'
|
|
13
|
+
|
|
14
|
+
def copy_initializer
|
|
15
|
+
template 'initializer.rb', 'config/initializers/userpattern.rb'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_request_events_migration
|
|
19
|
+
migration_template(
|
|
20
|
+
'create_userpattern_request_events.rb.erb',
|
|
21
|
+
'db/migrate/create_userpattern_request_events.rb'
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def copy_violations_migration
|
|
26
|
+
migration_template(
|
|
27
|
+
'create_userpattern_violations.rb.erb',
|
|
28
|
+
'db/migrate/create_userpattern_violations.rb'
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mount_engine
|
|
33
|
+
route 'mount UserPattern::Engine, at: "/userpatterns"'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def display_post_install
|
|
37
|
+
say ''
|
|
38
|
+
say 'UserPattern installed! Next steps:', :green
|
|
39
|
+
say ' 1. Run `rails db:migrate`'
|
|
40
|
+
say ' 2. Edit config/initializers/userpattern.rb to configure tracked models'
|
|
41
|
+
say ' 3. Set USERPATTERN_DASHBOARD_USER and USERPATTERN_DASHBOARD_PASSWORD env vars'
|
|
42
|
+
say ' 4. Visit /userpatterns to see the dashboard'
|
|
43
|
+
say ''
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class CreateUserpatternRequestEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :userpattern_request_events do |t|
|
|
4
|
+
t.string :model_type, null: false
|
|
5
|
+
t.string :endpoint, null: false
|
|
6
|
+
t.string :anonymous_session_id, null: false
|
|
7
|
+
t.datetime :recorded_at, null: false
|
|
8
|
+
t.datetime :created_at, null: false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :userpattern_request_events,
|
|
12
|
+
[:model_type, :endpoint, :recorded_at],
|
|
13
|
+
name: "idx_up_model_endpoint_time"
|
|
14
|
+
|
|
15
|
+
add_index :userpattern_request_events,
|
|
16
|
+
[:model_type, :endpoint, :anonymous_session_id],
|
|
17
|
+
name: "idx_up_model_endpoint_session"
|
|
18
|
+
|
|
19
|
+
add_index :userpattern_request_events,
|
|
20
|
+
:recorded_at,
|
|
21
|
+
name: "idx_up_recorded_at"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateUserpatternViolations < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :userpattern_violations do |t|
|
|
4
|
+
t.string :model_type, null: false
|
|
5
|
+
t.string :endpoint, null: false
|
|
6
|
+
t.string :period, null: false
|
|
7
|
+
t.integer :count, null: false
|
|
8
|
+
t.integer :limit, null: false
|
|
9
|
+
t.string :user_identifier, null: false
|
|
10
|
+
t.datetime :occurred_at, null: false
|
|
11
|
+
t.datetime :created_at, null: false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :userpattern_violations,
|
|
15
|
+
%i[model_type endpoint],
|
|
16
|
+
name: 'idx_up_violations_model_endpoint'
|
|
17
|
+
|
|
18
|
+
add_index :userpattern_violations,
|
|
19
|
+
:occurred_at,
|
|
20
|
+
name: 'idx_up_violations_occurred_at'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
UserPattern.configure do |config|
|
|
4
|
+
# ─── Tracked models ────────────────────────────────────────────────
|
|
5
|
+
# Each entry needs a :name and optionally a :current_method.
|
|
6
|
+
# If :current_method is omitted, it defaults to :current_<underscored_name>.
|
|
7
|
+
config.tracked_models = [
|
|
8
|
+
{ name: 'User', current_method: :current_user }
|
|
9
|
+
# { name: "Admin", current_method: :current_admin },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
# ─── Session detection ─────────────────────────────────────────────
|
|
13
|
+
# How to identify a session for anonymized grouping.
|
|
14
|
+
# :auto – use Authorization header (JWT) if present, otherwise session cookie
|
|
15
|
+
# :session – always use session cookie
|
|
16
|
+
# :header – always use Authorization header
|
|
17
|
+
# Proc – custom: ->(request) { request.headers["X-Session-Token"] }
|
|
18
|
+
config.session_detection = :auto
|
|
19
|
+
|
|
20
|
+
# ─── Performance ───────────────────────────────────────────────────
|
|
21
|
+
# Events are buffered in memory and flushed in batches.
|
|
22
|
+
# config.buffer_size = 100 # flush when buffer reaches this size
|
|
23
|
+
# config.flush_interval = 30 # flush at least every N seconds
|
|
24
|
+
|
|
25
|
+
# ─── Data retention ────────────────────────────────────────────────
|
|
26
|
+
# Raw events older than this are deleted by `rake userpattern:cleanup`.
|
|
27
|
+
# config.retention_period = 30 # days
|
|
28
|
+
|
|
29
|
+
# ─── Dashboard authentication ──────────────────────────────────────
|
|
30
|
+
# The dashboard is secure by default. Set these environment variables:
|
|
31
|
+
# USERPATTERN_DASHBOARD_USER
|
|
32
|
+
# USERPATTERN_DASHBOARD_PASSWORD
|
|
33
|
+
#
|
|
34
|
+
# Or provide a custom Proc:
|
|
35
|
+
# config.dashboard_auth = -> {
|
|
36
|
+
# redirect_to main_app.root_path unless current_user&.admin?
|
|
37
|
+
# }
|
|
38
|
+
|
|
39
|
+
# ─── Mode ──────────────────────────────────────────────────────────
|
|
40
|
+
# :collection — observe and record usage patterns (default)
|
|
41
|
+
# :alert — enforce rate limits derived from observed data
|
|
42
|
+
# config.mode = :collection
|
|
43
|
+
|
|
44
|
+
# ─── Alert mode settings ───────────────────────────────────────────
|
|
45
|
+
# config.threshold_multiplier = 1.5 # limit = observed_max * multiplier
|
|
46
|
+
# config.threshold_refresh_interval = 300 # reload limits from DB every N seconds
|
|
47
|
+
# config.block_unknown_endpoints = false # allow endpoints not seen during collection
|
|
48
|
+
|
|
49
|
+
# Cache store for rate-limiter counters (defaults to Rails.cache).
|
|
50
|
+
# For multi-process setups, use Redis:
|
|
51
|
+
# config.rate_limiter_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
|
|
52
|
+
|
|
53
|
+
# Actions to take when a threshold is exceeded (combine multiple):
|
|
54
|
+
# :raise — raise ThresholdExceeded (handle via rescue_from)
|
|
55
|
+
# :log — write to Rails.logger
|
|
56
|
+
# :record — persist to userpattern_violations table (visible in dashboard)
|
|
57
|
+
# :logout — call config.logout_method to terminate the session
|
|
58
|
+
# config.violation_actions = [:raise]
|
|
59
|
+
|
|
60
|
+
# Logout method (only used when :logout is in violation_actions):
|
|
61
|
+
# config.logout_method = ->(controller) { controller.sign_out(controller.current_user) }
|
|
62
|
+
|
|
63
|
+
# Optional callback for custom handling (Sentry, Slack, etc.):
|
|
64
|
+
# config.on_threshold_exceeded = ->(violation) {
|
|
65
|
+
# Sentry.capture_message("Rate limit: #{violation.message}")
|
|
66
|
+
# }
|
|
67
|
+
|
|
68
|
+
# ─── Ignored paths ────────────────────────────────────────────────
|
|
69
|
+
# Paths matching any entry are silently skipped — no event is recorded.
|
|
70
|
+
# Each entry can be a String (exact match) or a Regexp (pattern match).
|
|
71
|
+
# Matching is performed against the raw request path (no query string).
|
|
72
|
+
#
|
|
73
|
+
# Examples:
|
|
74
|
+
# config.ignored_paths = [
|
|
75
|
+
# "/health", # exact match
|
|
76
|
+
# "/up",
|
|
77
|
+
# %r{\A/api/internal}, # any path starting with /api/internal
|
|
78
|
+
# ]
|
|
79
|
+
# config.ignored_paths = []
|
|
80
|
+
|
|
81
|
+
# ─── Enable / disable ─────────────────────────────────────────────
|
|
82
|
+
# config.enabled = true
|
|
83
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :userpattern do
|
|
4
|
+
desc 'Remove request events older than the configured retention period'
|
|
5
|
+
task cleanup: :environment do
|
|
6
|
+
deleted = UserPattern.cleanup!
|
|
7
|
+
puts "[UserPattern] Cleaned up #{deleted} expired events."
|
|
8
|
+
end
|
|
9
|
+
end
|