rails_event_viewer 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +451 -0
- data/Rakefile +16 -0
- data/app/assets/stylesheets/rails_event_viewer/application.css +32 -0
- data/app/assets/stylesheets/rails_event_viewer/tailwind.css +54 -0
- data/app/controllers/rails_event_viewer/analytics_controller.rb +78 -0
- data/app/controllers/rails_event_viewer/application_controller.rb +84 -0
- data/app/controllers/rails_event_viewer/dashboard_controller.rb +18 -0
- data/app/controllers/rails_event_viewer/event_types_controller.rb +16 -0
- data/app/controllers/rails_event_viewer/events_controller.rb +72 -0
- data/app/controllers/rails_event_viewer/groups_controller.rb +52 -0
- data/app/helpers/rails_event_viewer/application_helper.rb +163 -0
- data/app/models/rails_event_viewer/application_record.rb +5 -0
- data/app/models/rails_event_viewer/entry.rb +34 -0
- data/app/views/layouts/rails_event_viewer/application.html.erb +45 -0
- data/app/views/rails_event_viewer/analytics/overview.html.erb +134 -0
- data/app/views/rails_event_viewer/dashboard/index.html.erb +113 -0
- data/app/views/rails_event_viewer/event_types/index.html.erb +59 -0
- data/app/views/rails_event_viewer/event_types/show.html.erb +102 -0
- data/app/views/rails_event_viewer/events/_event_row.html.erb +42 -0
- data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +66 -0
- data/app/views/rails_event_viewer/events/index.html.erb +65 -0
- data/app/views/rails_event_viewer/events/show.html.erb +155 -0
- data/app/views/rails_event_viewer/groups/index.html.erb +115 -0
- data/app/views/rails_event_viewer/groups/show.html.erb +134 -0
- data/app/views/rails_event_viewer/shared/_navigation.html.erb +47 -0
- data/app/views/rails_event_viewer/shared/_pagination.html.erb +60 -0
- data/app/views/rails_event_viewer/shared/_stat_card.html.erb +36 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20241208000001_create_rails_event_viewer_entries.rb +36 -0
- data/lib/generators/rails_event_viewer/install/USAGE +33 -0
- data/lib/generators/rails_event_viewer/install/install_generator.rb +86 -0
- data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +35 -0
- data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +90 -0
- data/lib/rails_event_viewer/adapter.rb +93 -0
- data/lib/rails_event_viewer/adapters/active_record.rb +227 -0
- data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +136 -0
- data/lib/rails_event_viewer/adapters/memory.rb +128 -0
- data/lib/rails_event_viewer/adapters/null.rb +63 -0
- data/lib/rails_event_viewer/adapters/redis.rb +213 -0
- data/lib/rails_event_viewer/engine.rb +24 -0
- data/lib/rails_event_viewer/events_relation.rb +130 -0
- data/lib/rails_event_viewer/json_query.rb +111 -0
- data/lib/rails_event_viewer/pagy_countable.rb +31 -0
- data/lib/rails_event_viewer/subscriber.rb +255 -0
- data/lib/rails_event_viewer/time_utils.rb +24 -0
- data/lib/rails_event_viewer/version.rb +3 -0
- data/lib/rails_event_viewer.rb +122 -0
- data/lib/tasks/rails_event_viewer_tasks.rake +81 -0
- metadata +151 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 323623721d960260a7ea2077cf30edc17c4708b7c8d96ab5d9fc7320b45f8113
|
|
4
|
+
data.tar.gz: 7afbd0a2835c53520bbbb8d1f323b1643297bc8e641948c614ddc407d6791edd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f28f9dff6c4085b5c319744f19c71ae1f373dede9add4a8f5a0bda335bb088ec283e353c151cd28eb5ae71ba2d9b4a6f8c13f45bb7a03d62b687ba706b405ff9
|
|
7
|
+
data.tar.gz: 544807b6dbee7af798bce690b6b51e0ada6f168b7681d867d2b57ce634aa3995b1878ba3f3e3dbe3976d6ce55edf9bb6f069f1b4b2722988154b97b86164103c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-09-29)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Web dashboard for browsing, searching, and analyzing events emitted via `Rails.event` (Rails 8.1+).
|
|
8
|
+
- Storage adapters: ActiveRecord (default), Redis, Memory, Null, or a custom class.
|
|
9
|
+
- Buffered async ingestion with configurable buffer size, flush interval, and sampling.
|
|
10
|
+
- Event filtering with `captured_events` and `ignored_events`.
|
|
11
|
+
- Event groups by context or tag keys, such as `request_id`.
|
|
12
|
+
- Analytics: events over time and counts by event name.
|
|
13
|
+
- HTTP Basic Auth or custom authentication. The dashboard returns 403 outside development and test when neither is configured, and when Basic Auth is enabled with a blank user or password.
|
|
14
|
+
- Rake tasks: `stats`, `cleanup`, `flush`, `clear`, `install:migrations`.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Keshav Biswa
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# RailsEventViewer
|
|
2
|
+
|
|
3
|
+
A Rails engine for viewing and analyzing events emitted via `Rails.event`.
|
|
4
|
+
Provides a web UI dashboard for browsing, filtering, and visualizing domain events in your Rails application.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- Web-based dashboard for viewing events
|
|
9
|
+
- Flexible storage adapters (ActiveRecord, Redis, Memory, or custom)
|
|
10
|
+
- High-performance buffered ingestion with configurable batch writes
|
|
11
|
+
- Fluent query interface for programmatic access
|
|
12
|
+
- Event filtering by name, tags, and time range
|
|
13
|
+
- Full-text search across event names and payloads
|
|
14
|
+
- Analytics with event counts over time
|
|
15
|
+
- Configurable retention and automatic cleanup
|
|
16
|
+
- HTTP Basic Auth or custom authentication
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Rails 8.1+
|
|
21
|
+
- Ruby 3.2+
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add this line to your application's Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "rails_event_viewer"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
And then execute:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run the install generator:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
rails generate rails_event_viewer:install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This will:
|
|
44
|
+
- Create a migration for the events table (if using ActiveRecord adapter)
|
|
45
|
+
- Create an initializer with configuration options
|
|
46
|
+
- Mount the engine at `/events`
|
|
47
|
+
|
|
48
|
+
Run the migration:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
rails db:migrate
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Configure RailsEventViewer in `config/initializers/rails_event_viewer.rb`:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
RailsEventViewer.configure do |config|
|
|
60
|
+
# Storage adapter: :active_record, :redis, :memory, :null, or a custom class
|
|
61
|
+
config.storage_adapter = :active_record
|
|
62
|
+
|
|
63
|
+
# Adapter-specific options
|
|
64
|
+
config.adapter_options = {}
|
|
65
|
+
|
|
66
|
+
# Performance settings
|
|
67
|
+
config.async = true # Enable buffered writes
|
|
68
|
+
config.buffer_size = 100 # Flush after N events
|
|
69
|
+
config.flush_interval = 2 # Flush every N seconds
|
|
70
|
+
config.sample_rate = 1.0 # 1.0 = capture all, 0.1 = capture 10%
|
|
71
|
+
|
|
72
|
+
# Retention
|
|
73
|
+
config.retention_period = 7.days
|
|
74
|
+
|
|
75
|
+
# Filtering
|
|
76
|
+
config.captured_events = [] # Empty = capture all
|
|
77
|
+
config.ignored_events = [] # Events to never capture
|
|
78
|
+
|
|
79
|
+
# UI settings
|
|
80
|
+
config.per_page = 25
|
|
81
|
+
|
|
82
|
+
# Authentication (choose one)
|
|
83
|
+
config.http_basic_auth_enabled = true
|
|
84
|
+
config.http_basic_auth_user = ENV["RAILS_EVENT_VIEWER_USER"]
|
|
85
|
+
config.http_basic_auth_password = ENV["RAILS_EVENT_VIEWER_PASSWORD"]
|
|
86
|
+
|
|
87
|
+
# Or use custom authentication
|
|
88
|
+
config.authentication = ->(controller) {
|
|
89
|
+
controller.authenticate_user!
|
|
90
|
+
controller.current_user.admin?
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Storage Adapters
|
|
96
|
+
|
|
97
|
+
### ActiveRecord (default)
|
|
98
|
+
|
|
99
|
+
Stores events in your database. Best for most applications with full analytics support.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
config.storage_adapter = :active_record
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The install generator creates the migration for you. If you skipped the generator, copy the migration from the engine instead. Do not do both, or you will get two migrations for the same table.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
rails rails_event_viewer:install:migrations
|
|
109
|
+
rails db:migrate
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Redis
|
|
113
|
+
|
|
114
|
+
Stores events in Redis sorted sets. Good for high-volume, ephemeral storage where you don't need long-term persistence.
|
|
115
|
+
|
|
116
|
+
First, add the redis gem to your Gemfile:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
gem "redis"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Then configure the adapter:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
config.storage_adapter = :redis
|
|
126
|
+
config.adapter_options = {
|
|
127
|
+
redis_options: { url: ENV.fetch("REDIS_URL", "redis://localhost:6379") },
|
|
128
|
+
pool_size: 5,
|
|
129
|
+
key_prefix: "rails_event_viewer",
|
|
130
|
+
max_events: 10_000
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Options:
|
|
135
|
+
- `redis_options` - Hash passed to `Redis.new` whenever the pool opens a new connection (default: `{}`)
|
|
136
|
+
- `pool_size` - Number of pooled Redis connections, should match your app's thread count (default: `5`)
|
|
137
|
+
- `pool_timeout` - Seconds to wait for a free connection before raising (default: `5`)
|
|
138
|
+
- `key_prefix` - Prefix for Redis keys (default: `"rails_event_viewer"`). The adapter wraps it in braces, so keys look like `{rails_event_viewer}:events`. The braces are a Redis Cluster hash tag that keeps every key on one node, which the adapter's multi-key commands require. Do not add braces yourself.
|
|
139
|
+
- `max_events` - Maximum events to retain, older events are automatically trimmed (default: `10_000`)
|
|
140
|
+
|
|
141
|
+
The adapter uses a connection pool rather than a single shared connection, since one Redis connection is not safe to use concurrently across multiple threads. Each pooled connection is built independently from `redis_options`. Pass `pool:` with your own `ConnectionPool` for full control.
|
|
142
|
+
|
|
143
|
+
Note: Redis adapter performs analytics by fetching events into memory, which may be slower than ActiveRecord for large datasets.
|
|
144
|
+
|
|
145
|
+
Filtered queries and analytics scan every stored event. Keep `max_events` modest, around 10,000, if you filter often.
|
|
146
|
+
|
|
147
|
+
### Memory
|
|
148
|
+
|
|
149
|
+
In-memory storage for development and testing.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
config.storage_adapter = :memory
|
|
153
|
+
config.adapter_options = { max_events: 1000 }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Options:
|
|
157
|
+
- `max_events` - Maximum events to retain (default: `1000`)
|
|
158
|
+
|
|
159
|
+
Note: Events are lost when the server process restarts. Not recommended for production use.
|
|
160
|
+
|
|
161
|
+
### Null
|
|
162
|
+
|
|
163
|
+
Discards all events. Useful for disabling event capture in specific environments.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
config.storage_adapter = :null
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Switching Adapters via Environment Variable
|
|
170
|
+
|
|
171
|
+
A common pattern is to use different adapters per environment:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
RailsEventViewer.configure do |config|
|
|
175
|
+
adapter = ENV.fetch("EVENT_STORAGE", "active_record").to_sym
|
|
176
|
+
config.storage_adapter = adapter
|
|
177
|
+
|
|
178
|
+
case adapter
|
|
179
|
+
when :redis
|
|
180
|
+
config.adapter_options = {
|
|
181
|
+
redis_options: { url: ENV.fetch("REDIS_URL", "redis://localhost:6379") },
|
|
182
|
+
max_events: 10_000
|
|
183
|
+
}
|
|
184
|
+
when :memory
|
|
185
|
+
config.adapter_options = { max_events: 1000 }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Then switch adapters when starting your server:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# Use ActiveRecord (default)
|
|
194
|
+
bin/rails server
|
|
195
|
+
|
|
196
|
+
# Use Redis
|
|
197
|
+
EVENT_STORAGE=redis bin/rails server
|
|
198
|
+
|
|
199
|
+
# Use Memory
|
|
200
|
+
EVENT_STORAGE=memory bin/rails server
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Custom Adapter
|
|
204
|
+
|
|
205
|
+
Create your own adapter by including the `RailsEventViewer::Adapter` module:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
class MyCustomAdapter
|
|
209
|
+
include RailsEventViewer::Adapter
|
|
210
|
+
|
|
211
|
+
def write_events(events)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fetch_events(relation)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def count_events(relation)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def distinct_event_names
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def find_event(id)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def delete_before(timestamp)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def distinct_group_values(key, source: :context)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def group_instances(key, source: :context, limit: 100)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def events_over_time(since:, interval:)
|
|
236
|
+
# Return { time => count }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def counts_by_name(limit:)
|
|
240
|
+
# Return { name => count }, highest first
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def count_since(since)
|
|
244
|
+
# Return count of events since a time
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
config.storage_adapter = MyCustomAdapter
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Emitting Events
|
|
252
|
+
|
|
253
|
+
RailsEventViewer automatically subscribes to `Rails.event`. Emit events using the standard Rails API:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
Rails.event.notify("user.created", user_id: user.id, email: user.email)
|
|
257
|
+
|
|
258
|
+
Rails.event.notify(
|
|
259
|
+
"order.placed",
|
|
260
|
+
order_id: order.id,
|
|
261
|
+
total: order.total,
|
|
262
|
+
tags: { environment: Rails.env, priority: "high" }
|
|
263
|
+
)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Programmatic Access
|
|
267
|
+
|
|
268
|
+
Use the fluent query interface to access events:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# Get all events
|
|
272
|
+
RailsEventViewer.events.each { |e| puts e[:name] }
|
|
273
|
+
|
|
274
|
+
# Filter by name
|
|
275
|
+
RailsEventViewer.events.with_name("user.created").to_a
|
|
276
|
+
|
|
277
|
+
# Filter by tag
|
|
278
|
+
RailsEventViewer.events.with_tag(:environment, "production").to_a
|
|
279
|
+
|
|
280
|
+
# Time range
|
|
281
|
+
RailsEventViewer.events.since(1.hour.ago).until(30.minutes.ago).to_a
|
|
282
|
+
|
|
283
|
+
# Search
|
|
284
|
+
RailsEventViewer.events.search("user@example.com").to_a
|
|
285
|
+
|
|
286
|
+
# Combine filters
|
|
287
|
+
RailsEventViewer.events
|
|
288
|
+
.with_name("order.placed")
|
|
289
|
+
.with_tag(:priority, "high")
|
|
290
|
+
.since(1.day.ago)
|
|
291
|
+
.limit(50)
|
|
292
|
+
.each { |e| process(e) }
|
|
293
|
+
|
|
294
|
+
# Count
|
|
295
|
+
RailsEventViewer.events.with_name("user.created").count
|
|
296
|
+
|
|
297
|
+
# Pagination
|
|
298
|
+
RailsEventViewer.events.offset(25).limit(25).to_a
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Rake Tasks
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
# Show statistics
|
|
305
|
+
bin/rails rails_event_viewer:stats
|
|
306
|
+
|
|
307
|
+
# Clean up old events (based on retention_period)
|
|
308
|
+
bin/rails rails_event_viewer:cleanup
|
|
309
|
+
|
|
310
|
+
# Flush buffered events
|
|
311
|
+
bin/rails rails_event_viewer:flush
|
|
312
|
+
|
|
313
|
+
# Clear all events (interactive confirmation required)
|
|
314
|
+
bin/rails rails_event_viewer:clear
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
For automatic cleanup, add to your scheduler:
|
|
318
|
+
|
|
319
|
+
```yaml
|
|
320
|
+
# config/recurring.yml (Solid Queue)
|
|
321
|
+
production:
|
|
322
|
+
rails_event_viewer_cleanup:
|
|
323
|
+
command: "RailsEventViewer.adapter.delete_before(RailsEventViewer.retention_period.ago)"
|
|
324
|
+
schedule: every day at 3am
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Or using the whenever gem:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
# config/schedule.rb (whenever gem)
|
|
331
|
+
every 1.day, at: "3:00 am" do
|
|
332
|
+
rake "rails_event_viewer:cleanup"
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Event Filtering
|
|
337
|
+
|
|
338
|
+
Control which events are captured:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# Only capture specific events
|
|
342
|
+
config.captured_events = ["user.created", "order.placed"]
|
|
343
|
+
|
|
344
|
+
# Or use regex patterns
|
|
345
|
+
config.captured_events = [/^user\./, /^order\./]
|
|
346
|
+
|
|
347
|
+
# Ignore specific events
|
|
348
|
+
config.ignored_events = ["internal.heartbeat", /^debug\./]
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Sampling
|
|
352
|
+
|
|
353
|
+
For high-volume applications, reduce storage by sampling if needed:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
# Capture only 10% of events
|
|
357
|
+
config.sample_rate = 0.1
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Authentication
|
|
361
|
+
|
|
362
|
+
If neither option below is configured, the dashboard is open in development and test, and returns `403 Forbidden` in every other environment, including staging. Enabling HTTP Basic Auth with a blank user or password also returns `403 Forbidden`.
|
|
363
|
+
|
|
364
|
+
Set `RAILS_ENV` on every deployed server. If it is missing, Rails falls back to development and the dashboard is open.
|
|
365
|
+
|
|
366
|
+
### HTTP Basic Auth
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
config.http_basic_auth_enabled = true
|
|
370
|
+
config.http_basic_auth_user = ENV["RAILS_EVENT_VIEWER_USER"]
|
|
371
|
+
config.http_basic_auth_password = ENV["RAILS_EVENT_VIEWER_PASSWORD"]
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Custom Authentication
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
# Devise example
|
|
378
|
+
config.authentication = ->(controller) {
|
|
379
|
+
controller.authenticate_user!
|
|
380
|
+
controller.current_user.admin?
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Routes
|
|
385
|
+
|
|
386
|
+
The engine is mounted at the path specified in your routes (default `/events`):
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
# config/routes.rb
|
|
390
|
+
mount RailsEventViewer::Engine, at: "/events"
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Available routes:
|
|
394
|
+
- `/events` - Dashboard
|
|
395
|
+
- `/events/events` - Event list with filtering
|
|
396
|
+
- `/events/events/:id` - Single event detail
|
|
397
|
+
- `/events/analytics/overview` - Analytics dashboard
|
|
398
|
+
- `/events/groups` - Event groups
|
|
399
|
+
|
|
400
|
+
## How Event Capture Works
|
|
401
|
+
|
|
402
|
+
RailsEventViewer subscribes to `Rails.event` (the Structured Event Reporter introduced in Rails 8.1),
|
|
403
|
+
which is built on top of `ActiveSupport::Notifications`. This means internal Rails instrumentation
|
|
404
|
+
events (`sql.active_record`, `process_action.action_controller`, etc.) are received by the subscriber.
|
|
405
|
+
|
|
406
|
+
By default, all internal Rails framework events are excluded via the built-in `ignored_events` patterns.
|
|
407
|
+
This prevents noise from flooding your storage.
|
|
408
|
+
|
|
409
|
+
**Captured by default:**
|
|
410
|
+
- Application events emitted via `Rails.event.notify("my.event", ...)`
|
|
411
|
+
|
|
412
|
+
**Ignored by default** (via `ignored_events`):
|
|
413
|
+
- `active_record.*`, `action_controller.*`, `action_view.*`, `active_job.*`
|
|
414
|
+
- `action_mailer.*`, `active_storage.*`, `action_cable.*`, `rails.*`
|
|
415
|
+
- `turbo.*`, `cache_*`, `*.sql`, `solid_*`
|
|
416
|
+
|
|
417
|
+
To capture internal Rails events, remove the relevant pattern from `ignored_events`:
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
RailsEventViewer.configure do |config|
|
|
421
|
+
config.ignored_events = RailsEventViewer.ignored_events.reject { |p| p == /^active_record\./ }
|
|
422
|
+
end
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Development
|
|
426
|
+
|
|
427
|
+
After checking out the repo, run:
|
|
428
|
+
|
|
429
|
+
```bash
|
|
430
|
+
cd test/dummy
|
|
431
|
+
bin/rails db:migrate
|
|
432
|
+
bin/rails server
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Run the test suite:
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
bundle exec rake test
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Contributing
|
|
442
|
+
|
|
443
|
+
1. Fork it
|
|
444
|
+
2. Create your feature branch (`git checkout -b feature/my-new-feature`)
|
|
445
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
446
|
+
4. Push to the branch (`git push origin feature/my-new-feature`)
|
|
447
|
+
5. Create a Pull Request
|
|
448
|
+
|
|
449
|
+
## License
|
|
450
|
+
|
|
451
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
require "bundler/gem_tasks"
|
|
7
|
+
|
|
8
|
+
require "rake/testtask"
|
|
9
|
+
|
|
10
|
+
Rake::TestTask.new(:test) do |t|
|
|
11
|
+
t.libs << "test"
|
|
12
|
+
t.pattern = "test/**/*_test.rb"
|
|
13
|
+
t.verbose = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
task default: :test
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* RailsEventViewer Application Styles */
|
|
2
|
+
/* Note: Tailwind CSS is loaded via CDN in development. For production, compile using:
|
|
3
|
+
npx tailwindcss -i ./app/assets/stylesheets/rails_event_viewer/tailwind.css \
|
|
4
|
+
-o ./app/assets/stylesheets/rails_event_viewer/application.css --minify
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* Custom styles that don't require Tailwind compilation */
|
|
8
|
+
.json-tree {
|
|
9
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
10
|
+
font-size: 0.875rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.json-tree-key {
|
|
14
|
+
color: #4b5563;
|
|
15
|
+
font-weight: 500;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.json-tree-string {
|
|
19
|
+
color: #059669;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.json-tree-number {
|
|
23
|
+
color: #2563eb;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.json-tree-boolean {
|
|
27
|
+
color: #7c3aed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.json-tree-null {
|
|
31
|
+
color: #9ca3af;
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
/* Custom component styles */
|
|
6
|
+
@layer components {
|
|
7
|
+
.btn {
|
|
8
|
+
@apply px-4 py-2 rounded-md text-sm font-medium transition-colors;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.btn-primary {
|
|
12
|
+
@apply bg-indigo-600 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.btn-secondary {
|
|
16
|
+
@apply bg-white text-gray-700 border border-gray-300 hover:bg-gray-50;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.card {
|
|
20
|
+
@apply bg-white rounded-lg shadow;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.input {
|
|
24
|
+
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.label {
|
|
28
|
+
@apply block text-sm font-medium text-gray-700;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.badge {
|
|
32
|
+
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.badge-primary {
|
|
36
|
+
@apply bg-indigo-100 text-indigo-800;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.badge-gray {
|
|
40
|
+
@apply bg-gray-100 text-gray-800;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.badge-green {
|
|
44
|
+
@apply bg-green-100 text-green-800;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.table-header {
|
|
48
|
+
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.table-cell {
|
|
52
|
+
@apply px-6 py-4 whitespace-nowrap text-sm;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module RailsEventViewer
|
|
2
|
+
class AnalyticsController < ApplicationController
|
|
3
|
+
before_action :check_analytics_support
|
|
4
|
+
|
|
5
|
+
def overview
|
|
6
|
+
@date_range = parse_date_range
|
|
7
|
+
|
|
8
|
+
relation = RailsEventViewer.events
|
|
9
|
+
.since(@date_range.begin)
|
|
10
|
+
.until(@date_range.end)
|
|
11
|
+
|
|
12
|
+
@total_events = relation.count
|
|
13
|
+
@unique_event_types = current_adapter.distinct_event_names.size
|
|
14
|
+
@events_by_type = current_adapter.counts_by_name(limit: 20)
|
|
15
|
+
@events_over_time = current_adapter.events_over_time(since: @date_range.begin, interval: period_for_range)
|
|
16
|
+
|
|
17
|
+
@avg_events_per_hour = calculate_avg_events_per_hour
|
|
18
|
+
@peak_period = find_peak_period
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def events_over_time
|
|
22
|
+
since = params[:since]&.to_i&.seconds&.ago || 24.hours.ago
|
|
23
|
+
interval = params[:interval]&.to_sym || :hour
|
|
24
|
+
|
|
25
|
+
@events_data = current_adapter.events_over_time(since: since, interval: interval)
|
|
26
|
+
|
|
27
|
+
respond_to do |format|
|
|
28
|
+
format.json { render json: @events_data }
|
|
29
|
+
format.html { render partial: "events_over_time_chart" }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def events_by_type
|
|
34
|
+
limit = (params[:limit] || 20).to_i.clamp(1, 100)
|
|
35
|
+
@data = current_adapter.counts_by_name(limit: limit)
|
|
36
|
+
|
|
37
|
+
respond_to do |format|
|
|
38
|
+
format.json { render json: @data }
|
|
39
|
+
format.html { render partial: "events_by_type_chart" }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def check_analytics_support
|
|
46
|
+
unless analytics_supported?
|
|
47
|
+
redirect_to root_path, alert: "Analytics not supported by current storage adapter"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_date_range
|
|
52
|
+
start_date = safe_parse_date(params[:start_date], default: 7.days.ago.to_date)
|
|
53
|
+
end_date = safe_parse_date(params[:end_date], default: Date.current)
|
|
54
|
+
|
|
55
|
+
start_date, end_date = end_date, start_date if start_date > end_date
|
|
56
|
+
|
|
57
|
+
start_date.beginning_of_day..end_date.end_of_day
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def period_for_range
|
|
61
|
+
duration = (@date_range.end - @date_range.begin).to_i
|
|
62
|
+
duration <= 604800 ? :hour : :day
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def calculate_avg_events_per_hour
|
|
66
|
+
hours = ((@date_range.end - @date_range.begin) / 3600.0).round(1)
|
|
67
|
+
return 0 if hours.zero?
|
|
68
|
+
|
|
69
|
+
(@total_events.to_f / hours).round(1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_peak_period
|
|
73
|
+
return nil if @events_over_time.empty?
|
|
74
|
+
|
|
75
|
+
@events_over_time.max_by { |_, count| count }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|