rails_event_viewer 0.2.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +21 -1
- data/Rakefile +2 -0
- data/app/controllers/rails_event_viewer/analytics_controller.rb +5 -30
- data/app/controllers/rails_event_viewer/application_controller.rb +5 -0
- data/app/controllers/rails_event_viewer/dashboard_controller.rb +1 -1
- data/app/controllers/rails_event_viewer/event_types_controller.rb +3 -1
- data/app/controllers/rails_event_viewer/groups_controller.rb +4 -2
- data/app/views/rails_event_viewer/groups/index.html.erb +1 -1
- data/config/routes.rb +5 -4
- data/db/migrate/{20241208000001_create_rails_event_viewer_entries.rb → 20261002121337_create_rails_event_viewer_entries.rb} +9 -4
- data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +8 -3
- data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +7 -9
- data/lib/rails_event_viewer/adapter.rb +2 -2
- data/lib/rails_event_viewer/adapters/active_record.rb +12 -37
- data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +22 -3
- data/lib/rails_event_viewer/adapters/memory.rb +1 -21
- data/lib/rails_event_viewer/adapters/null.rb +2 -2
- data/lib/rails_event_viewer/adapters/redis.rb +1 -25
- data/lib/rails_event_viewer/batch_writer.rb +2 -1
- data/lib/rails_event_viewer/json_query.rb +52 -60
- data/lib/rails_event_viewer/subscriber.rb +1 -2
- data/lib/rails_event_viewer/time_utils.rb +3 -10
- data/lib/rails_event_viewer/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9535859f9ef02bb389bd7b17f4d9c34329c199c4af3cf6e97effeaa5ad27b52
|
|
4
|
+
data.tar.gz: 404478f207dae7bcfc10152b3832bb6a9200d4ec1e36c9a1a36ce1cd2ac4ff0c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15c2c7235db0b5e5b4694c5ddffd7f71c543560670011db7984d19e68dd4ba7848fa71934a6755cac6055f2e29414b4f9d3318ee4e0d3b7382c37ba23303cd0f
|
|
7
|
+
data.tar.gz: a32771c3b8d554d69445b037aa8e8b64a1fb2dd2d7e84169a665c61bd83b632cac2f863ec3d5c65c195f772fd49c0af7af56cdfa0af08712d4381478bb7c614e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
Breaking:
|
|
6
|
+
|
|
7
|
+
- Event type and group pages moved to `/event_type?name=...` and `/group?key=...&value=...`. Old links to those pages no longer work.
|
|
8
|
+
- Custom adapters: `counts_by_name` and `events_over_time` now take a `range:` argument.
|
|
9
|
+
- Unknown options for the Redis and memory adapters raise an `ArgumentError`.
|
|
10
|
+
- `with_tag` and `with_context` with a value of `""` or `false` now match that value, not every event that has the key.
|
|
11
|
+
|
|
12
|
+
Fixed:
|
|
13
|
+
|
|
14
|
+
- PostgreSQL: group pages no longer raise.
|
|
15
|
+
- MySQL: the migration runs and search works.
|
|
16
|
+
- Event names and group values with a slash no longer break the Event Types and Groups pages.
|
|
17
|
+
- Tag and context filters return the same events on every database and adapter. A value typed as text also matches numbers and booleans.
|
|
18
|
+
- Filter keys with a hyphen, dot, space or quote work on every database.
|
|
19
|
+
- Events the database rejects no longer hold up the events behind them.
|
|
20
|
+
- Payloads that cannot be serialized, such as invalid UTF-8, are stored as text.
|
|
21
|
+
- A dropped connection during the first table check no longer stops capture until restart.
|
|
22
|
+
- The analytics page respects its date range.
|
|
23
|
+
- The generated Redis config suggests `redis_options`, which the adapter reads.
|
|
24
|
+
- The generated initializer adds to `ignored_events` and keeps the defaults.
|
|
25
|
+
|
|
26
|
+
Changed:
|
|
27
|
+
|
|
28
|
+
- Removed two unused analytics endpoints.
|
|
29
|
+
- The engine migration has a new timestamp and targets Rails 8.1. Apps that already installed it need no action.
|
|
30
|
+
|
|
3
31
|
## 0.2.1 (2026-10-01)
|
|
4
32
|
|
|
5
33
|
- Forked workers no longer duplicate the parent's buffered events on exit.
|
data/README.md
CHANGED
|
@@ -94,6 +94,17 @@ Set `RAILS_ENV` on every server. Without it, Rails falls back to development and
|
|
|
94
94
|
|
|
95
95
|
Set `config.storage_adapter` to `:active_record` (default), `:redis`, `:memory`, or `:null`. Redis needs the `redis` gem and takes options through `config.adapter_options`.
|
|
96
96
|
|
|
97
|
+
```ruby
|
|
98
|
+
config.storage_adapter = :redis
|
|
99
|
+
config.adapter_options = {
|
|
100
|
+
redis_options: { url: ENV["REDIS_URL"] }, # passed to Redis.new
|
|
101
|
+
key_prefix: "rails_event_viewer",
|
|
102
|
+
max_events: 10_000
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Pass `pool:` to use your own `ConnectionPool`, or `pool_size:` and `pool_timeout:` to size the built-in one. The memory adapter takes `max_events:`. An unknown option raises an `ArgumentError`.
|
|
107
|
+
|
|
97
108
|
Filtered queries on Redis scan every stored event, so keep `max_events` (how many events Redis keeps, set in `adapter_options`) modest. For your own storage, include `RailsEventViewer::Adapter`.
|
|
98
109
|
|
|
99
110
|
## Rake tasks
|
|
@@ -119,7 +130,16 @@ production:
|
|
|
119
130
|
```bash
|
|
120
131
|
bundle install
|
|
121
132
|
bundle exec rake test
|
|
122
|
-
|
|
133
|
+
bin/rails db:migrate && bin/rails server
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`rake test` prepares the test database first. Run it once before running a single test file.
|
|
137
|
+
|
|
138
|
+
To test against PostgreSQL or MySQL, set `DATABASE_URL`:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
DATABASE_URL=postgresql://localhost/rails_event_viewer_test bundle exec rake test
|
|
142
|
+
DATABASE_URL=trilogy://root@127.0.0.1/rails_event_viewer_test bundle exec rake test
|
|
123
143
|
```
|
|
124
144
|
|
|
125
145
|
## License
|
data/Rakefile
CHANGED
|
@@ -4,42 +4,17 @@ module RailsEventViewer
|
|
|
4
4
|
|
|
5
5
|
def overview
|
|
6
6
|
@date_range = parse_date_range
|
|
7
|
+
counts = current_adapter.counts_by_name(range: @date_range)
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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)
|
|
9
|
+
@total_events = counts.values.sum
|
|
10
|
+
@unique_event_types = counts.size
|
|
11
|
+
@events_by_type = counts.first(20).to_h
|
|
12
|
+
@events_over_time = current_adapter.events_over_time(range: @date_range, interval: period_for_range)
|
|
16
13
|
|
|
17
14
|
@avg_events_per_hour = calculate_avg_events_per_hour
|
|
18
15
|
@peak_period = find_peak_period
|
|
19
16
|
end
|
|
20
17
|
|
|
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
18
|
private
|
|
44
19
|
|
|
45
20
|
def check_analytics_support
|
|
@@ -42,6 +42,11 @@ module RailsEventViewer
|
|
|
42
42
|
Time.use_zone(zone, &block)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def string_param(key)
|
|
46
|
+
value = params[key]
|
|
47
|
+
value if value.is_a?(String) && !value.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
45
50
|
def safe_parse_date(date_string, default: nil)
|
|
46
51
|
return default if date_string.blank?
|
|
47
52
|
|
|
@@ -8,7 +8,7 @@ module RailsEventViewer
|
|
|
8
8
|
|
|
9
9
|
if analytics_supported?
|
|
10
10
|
@events_by_type = current_adapter.counts_by_name(limit: 10)
|
|
11
|
-
@events_over_time = current_adapter.events_over_time(
|
|
11
|
+
@events_over_time = current_adapter.events_over_time(range: 24.hours.ago.., interval: :hour)
|
|
12
12
|
else
|
|
13
13
|
@events_by_type = {}
|
|
14
14
|
@events_over_time = {}
|
|
@@ -5,7 +5,9 @@ module RailsEventViewer
|
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def show
|
|
8
|
-
@event_type_name =
|
|
8
|
+
@event_type_name = string_param(:name)
|
|
9
|
+
return redirect_to event_types_path unless @event_type_name
|
|
10
|
+
|
|
9
11
|
total_events = RailsEventViewer.events.with_name(@event_type_name)
|
|
10
12
|
|
|
11
13
|
@pagination, @events = paginate(total_events)
|
|
@@ -17,8 +17,10 @@ module RailsEventViewer
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def show
|
|
20
|
-
@key =
|
|
21
|
-
@value =
|
|
20
|
+
@key = string_param(:key)
|
|
21
|
+
@value = string_param(:value)
|
|
22
|
+
return redirect_to groups_path unless @key && @value
|
|
23
|
+
|
|
22
24
|
@source = source_param
|
|
23
25
|
|
|
24
26
|
events = if @source == :tags
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
<% duration = group[:last_event_at] && group[:first_event_at] ? group[:last_event_at] - group[:first_event_at] : nil %>
|
|
68
68
|
<tr class="hover:bg-gray-50">
|
|
69
69
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
70
|
-
<%= link_to rails_event_viewer.group_path(group[:value], key: @selected_key, source: @source),
|
|
70
|
+
<%= link_to rails_event_viewer.group_path(value: group[:value], key: @selected_key, source: @source),
|
|
71
71
|
class: "text-indigo-600 hover:text-indigo-900 font-medium" do %>
|
|
72
72
|
<code class="text-sm bg-gray-100 px-2 py-1 rounded"><%= group[:value] %></code>
|
|
73
73
|
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -9,10 +9,11 @@ RailsEventViewer::Engine.routes.draw do
|
|
|
9
9
|
|
|
10
10
|
namespace :analytics do
|
|
11
11
|
get :overview
|
|
12
|
-
get :events_over_time
|
|
13
|
-
get :events_by_type
|
|
14
12
|
end
|
|
15
13
|
|
|
16
|
-
resources :event_types, only:
|
|
17
|
-
|
|
14
|
+
resources :event_types, only: :index
|
|
15
|
+
get "event_type", to: "event_types#show", as: :event_type
|
|
16
|
+
|
|
17
|
+
resources :groups, only: :index
|
|
18
|
+
get "group", to: "groups#show", as: :group
|
|
18
19
|
end
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
class CreateRailsEventViewerEntries < ActiveRecord::Migration[8.
|
|
1
|
+
class CreateRailsEventViewerEntries < ActiveRecord::Migration[8.1]
|
|
2
2
|
def change
|
|
3
3
|
create_table :rails_event_viewer_entries do |t|
|
|
4
4
|
t.string :name, null: false
|
|
5
5
|
|
|
6
6
|
# Use jsonb for PostgreSQL (better performance), json for others (SQLite, MySQL)
|
|
7
7
|
json_type = postgresql? ? :jsonb : :json
|
|
8
|
-
|
|
9
|
-
t.column :
|
|
10
|
-
t.column :
|
|
8
|
+
json_options = mysql? ? {} : { default: {} }
|
|
9
|
+
t.column :payload, json_type, **json_options
|
|
10
|
+
t.column :tags, json_type, **json_options
|
|
11
|
+
t.column :context, json_type, **json_options
|
|
11
12
|
|
|
12
13
|
t.string :source_file
|
|
13
14
|
t.integer :source_line
|
|
@@ -33,4 +34,8 @@ class CreateRailsEventViewerEntries < ActiveRecord::Migration[8.0]
|
|
|
33
34
|
def postgresql?
|
|
34
35
|
connection.adapter_name.downcase.include?("postgresql")
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
def mysql?
|
|
39
|
+
connection.adapter_name.match?(/mysql|trilogy/i)
|
|
40
|
+
end
|
|
36
41
|
end
|
data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb
CHANGED
|
@@ -4,9 +4,10 @@ class CreateRailsEventViewerEntries < ActiveRecord::Migration<%= migration_versi
|
|
|
4
4
|
t.string :name, null: false
|
|
5
5
|
|
|
6
6
|
json_type = postgresql? ? :jsonb : :json
|
|
7
|
-
|
|
8
|
-
t.column :
|
|
9
|
-
t.column :
|
|
7
|
+
json_options = mysql? ? {} : { default: {} }
|
|
8
|
+
t.column :payload, json_type, **json_options
|
|
9
|
+
t.column :tags, json_type, **json_options
|
|
10
|
+
t.column :context, json_type, **json_options
|
|
10
11
|
|
|
11
12
|
t.string :source_file
|
|
12
13
|
t.integer :source_line
|
|
@@ -32,4 +33,8 @@ class CreateRailsEventViewerEntries < ActiveRecord::Migration<%= migration_versi
|
|
|
32
33
|
def postgresql?
|
|
33
34
|
connection.adapter_name.downcase.include?("postgresql")
|
|
34
35
|
end
|
|
36
|
+
|
|
37
|
+
def mysql?
|
|
38
|
+
connection.adapter_name.match?(/mysql|trilogy/i)
|
|
39
|
+
end
|
|
35
40
|
end
|
|
@@ -16,9 +16,9 @@ RailsEventViewer.configure do |config|
|
|
|
16
16
|
<% if storage_adapter == "redis" %>
|
|
17
17
|
|
|
18
18
|
config.adapter_options = {
|
|
19
|
-
#
|
|
20
|
-
# key_prefix: "rails_event_viewer",
|
|
21
|
-
# max_events: 10_000
|
|
19
|
+
# redis_options: { url: ENV["REDIS_URL"] }, # Passed to Redis.new
|
|
20
|
+
# key_prefix: "rails_event_viewer", # Key prefix for Redis keys
|
|
21
|
+
# max_events: 10_000 # Maximum events to retain
|
|
22
22
|
}
|
|
23
23
|
<% elsif storage_adapter == "active_record" %>
|
|
24
24
|
|
|
@@ -56,14 +56,12 @@ RailsEventViewer.configure do |config|
|
|
|
56
56
|
# How long to keep events before cleanup (used by retention task)
|
|
57
57
|
# config.retention_period = 7.days
|
|
58
58
|
|
|
59
|
-
# Only capture specific events (empty = capture
|
|
60
|
-
# Note: This only affects events emitted via Rails.event.notify(), NOT internal
|
|
61
|
-
# Rails instrumentation events (sql.active_record, process_action.action_controller, etc.)
|
|
62
|
-
# which use ActiveSupport::Notifications and are not captured by this gem.
|
|
59
|
+
# Only capture specific events (empty = capture every event that is not ignored)
|
|
63
60
|
# config.captured_events = ["user.created", "order.placed"]
|
|
64
61
|
|
|
65
|
-
# Ignore
|
|
66
|
-
#
|
|
62
|
+
# Ignore more events (strings and regex patterns). Use += to keep the defaults,
|
|
63
|
+
# which skip Rails' own events such as action_controller.* and active_record.*
|
|
64
|
+
# config.ignored_events += ["internal.heartbeat", /^debug\./]
|
|
67
65
|
|
|
68
66
|
# Default pagination limit
|
|
69
67
|
# config.per_page = 25
|
|
@@ -38,12 +38,12 @@ module RailsEventViewer
|
|
|
38
38
|
raise NotImplementedError, "#{self.class} must implement #find_event"
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def events_over_time(
|
|
41
|
+
def events_over_time(range:, interval:)
|
|
42
42
|
raise NotImplementedError, "#{self.class} must implement #events_over_time" if supports_analytics?
|
|
43
43
|
{}
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def counts_by_name(limit:)
|
|
46
|
+
def counts_by_name(limit: nil, range: nil)
|
|
47
47
|
raise NotImplementedError, "#{self.class} must implement #counts_by_name" if supports_analytics?
|
|
48
48
|
{}
|
|
49
49
|
end
|
|
@@ -9,7 +9,7 @@ module RailsEventViewer
|
|
|
9
9
|
|
|
10
10
|
def table_exists?
|
|
11
11
|
Entry.table_exists?
|
|
12
|
-
rescue ::ActiveRecord::NoDatabaseError
|
|
12
|
+
rescue ::ActiveRecord::NoDatabaseError
|
|
13
13
|
false
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -57,28 +57,13 @@ module RailsEventViewer
|
|
|
57
57
|
Entry.where("occurred_at < ?", timestamp).in_batches.delete_all
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def events_over_time(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
case interval
|
|
64
|
-
when :minute
|
|
65
|
-
scope.group_by_minute(:occurred_at).count
|
|
66
|
-
when :hour
|
|
67
|
-
scope.group_by_hour(:occurred_at).count
|
|
68
|
-
when :day
|
|
69
|
-
scope.group_by_day(:occurred_at).count
|
|
70
|
-
when :week
|
|
71
|
-
scope.group_by_week(:occurred_at).count
|
|
72
|
-
else
|
|
73
|
-
scope.group_by_hour(:occurred_at).count
|
|
74
|
-
end
|
|
60
|
+
def events_over_time(range:, interval:)
|
|
61
|
+
Entry.where(occurred_at: range).group_by_period(interval, :occurred_at).count
|
|
75
62
|
end
|
|
76
63
|
|
|
77
|
-
def counts_by_name(limit:)
|
|
78
|
-
Entry.
|
|
79
|
-
|
|
80
|
-
.limit(limit)
|
|
81
|
-
.count
|
|
64
|
+
def counts_by_name(limit: nil, range: nil)
|
|
65
|
+
scope = range ? Entry.where(occurred_at: range) : Entry
|
|
66
|
+
scope.group(:name).order(Arel.sql("COUNT(*) DESC")).limit(limit).count
|
|
82
67
|
end
|
|
83
68
|
|
|
84
69
|
def count_since(since)
|
|
@@ -104,12 +89,9 @@ module RailsEventViewer
|
|
|
104
89
|
json_path = JsonQuery.extract_path(column, key)
|
|
105
90
|
|
|
106
91
|
Entry
|
|
107
|
-
.where(JsonQuery.
|
|
92
|
+
.where(JsonQuery.extract_path_present(column, key))
|
|
108
93
|
.distinct
|
|
109
94
|
.pluck(Arel.sql(json_path))
|
|
110
|
-
.compact
|
|
111
|
-
.map { |v| v.is_a?(String) ? v.gsub(/^"|"$/, "") : v.to_s }
|
|
112
|
-
.uniq
|
|
113
95
|
end
|
|
114
96
|
|
|
115
97
|
def group_instances(key, source: :context, limit: 100)
|
|
@@ -123,13 +105,13 @@ module RailsEventViewer
|
|
|
123
105
|
Arel.sql("MIN(occurred_at) as first_event_at"),
|
|
124
106
|
Arel.sql("MAX(occurred_at) as last_event_at")
|
|
125
107
|
)
|
|
126
|
-
.where(JsonQuery.
|
|
108
|
+
.where(JsonQuery.extract_path_present(column, key))
|
|
127
109
|
.group(Arel.sql(json_path))
|
|
128
110
|
.order(Arel.sql("MAX(occurred_at) DESC"))
|
|
129
111
|
.limit(limit)
|
|
130
112
|
.map do |row|
|
|
131
113
|
{
|
|
132
|
-
value:
|
|
114
|
+
value: row.group_value,
|
|
133
115
|
count: row.event_count,
|
|
134
116
|
first_event_at: parse_timestamp(row.first_event_at),
|
|
135
117
|
last_event_at: parse_timestamp(row.last_event_at)
|
|
@@ -142,7 +124,7 @@ module RailsEventViewer
|
|
|
142
124
|
end
|
|
143
125
|
|
|
144
126
|
def event_time_span(relation)
|
|
145
|
-
row = build_scope(relation).pick(
|
|
127
|
+
row = build_scope(relation).unscope(:order).pick(
|
|
146
128
|
Arel.sql("MIN(occurred_at)"),
|
|
147
129
|
Arel.sql("MAX(occurred_at)")
|
|
148
130
|
)
|
|
@@ -161,13 +143,6 @@ module RailsEventViewer
|
|
|
161
143
|
nil
|
|
162
144
|
end
|
|
163
145
|
|
|
164
|
-
def normalize_group_value(value)
|
|
165
|
-
return nil if value.nil?
|
|
166
|
-
return value.to_s if value.is_a?(Numeric)
|
|
167
|
-
|
|
168
|
-
value.to_s.gsub(/^"|"$/, "")
|
|
169
|
-
end
|
|
170
|
-
|
|
171
146
|
def build_scope(relation)
|
|
172
147
|
scope = Entry.order(occurred_at: :desc)
|
|
173
148
|
scope = apply_name_filters(scope, relation)
|
|
@@ -215,10 +190,10 @@ module RailsEventViewer
|
|
|
215
190
|
def apply_search(scope, relation)
|
|
216
191
|
return scope if relation.query.blank?
|
|
217
192
|
|
|
218
|
-
sanitized = "%#{Entry.sanitize_sql_like(relation.query.downcase)}%"
|
|
193
|
+
sanitized = "%#{Entry.sanitize_sql_like(relation.query.downcase, "!")}%"
|
|
219
194
|
text_type = JsonQuery.detect_adapter == :mysql ? "CHAR" : "TEXT"
|
|
220
195
|
scope.where(
|
|
221
|
-
"LOWER(name) LIKE :q ESCAPE '
|
|
196
|
+
"LOWER(name) LIKE :q ESCAPE '!' OR LOWER(CAST(payload AS #{text_type})) LIKE :q ESCAPE '!'",
|
|
222
197
|
q: sanitized
|
|
223
198
|
)
|
|
224
199
|
end
|
|
@@ -15,8 +15,8 @@ module RailsEventViewer
|
|
|
15
15
|
|
|
16
16
|
def group_instances(key, source: :context, limit: 100)
|
|
17
17
|
in_memory_events
|
|
18
|
-
.group_by { |e| group_value_for(e, key, source) }
|
|
19
|
-
.reject { |value, _| value.
|
|
18
|
+
.group_by { |e| group_value_for(e, key, source).to_s }
|
|
19
|
+
.reject { |value, _| value.empty? }
|
|
20
20
|
.map { |value, events| build_group_instance(value, events) }
|
|
21
21
|
.sort_by { |s| -(s[:last_event_at]&.to_f || 0) }
|
|
22
22
|
.take(limit)
|
|
@@ -28,8 +28,27 @@ module RailsEventViewer
|
|
|
28
28
|
[timestamps.min, timestamps.max]
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def events_over_time(range:, interval:)
|
|
32
|
+
events_in(range)
|
|
33
|
+
.group_by { |e| TimeUtils.truncate_to_interval(e[:occurred_at], interval) }
|
|
34
|
+
.transform_values(&:size)
|
|
35
|
+
.sort
|
|
36
|
+
.to_h
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def counts_by_name(limit: nil, range: nil)
|
|
40
|
+
counts = events_in(range).map { |e| e[:name] }.tally.sort_by { |_, count| -count }
|
|
41
|
+
(limit ? counts.first(limit) : counts).to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
31
44
|
private
|
|
32
45
|
|
|
46
|
+
def events_in(range)
|
|
47
|
+
return in_memory_events unless range
|
|
48
|
+
|
|
49
|
+
in_memory_events.select { |e| e[:occurred_at] && range.cover?(e[:occurred_at]) }
|
|
50
|
+
end
|
|
51
|
+
|
|
33
52
|
def group_value_for(event, key, source)
|
|
34
53
|
data = event[source]
|
|
35
54
|
data&.dig(key.to_s) || data&.dig(key.to_sym)
|
|
@@ -96,7 +115,7 @@ module RailsEventViewer
|
|
|
96
115
|
def values_match?(actual, expected)
|
|
97
116
|
return false if actual.nil?
|
|
98
117
|
|
|
99
|
-
actual
|
|
118
|
+
actual.to_s == expected.to_s
|
|
100
119
|
end
|
|
101
120
|
|
|
102
121
|
def filter_by_time_range(events, since_time, until_time)
|
|
@@ -20,7 +20,7 @@ module RailsEventViewer
|
|
|
20
20
|
self.class.events
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def initialize(max_events: 1000
|
|
23
|
+
def initialize(max_events: 1000)
|
|
24
24
|
@max_events = max_events
|
|
25
25
|
end
|
|
26
26
|
|
|
@@ -68,26 +68,6 @@ module RailsEventViewer
|
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
def events_over_time(since:, interval:)
|
|
72
|
-
snapshot = mutex.synchronize { events.dup }
|
|
73
|
-
filtered = snapshot.select { |e| e[:occurred_at] && e[:occurred_at] >= since }
|
|
74
|
-
|
|
75
|
-
grouped = filtered.group_by do |e|
|
|
76
|
-
TimeUtils.truncate_to_interval(e[:occurred_at], interval)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
grouped.transform_values(&:size).sort.to_h
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def counts_by_name(limit:)
|
|
83
|
-
mutex.synchronize { events.dup }
|
|
84
|
-
.group_by { |e| e[:name] }
|
|
85
|
-
.transform_values(&:size)
|
|
86
|
-
.sort_by { |_, count| -count }
|
|
87
|
-
.take(limit)
|
|
88
|
-
.to_h
|
|
89
|
-
end
|
|
90
|
-
|
|
91
71
|
def count_since(since)
|
|
92
72
|
mutex.synchronize { events.count { |e| e[:occurred_at] && e[:occurred_at] >= since } }
|
|
93
73
|
end
|
|
@@ -20,7 +20,7 @@ module RailsEventViewer
|
|
|
20
20
|
|
|
21
21
|
def initialize(redis_options: {}, pool_size: DEFAULT_POOL_SIZE, pool_timeout: DEFAULT_POOL_TIMEOUT,
|
|
22
22
|
pool: build_pool(pool_size, pool_timeout, redis_options),
|
|
23
|
-
key_prefix: DEFAULT_KEY_PREFIX, max_events: DEFAULT_MAX_EVENTS
|
|
23
|
+
key_prefix: DEFAULT_KEY_PREFIX, max_events: DEFAULT_MAX_EVENTS)
|
|
24
24
|
@redis = pool
|
|
25
25
|
@key_prefix = "{#{key_prefix}}"
|
|
26
26
|
@max_events = max_events
|
|
@@ -105,24 +105,6 @@ module RailsEventViewer
|
|
|
105
105
|
end
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
-
def events_over_time(since:, interval:)
|
|
109
|
-
events = fetch_events_since(since)
|
|
110
|
-
|
|
111
|
-
events.group_by do |e|
|
|
112
|
-
TimeUtils.truncate_to_interval(e[:occurred_at], interval)
|
|
113
|
-
end.transform_values(&:size)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def counts_by_name(limit:)
|
|
117
|
-
serialized_events = @redis.with { |conn| conn.zrevrange(events_key, 0, -1) }
|
|
118
|
-
serialized_events
|
|
119
|
-
.map { |json| deserialize_event(json)[:name] }
|
|
120
|
-
.tally
|
|
121
|
-
.sort_by { |_, count| -count }
|
|
122
|
-
.take(limit)
|
|
123
|
-
.to_h
|
|
124
|
-
end
|
|
125
|
-
|
|
126
108
|
def count_since(since)
|
|
127
109
|
score = (since.to_f * 1_000_000).to_i
|
|
128
110
|
@redis.with { |conn| conn.zcount(events_key, score, "+inf") }
|
|
@@ -189,12 +171,6 @@ module RailsEventViewer
|
|
|
189
171
|
data
|
|
190
172
|
end
|
|
191
173
|
|
|
192
|
-
def fetch_events_since(since)
|
|
193
|
-
score = (since.to_f * 1_000_000).to_i
|
|
194
|
-
serialized_events = @redis.with { |conn| conn.zrangebyscore(events_key, score, "+inf") }
|
|
195
|
-
serialized_events.map { |json| deserialize_event(json) }
|
|
196
|
-
end
|
|
197
|
-
|
|
198
174
|
def fetch_all_events
|
|
199
175
|
serialized_events = @redis.with { |conn| conn.zrevrange(events_key, 0, -1) }
|
|
200
176
|
serialized_events.map { |json| deserialize_event(json) }
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
module RailsEventViewer
|
|
2
2
|
module JsonQuery
|
|
3
|
-
INTEGER_PATTERN = /\A-?\d+\z/ # Matches: "123", "-456", "0"
|
|
4
|
-
FLOAT_PATTERN = /\A-?\d+\.?\d*\z/ # Matches: "123.45", "-0.5", "123"
|
|
5
3
|
SAFE_KEY_PATTERN = /\A[\w.-]+\z/
|
|
6
4
|
|
|
7
5
|
module_function
|
|
8
6
|
|
|
9
7
|
def contains(column, key, value = nil)
|
|
8
|
+
return exists(column, key) if value.nil?
|
|
9
|
+
|
|
10
10
|
adapter = detect_adapter
|
|
11
|
+
return fallback_contains(column, key, value) if adapter == :fallback
|
|
12
|
+
|
|
13
|
+
condition = "#{text_at(column, ':path')} = :value"
|
|
14
|
+
binds = { path: path_for(key), value: value.to_s }
|
|
15
|
+
return [condition, binds] unless adapter == :postgresql
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
variants = { as_string: value.to_s, as_scalar: parse_scalar(value) }.compact
|
|
18
|
+
prefilter = variants.keys.map { |name| "#{column} @> :#{name}" }.join(" OR ")
|
|
19
|
+
["(#{prefilter}) AND #{condition}", binds.merge(variants.transform_values { |variant| { key.to_s => variant }.to_json })]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists(column, key)
|
|
23
|
+
case detect_adapter
|
|
13
24
|
when :postgresql
|
|
14
|
-
|
|
25
|
+
["#{column} ? :key", { key: key.to_s }]
|
|
15
26
|
when :mysql
|
|
16
|
-
|
|
27
|
+
["JSON_CONTAINS_PATH(#{column}, 'one', ?)", quoted_path(key)]
|
|
17
28
|
when :sqlite
|
|
18
|
-
|
|
29
|
+
["json_type(#{column}, ?) IS NOT NULL", quoted_path(key)]
|
|
19
30
|
else
|
|
20
|
-
fallback_contains(column, key
|
|
31
|
+
fallback_contains(column, key)
|
|
21
32
|
end
|
|
22
33
|
end
|
|
23
34
|
|
|
@@ -36,76 +47,57 @@ module RailsEventViewer
|
|
|
36
47
|
end
|
|
37
48
|
end
|
|
38
49
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def postgresql_contains(column, key, value = nil)
|
|
42
|
-
if value.present?
|
|
43
|
-
["#{column} @> ?", { key.to_s => value }.to_json]
|
|
44
|
-
else
|
|
45
|
-
["#{column} ? ?", key.to_s]
|
|
46
|
-
end
|
|
47
|
-
end
|
|
50
|
+
def fallback_contains(column, key, value = nil)
|
|
51
|
+
escaped_key = key.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
["JSON_CONTAINS(#{column}, ?, ?)", value.to_json, "$.#{key}"]
|
|
53
|
+
if value.nil?
|
|
54
|
+
["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\"%"]
|
|
52
55
|
else
|
|
53
|
-
["
|
|
56
|
+
["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\":#{value.to_json}%"]
|
|
54
57
|
end
|
|
55
58
|
end
|
|
56
59
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
# SQLite stores JSON values with their original types (integers stay integers)
|
|
60
|
-
# We need to cast the value appropriately for comparison
|
|
61
|
-
normalized_value = normalize_sqlite_value(value)
|
|
62
|
-
["json_extract(#{column}, ?) = ?", "$.#{key}", normalized_value]
|
|
63
|
-
else
|
|
64
|
-
["json_extract(#{column}, ?) IS NOT NULL", "$.#{key}"]
|
|
65
|
-
end
|
|
66
|
-
end
|
|
60
|
+
def extract_path(column, key)
|
|
61
|
+
raise ArgumentError, "Invalid JSON key: #{key.inspect}" unless key.to_s.match?(SAFE_KEY_PATTERN)
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
text_at(column, "'#{path_for(key)}'")
|
|
64
|
+
end
|
|
70
65
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
value.to_f
|
|
75
|
-
else
|
|
76
|
-
value
|
|
77
|
-
end
|
|
66
|
+
def extract_path_present(column, key)
|
|
67
|
+
path = extract_path(column, key)
|
|
68
|
+
"#{path} IS NOT NULL AND #{path} <> ''"
|
|
78
69
|
end
|
|
79
70
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
71
|
+
def text_at(column, path)
|
|
72
|
+
case detect_adapter
|
|
73
|
+
when :postgresql
|
|
74
|
+
"#{column} ->> #{path}"
|
|
75
|
+
when :mysql
|
|
76
|
+
"JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{column}, #{path}), CAST('null' AS JSON))) COLLATE #{mysql_collation}"
|
|
84
77
|
else
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
"CASE json_type(#{column}, #{path}) WHEN 'true' THEN 'true' WHEN 'false' THEN 'false' " \
|
|
79
|
+
"ELSE CAST(json_extract(#{column}, #{path}) AS TEXT) END"
|
|
87
80
|
end
|
|
88
81
|
end
|
|
89
82
|
|
|
90
|
-
def
|
|
91
|
-
|
|
83
|
+
def mysql_collation
|
|
84
|
+
connection = ActiveRecord::Base.connection
|
|
85
|
+
!connection.mariadb? && connection.database_version >= "8.0.17" ? "utf8mb4_0900_bin" : "utf8mb4_bin"
|
|
86
|
+
end
|
|
92
87
|
|
|
93
|
-
|
|
88
|
+
def path_for(key)
|
|
89
|
+
detect_adapter == :postgresql ? key.to_s : quoted_path(key)
|
|
90
|
+
end
|
|
94
91
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"#{column} ->> '#{key}'"
|
|
98
|
-
when :mysql
|
|
99
|
-
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, '$.#{key}'))"
|
|
100
|
-
when :sqlite
|
|
101
|
-
"json_extract(#{column}, '$.#{key}')"
|
|
102
|
-
else
|
|
103
|
-
"json_extract(#{column}, '$.#{key}')"
|
|
104
|
-
end
|
|
92
|
+
def quoted_path(key)
|
|
93
|
+
%($."#{key.to_s.gsub(/["\\]/) { |m| "\\#{m}" }}")
|
|
105
94
|
end
|
|
106
95
|
|
|
107
|
-
def
|
|
108
|
-
|
|
96
|
+
def parse_scalar(value)
|
|
97
|
+
parsed = JSON.parse(value.to_s)
|
|
98
|
+
parsed if parsed.is_a?(Numeric) || parsed == true || parsed == false
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
nil
|
|
109
101
|
end
|
|
110
102
|
end
|
|
111
103
|
end
|
|
@@ -93,8 +93,7 @@ module RailsEventViewer
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
def snapshot(value)
|
|
96
|
-
|
|
97
|
-
json.is_a?(Hash) ? json.deep_symbolize_keys : json
|
|
96
|
+
JSON.parse(JSON.generate(value.as_json), symbolize_names: true)
|
|
98
97
|
rescue SystemStackError, StandardError
|
|
99
98
|
{ value: value.inspect }
|
|
100
99
|
end
|
|
@@ -8,16 +8,9 @@ module RailsEventViewer
|
|
|
8
8
|
return nil unless time
|
|
9
9
|
|
|
10
10
|
case interval
|
|
11
|
-
when :
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
time.beginning_of_hour
|
|
15
|
-
when :day
|
|
16
|
-
time.beginning_of_day
|
|
17
|
-
when :week
|
|
18
|
-
time.beginning_of_week
|
|
19
|
-
else
|
|
20
|
-
time.beginning_of_hour
|
|
11
|
+
when :hour then time.beginning_of_hour
|
|
12
|
+
when :day then time.beginning_of_day
|
|
13
|
+
else raise ArgumentError, "Unknown interval: #{interval.inspect}"
|
|
21
14
|
end
|
|
22
15
|
end
|
|
23
16
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_event_viewer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keshav Biswa
|
|
@@ -95,7 +95,7 @@ files:
|
|
|
95
95
|
- app/views/rails_event_viewer/shared/_pagination.html.erb
|
|
96
96
|
- app/views/rails_event_viewer/shared/_stat_card.html.erb
|
|
97
97
|
- config/routes.rb
|
|
98
|
-
- db/migrate/
|
|
98
|
+
- db/migrate/20261002121337_create_rails_event_viewer_entries.rb
|
|
99
99
|
- lib/generators/rails_event_viewer/install/USAGE
|
|
100
100
|
- lib/generators/rails_event_viewer/install/install_generator.rb
|
|
101
101
|
- lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb
|