rails_event_viewer 0.2.1 → 0.3.1
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 +48 -0
- data/README.md +24 -1
- data/Rakefile +2 -0
- data/app/assets/javascripts/rails_event_viewer/application.js +2 -1
- data/app/assets/stylesheets/rails_event_viewer/application.css +1 -1
- data/app/controllers/rails_event_viewer/analytics_controller.rb +5 -30
- data/app/controllers/rails_event_viewer/application_controller.rb +11 -5
- 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/events_controller.rb +10 -23
- data/app/controllers/rails_event_viewer/groups_controller.rb +4 -11
- data/app/helpers/rails_event_viewer/application_helper.rb +2 -2
- data/app/views/layouts/rails_event_viewer/application.html.erb +8 -0
- data/app/views/rails_event_viewer/analytics/overview.html.erb +2 -0
- data/app/views/rails_event_viewer/dashboard/index.html.erb +4 -3
- data/app/views/rails_event_viewer/event_types/index.html.erb +1 -1
- data/app/views/rails_event_viewer/event_types/show.html.erb +5 -3
- data/app/views/rails_event_viewer/events/_event_row.html.erb +3 -3
- data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +2 -1
- data/app/views/rails_event_viewer/events/index.html.erb +4 -4
- data/app/views/rails_event_viewer/events/show.html.erb +7 -7
- data/app/views/rails_event_viewer/groups/index.html.erb +2 -2
- data/app/views/rails_event_viewer/groups/show.html.erb +1 -5
- data/app/views/rails_event_viewer/shared/_navigation.html.erb +7 -5
- data/config/routes.rb +6 -9
- 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 +15 -36
- data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +29 -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 +4 -28
- data/lib/rails_event_viewer/batch_writer.rb +2 -1
- data/lib/rails_event_viewer/events_relation.rb +1 -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
- data/lib/tasks/rails_event_viewer_tasks.rake +1 -1
- metadata +2 -2
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
</div>
|
|
50
50
|
</div>
|
|
51
51
|
<% elsif @groups.any? %>
|
|
52
|
-
<div class="bg-white shadow rounded-lg overflow-
|
|
52
|
+
<div class="bg-white shadow rounded-lg overflow-x-auto">
|
|
53
53
|
<table class="min-w-full divide-y divide-gray-200">
|
|
54
54
|
<thead class="bg-gray-50">
|
|
55
55
|
<tr>
|
|
@@ -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 %>
|
|
@@ -19,15 +19,11 @@
|
|
|
19
19
|
</div>
|
|
20
20
|
|
|
21
21
|
<!-- Stats -->
|
|
22
|
-
<div class="grid grid-cols-1 gap-5 sm:grid-cols-
|
|
22
|
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
|
23
23
|
<%= render "rails_event_viewer/shared/stat_card",
|
|
24
24
|
title: "Total Events",
|
|
25
25
|
value: number_with_delimiter(@total_count),
|
|
26
26
|
icon: "database" %>
|
|
27
|
-
<%= render "rails_event_viewer/shared/stat_card",
|
|
28
|
-
title: "Event Types",
|
|
29
|
-
value: @event_names.size,
|
|
30
|
-
icon: "tag" %>
|
|
31
27
|
<%= render "rails_event_viewer/shared/stat_card",
|
|
32
28
|
title: "Duration",
|
|
33
29
|
value: @duration ? format_duration(@duration) : "-",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<nav class="bg-indigo-600">
|
|
2
2
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
3
|
-
<div class="flex h-16 items-center justify-between">
|
|
3
|
+
<div class="flex min-h-16 flex-wrap items-center justify-between gap-y-2 py-2">
|
|
4
4
|
<div class="flex items-center">
|
|
5
5
|
<div class="flex-shrink-0">
|
|
6
6
|
<%= link_to rails_event_viewer.root_path, class: "text-white text-xl font-bold" do %>
|
|
7
7
|
Event Viewer
|
|
8
8
|
<% end %>
|
|
9
9
|
</div>
|
|
10
|
-
<div class="ml-10 flex items-baseline space-x-4">
|
|
10
|
+
<div class="ml-10 flex flex-wrap items-baseline space-x-4">
|
|
11
11
|
<%= link_to "Dashboard", rails_event_viewer.root_path,
|
|
12
12
|
class: nav_link_class("dashboard") %>
|
|
13
13
|
<%= link_to "Events", rails_event_viewer.events_path,
|
|
@@ -16,14 +16,16 @@
|
|
|
16
16
|
class: nav_link_class("event_types") %>
|
|
17
17
|
<%= link_to "Groups", rails_event_viewer.groups_path,
|
|
18
18
|
class: nav_link_class("groups") %>
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
<% if analytics_supported? %>
|
|
20
|
+
<%= link_to "Analytics", rails_event_viewer.analytics_overview_path,
|
|
21
|
+
class: nav_link_class("analytics") %>
|
|
22
|
+
<% end %>
|
|
21
23
|
</div>
|
|
22
24
|
</div>
|
|
23
25
|
|
|
24
26
|
<div class="flex items-center space-x-4">
|
|
25
27
|
<!-- Search -->
|
|
26
|
-
<%= form_with url: rails_event_viewer.
|
|
28
|
+
<%= form_with url: rails_event_viewer.events_path, method: :get, class: "relative" do |f| %>
|
|
27
29
|
<div class="relative">
|
|
28
30
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
29
31
|
<svg class="h-4 w-4 text-indigo-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
data/config/routes.rb
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
RailsEventViewer::Engine.routes.draw do
|
|
2
2
|
root to: "dashboard#index"
|
|
3
3
|
|
|
4
|
-
resources :events, only: [:index, :show]
|
|
5
|
-
collection do
|
|
6
|
-
get :search
|
|
7
|
-
end
|
|
8
|
-
end
|
|
4
|
+
resources :events, only: [:index, :show]
|
|
9
5
|
|
|
10
6
|
namespace :analytics do
|
|
11
7
|
get :overview
|
|
12
|
-
get :events_over_time
|
|
13
|
-
get :events_by_type
|
|
14
8
|
end
|
|
15
9
|
|
|
16
|
-
resources :event_types, only:
|
|
17
|
-
|
|
10
|
+
resources :event_types, only: :index
|
|
11
|
+
get "event_type", to: "event_types#show", as: :event_type
|
|
12
|
+
|
|
13
|
+
resources :groups, only: :index
|
|
14
|
+
get "group", to: "groups#show", as: :group
|
|
18
15
|
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,17 @@ module RailsEventViewer
|
|
|
57
57
|
Entry.where("occurred_at < ?", timestamp).in_batches.delete_all
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def
|
|
61
|
-
|
|
60
|
+
def clear!
|
|
61
|
+
Entry.in_batches.delete_all
|
|
62
|
+
end
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
64
|
+
def events_over_time(range:, interval:)
|
|
65
|
+
Entry.where(occurred_at: range).group_by_period(interval, :occurred_at).count
|
|
75
66
|
end
|
|
76
67
|
|
|
77
|
-
def counts_by_name(limit:)
|
|
78
|
-
Entry.
|
|
79
|
-
|
|
80
|
-
.limit(limit)
|
|
81
|
-
.count
|
|
68
|
+
def counts_by_name(limit: nil, range: nil)
|
|
69
|
+
scope = range ? Entry.where(occurred_at: range) : Entry
|
|
70
|
+
scope.group(:name).order(Arel.sql("COUNT(*) DESC")).limit(limit).count
|
|
82
71
|
end
|
|
83
72
|
|
|
84
73
|
def count_since(since)
|
|
@@ -104,12 +93,9 @@ module RailsEventViewer
|
|
|
104
93
|
json_path = JsonQuery.extract_path(column, key)
|
|
105
94
|
|
|
106
95
|
Entry
|
|
107
|
-
.where(JsonQuery.
|
|
96
|
+
.where(JsonQuery.extract_path_present(column, key))
|
|
108
97
|
.distinct
|
|
109
98
|
.pluck(Arel.sql(json_path))
|
|
110
|
-
.compact
|
|
111
|
-
.map { |v| v.is_a?(String) ? v.gsub(/^"|"$/, "") : v.to_s }
|
|
112
|
-
.uniq
|
|
113
99
|
end
|
|
114
100
|
|
|
115
101
|
def group_instances(key, source: :context, limit: 100)
|
|
@@ -123,13 +109,13 @@ module RailsEventViewer
|
|
|
123
109
|
Arel.sql("MIN(occurred_at) as first_event_at"),
|
|
124
110
|
Arel.sql("MAX(occurred_at) as last_event_at")
|
|
125
111
|
)
|
|
126
|
-
.where(JsonQuery.
|
|
112
|
+
.where(JsonQuery.extract_path_present(column, key))
|
|
127
113
|
.group(Arel.sql(json_path))
|
|
128
114
|
.order(Arel.sql("MAX(occurred_at) DESC"))
|
|
129
115
|
.limit(limit)
|
|
130
116
|
.map do |row|
|
|
131
117
|
{
|
|
132
|
-
value:
|
|
118
|
+
value: row.group_value,
|
|
133
119
|
count: row.event_count,
|
|
134
120
|
first_event_at: parse_timestamp(row.first_event_at),
|
|
135
121
|
last_event_at: parse_timestamp(row.last_event_at)
|
|
@@ -142,7 +128,7 @@ module RailsEventViewer
|
|
|
142
128
|
end
|
|
143
129
|
|
|
144
130
|
def event_time_span(relation)
|
|
145
|
-
row = build_scope(relation).pick(
|
|
131
|
+
row = build_scope(relation).unscope(:order).pick(
|
|
146
132
|
Arel.sql("MIN(occurred_at)"),
|
|
147
133
|
Arel.sql("MAX(occurred_at)")
|
|
148
134
|
)
|
|
@@ -161,13 +147,6 @@ module RailsEventViewer
|
|
|
161
147
|
nil
|
|
162
148
|
end
|
|
163
149
|
|
|
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
150
|
def build_scope(relation)
|
|
172
151
|
scope = Entry.order(occurred_at: :desc)
|
|
173
152
|
scope = apply_name_filters(scope, relation)
|
|
@@ -215,10 +194,10 @@ module RailsEventViewer
|
|
|
215
194
|
def apply_search(scope, relation)
|
|
216
195
|
return scope if relation.query.blank?
|
|
217
196
|
|
|
218
|
-
sanitized = "%#{Entry.sanitize_sql_like(relation.query.downcase)}%"
|
|
197
|
+
sanitized = "%#{Entry.sanitize_sql_like(relation.query.downcase, "!")}%"
|
|
219
198
|
text_type = JsonQuery.detect_adapter == :mysql ? "CHAR" : "TEXT"
|
|
220
199
|
scope.where(
|
|
221
|
-
"LOWER(name) LIKE :q ESCAPE '
|
|
200
|
+
"LOWER(name) LIKE :q ESCAPE '!' OR LOWER(CAST(payload AS #{text_type})) LIKE :q ESCAPE '!'",
|
|
222
201
|
q: sanitized
|
|
223
202
|
)
|
|
224
203
|
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,34 @@ 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
|
+
|
|
44
|
+
def event_type_statistics
|
|
45
|
+
statistics = in_memory_events.group_by { |e| e[:name] }.map do |name, events|
|
|
46
|
+
{ name: name, count: events.size, last_event_at: events.filter_map { |e| e[:occurred_at] }.max }
|
|
47
|
+
end
|
|
48
|
+
statistics.sort_by { |statistic| -statistic[:count] }
|
|
49
|
+
end
|
|
50
|
+
|
|
31
51
|
private
|
|
32
52
|
|
|
53
|
+
def events_in(range)
|
|
54
|
+
return in_memory_events unless range
|
|
55
|
+
|
|
56
|
+
in_memory_events.select { |e| e[:occurred_at] && range.cover?(e[:occurred_at]) }
|
|
57
|
+
end
|
|
58
|
+
|
|
33
59
|
def group_value_for(event, key, source)
|
|
34
60
|
data = event[source]
|
|
35
61
|
data&.dig(key.to_s) || data&.dig(key.to_sym)
|
|
@@ -96,7 +122,7 @@ module RailsEventViewer
|
|
|
96
122
|
def values_match?(actual, expected)
|
|
97
123
|
return false if actual.nil?
|
|
98
124
|
|
|
99
|
-
actual
|
|
125
|
+
actual.to_s == expected.to_s
|
|
100
126
|
end
|
|
101
127
|
|
|
102
128
|
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
|
|
@@ -99,30 +99,12 @@ module RailsEventViewer
|
|
|
99
99
|
def delete_before(timestamp)
|
|
100
100
|
score = (timestamp.to_f * 1_000_000).to_i
|
|
101
101
|
@redis.with do |conn|
|
|
102
|
-
deleted = conn.zremrangebyscore(events_key, "-inf", score)
|
|
102
|
+
deleted = conn.zremrangebyscore(events_key, "-inf", "(#{score}")
|
|
103
103
|
prune_names(conn)
|
|
104
104
|
deleted
|
|
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") }
|
|
@@ -173,8 +155,8 @@ module RailsEventViewer
|
|
|
173
155
|
|
|
174
156
|
def serialize_event(event, timestamp_usec = nil)
|
|
175
157
|
timestamp_usec ||= (event[:occurred_at].to_f * 1_000_000).to_i
|
|
176
|
-
|
|
177
|
-
JSON.generate(
|
|
158
|
+
stored = event.merge(id: "#{timestamp_usec}-#{SecureRandom.hex(8)}", occurred_at: event[:occurred_at]&.iso8601(6))
|
|
159
|
+
JSON.generate(stored)
|
|
178
160
|
end
|
|
179
161
|
|
|
180
162
|
def deserialize_event(json)
|
|
@@ -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) }
|
|
@@ -18,7 +18,7 @@ module RailsEventViewer
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def with_name(*event_names)
|
|
21
|
-
event_names = event_names.flatten.compact
|
|
21
|
+
event_names = event_names.flatten.compact.map(&:to_s)
|
|
22
22
|
return self if event_names.empty?
|
|
23
23
|
|
|
24
24
|
clone_with { |rel| rel.names.concat(event_names) }
|
|
@@ -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
|