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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d1fae32fbd34187f3531ab552271d62005f6c8877bd814ca08207053d349178
4
- data.tar.gz: 05b561609b0ad4d7d7065e61e0a67891a8d9b4f2b970366869097a38a4d17293
3
+ metadata.gz: c9535859f9ef02bb389bd7b17f4d9c34329c199c4af3cf6e97effeaa5ad27b52
4
+ data.tar.gz: 404478f207dae7bcfc10152b3832bb6a9200d4ec1e36c9a1a36ce1cd2ac4ff0c
5
5
  SHA512:
6
- metadata.gz: 38ee32bc44c4fd20adf78b42e8d9110c1328615fe0072bfe00325d9c76fc7eba4ed3f909a98cabf0e6c345b4f8fb64cca6802255c5c7ab3721731bef3bfc91ee
7
- data.tar.gz: 0040bd1d2ce832bc80e35cac7248bc6461375f703f3ae299913264b43955d07e2b8a947bb0b9b349128f0aa45d352a5e5cc4b52401554943141e3fe5eec06962
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
- cd test/dummy && bin/rails db:migrate && bin/rails server
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
@@ -13,4 +13,6 @@ Rake::TestTask.new(:test) do |t|
13
13
  t.verbose = false
14
14
  end
15
15
 
16
+ task test: "app:db:prepare"
17
+
16
18
  task default: :test
@@ -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
- 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)
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(since: 24.hours.ago, interval: :hour)
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 = params[: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 = params[:key]
21
- @value = params[:id]
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: [:index, :show], param: :name, constraints: { name: %r{[^/]+} }
17
- resources :groups, only: [:index, :show], constraints: { id: %r{[^/]+} }
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.0]
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
- t.column :payload, json_type, default: {}
9
- t.column :tags, json_type, default: {}
10
- t.column :context, json_type, default: {}
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
@@ -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
- t.column :payload, json_type, default: {}
8
- t.column :tags, json_type, default: {}
9
- t.column :context, json_type, default: {}
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
- # redis: Redis.new(url: ENV["REDIS_URL"]), # Your Redis instance
20
- # key_prefix: "rails_event_viewer", # Key prefix for Redis keys
21
- # max_events: 10_000 # Maximum events to retain
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 all Rails.event.notify() events)
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 specific events (supports strings and regex patterns)
66
- # config.ignored_events = ["internal.heartbeat", /^debug\./]
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(since:, interval:)
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, ::ActiveRecord::StatementInvalid
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(since:, interval:)
61
- scope = Entry.where("occurred_at >= ?", since)
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.group(:name)
79
- .order(Arel.sql("COUNT(*) DESC"))
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.extract_path_not_null(column, key))
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.extract_path_not_null(column, key))
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: normalize_group_value(row.group_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 '\\' OR LOWER(CAST(payload AS #{text_type})) 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.nil? }
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 == expected || actual.to_s == expected.to_s
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
@@ -39,11 +39,11 @@ module RailsEventViewer
39
39
  0
40
40
  end
41
41
 
42
- def events_over_time(since:, interval:)
42
+ def events_over_time(range:, interval:)
43
43
  {}
44
44
  end
45
45
 
46
- def counts_by_name(limit:)
46
+ def counts_by_name(limit: nil, range: nil)
47
47
  {}
48
48
  end
49
49
 
@@ -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) }
@@ -47,7 +47,8 @@ module RailsEventViewer
47
47
  end
48
48
 
49
49
  if written.empty?
50
- @buffer.revert(events)
50
+ untried = events.drop(rejected.size)
51
+ @buffer.revert(untried + rejected)
51
52
  return false
52
53
  end
53
54
 
@@ -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
- case adapter
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
- postgresql_contains(column, key, value)
25
+ ["#{column} ? :key", { key: key.to_s }]
15
26
  when :mysql
16
- mysql_contains(column, key, value)
27
+ ["JSON_CONTAINS_PATH(#{column}, 'one', ?)", quoted_path(key)]
17
28
  when :sqlite
18
- sqlite_contains(column, key, value)
29
+ ["json_type(#{column}, ?) IS NOT NULL", quoted_path(key)]
19
30
  else
20
- fallback_contains(column, key, value)
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
- # PostgreSQL: Uses @> operator for "contains"
40
- # Example: tags @> '{"env": "production"}'
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
- def mysql_contains(column, key, value = nil)
50
- if value.present?
51
- ["JSON_CONTAINS(#{column}, ?, ?)", value.to_json, "$.#{key}"]
53
+ if value.nil?
54
+ ["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\"%"]
52
55
  else
53
- ["JSON_CONTAINS_PATH(#{column}, 'one', ?)", "$.#{key}"]
56
+ ["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\":#{value.to_json}%"]
54
57
  end
55
58
  end
56
59
 
57
- def sqlite_contains(column, key, value = nil)
58
- if value.present?
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
- def normalize_sqlite_value(value)
69
- return value unless value.is_a?(String)
63
+ text_at(column, "'#{path_for(key)}'")
64
+ end
70
65
 
71
- if value.match?(INTEGER_PATTERN)
72
- value.to_i
73
- elsif value.match?(FLOAT_PATTERN)
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 fallback_contains(column, key, value = nil)
81
- if value.present?
82
- escaped_key = key.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
83
- ["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\":#{value.to_json}%"]
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
- escaped_key = key.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
86
- ["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\"%"]
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 extract_path(column, key)
91
- raise ArgumentError, "Invalid JSON key: #{key.inspect}" unless key.to_s.match?(SAFE_KEY_PATTERN)
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
- adapter = detect_adapter
88
+ def path_for(key)
89
+ detect_adapter == :postgresql ? key.to_s : quoted_path(key)
90
+ end
94
91
 
95
- case adapter
96
- when :postgresql
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 extract_path_not_null(column, key)
108
- "#{extract_path(column, key)} IS NOT NULL"
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
- json = value.as_json
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 :minute
12
- time.beginning_of_minute
13
- when :hour
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
@@ -1,3 +1,3 @@
1
1
  module RailsEventViewer
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  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.2.1
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/20241208000001_create_rails_event_viewer_entries.rb
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