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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UserPattern
4
+ class Violation < ActiveRecord::Base
5
+ self.table_name = 'userpattern_violations'
6
+
7
+ scope :recent, ->(days = 7) { where('occurred_at > ?', days.days.ago) }
8
+ end
9
+ 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
+ &middot;
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] %>&hellip;</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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ UserPattern::Engine.routes.draw do
4
+ get 'stylesheet', to: 'dashboard#stylesheet', as: :stylesheet
5
+ get 'violations', to: 'dashboard#violations', as: :violations
6
+ root to: 'dashboard#index'
7
+ end
@@ -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