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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +24 -1
  4. data/Rakefile +2 -0
  5. data/app/assets/javascripts/rails_event_viewer/application.js +2 -1
  6. data/app/assets/stylesheets/rails_event_viewer/application.css +1 -1
  7. data/app/controllers/rails_event_viewer/analytics_controller.rb +5 -30
  8. data/app/controllers/rails_event_viewer/application_controller.rb +11 -5
  9. data/app/controllers/rails_event_viewer/dashboard_controller.rb +1 -1
  10. data/app/controllers/rails_event_viewer/event_types_controller.rb +3 -1
  11. data/app/controllers/rails_event_viewer/events_controller.rb +10 -23
  12. data/app/controllers/rails_event_viewer/groups_controller.rb +4 -11
  13. data/app/helpers/rails_event_viewer/application_helper.rb +2 -2
  14. data/app/views/layouts/rails_event_viewer/application.html.erb +8 -0
  15. data/app/views/rails_event_viewer/analytics/overview.html.erb +2 -0
  16. data/app/views/rails_event_viewer/dashboard/index.html.erb +4 -3
  17. data/app/views/rails_event_viewer/event_types/index.html.erb +1 -1
  18. data/app/views/rails_event_viewer/event_types/show.html.erb +5 -3
  19. data/app/views/rails_event_viewer/events/_event_row.html.erb +3 -3
  20. data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +2 -1
  21. data/app/views/rails_event_viewer/events/index.html.erb +4 -4
  22. data/app/views/rails_event_viewer/events/show.html.erb +7 -7
  23. data/app/views/rails_event_viewer/groups/index.html.erb +2 -2
  24. data/app/views/rails_event_viewer/groups/show.html.erb +1 -5
  25. data/app/views/rails_event_viewer/shared/_navigation.html.erb +7 -5
  26. data/config/routes.rb +6 -9
  27. data/db/migrate/{20241208000001_create_rails_event_viewer_entries.rb → 20261002121337_create_rails_event_viewer_entries.rb} +9 -4
  28. data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +8 -3
  29. data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +7 -9
  30. data/lib/rails_event_viewer/adapter.rb +2 -2
  31. data/lib/rails_event_viewer/adapters/active_record.rb +15 -36
  32. data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +29 -3
  33. data/lib/rails_event_viewer/adapters/memory.rb +1 -21
  34. data/lib/rails_event_viewer/adapters/null.rb +2 -2
  35. data/lib/rails_event_viewer/adapters/redis.rb +4 -28
  36. data/lib/rails_event_viewer/batch_writer.rb +2 -1
  37. data/lib/rails_event_viewer/events_relation.rb +1 -1
  38. data/lib/rails_event_viewer/json_query.rb +52 -60
  39. data/lib/rails_event_viewer/subscriber.rb +1 -2
  40. data/lib/rails_event_viewer/time_utils.rb +3 -10
  41. data/lib/rails_event_viewer/version.rb +1 -1
  42. data/lib/tasks/rails_event_viewer_tasks.rake +1 -1
  43. 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-hidden">
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-4">
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
- <%= link_to "Analytics", rails_event_viewer.analytics_overview_path,
20
- class: nav_link_class("analytics") %>
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.search_events_path, method: :get, class: "relative", data: { turbo_frame: "_top" } do |f| %>
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] do
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: [:index, :show], param: :name, constraints: { name: %r{[^/]+} }
17
- resources :groups, only: [:index, :show], constraints: { id: %r{[^/]+} }
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.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,17 @@ 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)
60
+ def clear!
61
+ Entry.in_batches.delete_all
62
+ end
62
63
 
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
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.group(:name)
79
- .order(Arel.sql("COUNT(*) DESC"))
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.extract_path_not_null(column, key))
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.extract_path_not_null(column, key))
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: normalize_group_value(row.group_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 '\\' OR LOWER(CAST(payload AS #{text_type})) 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.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,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 == expected || actual.to_s == expected.to_s
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
@@ -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
@@ -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
- event_with_id = event.merge(id: "#{timestamp_usec}-#{SecureRandom.hex(8)}")
177
- JSON.generate(event_with_id)
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) }
@@ -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
 
@@ -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
- 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