rails_event_viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +451 -0
  5. data/Rakefile +16 -0
  6. data/app/assets/stylesheets/rails_event_viewer/application.css +32 -0
  7. data/app/assets/stylesheets/rails_event_viewer/tailwind.css +54 -0
  8. data/app/controllers/rails_event_viewer/analytics_controller.rb +78 -0
  9. data/app/controllers/rails_event_viewer/application_controller.rb +84 -0
  10. data/app/controllers/rails_event_viewer/dashboard_controller.rb +18 -0
  11. data/app/controllers/rails_event_viewer/event_types_controller.rb +16 -0
  12. data/app/controllers/rails_event_viewer/events_controller.rb +72 -0
  13. data/app/controllers/rails_event_viewer/groups_controller.rb +52 -0
  14. data/app/helpers/rails_event_viewer/application_helper.rb +163 -0
  15. data/app/models/rails_event_viewer/application_record.rb +5 -0
  16. data/app/models/rails_event_viewer/entry.rb +34 -0
  17. data/app/views/layouts/rails_event_viewer/application.html.erb +45 -0
  18. data/app/views/rails_event_viewer/analytics/overview.html.erb +134 -0
  19. data/app/views/rails_event_viewer/dashboard/index.html.erb +113 -0
  20. data/app/views/rails_event_viewer/event_types/index.html.erb +59 -0
  21. data/app/views/rails_event_viewer/event_types/show.html.erb +102 -0
  22. data/app/views/rails_event_viewer/events/_event_row.html.erb +42 -0
  23. data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +66 -0
  24. data/app/views/rails_event_viewer/events/index.html.erb +65 -0
  25. data/app/views/rails_event_viewer/events/show.html.erb +155 -0
  26. data/app/views/rails_event_viewer/groups/index.html.erb +115 -0
  27. data/app/views/rails_event_viewer/groups/show.html.erb +134 -0
  28. data/app/views/rails_event_viewer/shared/_navigation.html.erb +47 -0
  29. data/app/views/rails_event_viewer/shared/_pagination.html.erb +60 -0
  30. data/app/views/rails_event_viewer/shared/_stat_card.html.erb +36 -0
  31. data/config/routes.rb +18 -0
  32. data/db/migrate/20241208000001_create_rails_event_viewer_entries.rb +36 -0
  33. data/lib/generators/rails_event_viewer/install/USAGE +33 -0
  34. data/lib/generators/rails_event_viewer/install/install_generator.rb +86 -0
  35. data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +35 -0
  36. data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +90 -0
  37. data/lib/rails_event_viewer/adapter.rb +93 -0
  38. data/lib/rails_event_viewer/adapters/active_record.rb +227 -0
  39. data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +136 -0
  40. data/lib/rails_event_viewer/adapters/memory.rb +128 -0
  41. data/lib/rails_event_viewer/adapters/null.rb +63 -0
  42. data/lib/rails_event_viewer/adapters/redis.rb +213 -0
  43. data/lib/rails_event_viewer/engine.rb +24 -0
  44. data/lib/rails_event_viewer/events_relation.rb +130 -0
  45. data/lib/rails_event_viewer/json_query.rb +111 -0
  46. data/lib/rails_event_viewer/pagy_countable.rb +31 -0
  47. data/lib/rails_event_viewer/subscriber.rb +255 -0
  48. data/lib/rails_event_viewer/time_utils.rb +24 -0
  49. data/lib/rails_event_viewer/version.rb +3 -0
  50. data/lib/rails_event_viewer.rb +122 -0
  51. data/lib/tasks/rails_event_viewer_tasks.rake +81 -0
  52. metadata +151 -0
@@ -0,0 +1,63 @@
1
+ module RailsEventViewer
2
+ module Adapters
3
+ class Null
4
+ include Adapter
5
+
6
+ def supports_persistence?
7
+ false
8
+ end
9
+
10
+ def supports_analytics?
11
+ false
12
+ end
13
+
14
+ def supports_retention?
15
+ false
16
+ end
17
+
18
+ def write_events(events)
19
+ nil
20
+ end
21
+
22
+ def fetch_events(relation)
23
+ []
24
+ end
25
+
26
+ def count_events(relation)
27
+ 0
28
+ end
29
+
30
+ def distinct_event_names
31
+ []
32
+ end
33
+
34
+ def find_event(id)
35
+ nil
36
+ end
37
+
38
+ def delete_before(timestamp)
39
+ 0
40
+ end
41
+
42
+ def events_over_time(since:, interval:)
43
+ {}
44
+ end
45
+
46
+ def counts_by_name(limit:)
47
+ {}
48
+ end
49
+
50
+ def count_since(since)
51
+ 0
52
+ end
53
+
54
+ def distinct_group_values(key, source: :context)
55
+ []
56
+ end
57
+
58
+ def group_instances(key, source: :context, limit: 100)
59
+ []
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "concerns/in_memory_filtering"
4
+
5
+ module RailsEventViewer
6
+ module Adapters
7
+ class Redis
8
+ include Adapter
9
+ include Concerns::InMemoryFiltering
10
+
11
+ DEFAULT_KEY_PREFIX = "rails_event_viewer"
12
+ DEFAULT_MAX_EVENTS = 10_000
13
+ DEFAULT_POOL_SIZE = 5
14
+ DEFAULT_POOL_TIMEOUT = 5
15
+ NAME_BATCH_TTL_SECONDS = 60
16
+
17
+ # Matches event IDs in format: microsecond_timestamp-hex
18
+ # Example: "1702089600123456-a1b2c3d4e5f6g7h8"
19
+ EVENT_ID_PATTERN = /^(\d+)-([a-f0-9]+)$/i
20
+
21
+ def initialize(redis_options: {}, pool_size: DEFAULT_POOL_SIZE, pool_timeout: DEFAULT_POOL_TIMEOUT,
22
+ pool: build_pool(pool_size, pool_timeout, redis_options),
23
+ key_prefix: DEFAULT_KEY_PREFIX, max_events: DEFAULT_MAX_EVENTS, **)
24
+ @redis = pool
25
+ @key_prefix = "{#{key_prefix}}"
26
+ @max_events = max_events
27
+ end
28
+
29
+ def supports_analytics?
30
+ true
31
+ end
32
+
33
+ def write_events(events)
34
+ return if events.empty?
35
+
36
+ newest_by_name = Hash.new(0)
37
+ batch_key = "#{@key_prefix}:name_batch:#{SecureRandom.hex(8)}"
38
+
39
+ @redis.with do |redis|
40
+ results = redis.pipelined do |pipe|
41
+ events.each do |event|
42
+ timestamp_usec = (event[:occurred_at].to_f * 1_000_000).to_i
43
+ pipe.zadd(events_key, timestamp_usec, serialize_event(event, timestamp_usec))
44
+ name = event[:name].to_s
45
+ newest_by_name[name] = timestamp_usec if timestamp_usec > newest_by_name[name]
46
+ end
47
+ pipe.zadd(batch_key, newest_by_name.map { |name, score| [score, name] })
48
+ pipe.expire(batch_key, NAME_BATCH_TTL_SECONDS)
49
+ pipe.zunionstore(names_key, [names_key, batch_key], aggregate: "max")
50
+ pipe.del(batch_key)
51
+ pipe.zremrangebyrank(events_key, 0, -@max_events - 1)
52
+ end
53
+
54
+ prune_names(redis) if results.last.to_i.positive?
55
+ end
56
+ end
57
+
58
+ def fetch_events(relation)
59
+ if relation.filtered?
60
+ return fetch_all_filtered(relation).drop(relation.offset_value).take(relation.limit_value)
61
+ end
62
+
63
+ start_idx = relation.offset_value
64
+ end_idx = start_idx + relation.limit_value - 1
65
+
66
+ @redis.with { |conn| conn.zrevrange(events_key, start_idx, end_idx) }.map { |json| deserialize_event(json) }
67
+ end
68
+
69
+ def count_events(relation)
70
+ if relation.filtered?
71
+ fetch_all_filtered(relation).size
72
+ else
73
+ @redis.with { |conn| conn.zcard(events_key) }
74
+ end
75
+ end
76
+
77
+ def distinct_event_names
78
+ @redis.with { |conn| conn.zrange(names_key, 0, -1).sort }
79
+ end
80
+
81
+ def find_event(id)
82
+ id_str = id.to_s
83
+ match = id_str.match(EVENT_ID_PATTERN)
84
+ return nil unless match
85
+
86
+ timestamp_usec = match[1].to_i
87
+
88
+ serialized_events = @redis.with { |conn| conn.zrangebyscore(events_key, timestamp_usec, timestamp_usec) }
89
+ return nil if serialized_events.empty?
90
+
91
+ serialized_events.each do |json|
92
+ event = deserialize_event(json)
93
+ return event if event[:id] == id_str
94
+ end
95
+
96
+ nil
97
+ end
98
+
99
+ def delete_before(timestamp)
100
+ score = (timestamp.to_f * 1_000_000).to_i
101
+ @redis.with do |conn|
102
+ deleted = conn.zremrangebyscore(events_key, "-inf", score)
103
+ prune_names(conn)
104
+ deleted
105
+ end
106
+ end
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
+ def count_since(since)
127
+ score = (since.to_f * 1_000_000).to_i
128
+ @redis.with { |conn| conn.zcount(events_key, score, "+inf") }
129
+ end
130
+
131
+ def clear!
132
+ @redis.with { |conn| conn.del(events_key, names_key) }
133
+ end
134
+
135
+ private
136
+
137
+ def build_pool(size, timeout, redis_options)
138
+ require "connection_pool"
139
+ ConnectionPool.new(size: size, timeout: timeout) { build_redis(redis_options) }
140
+ rescue LoadError
141
+ raise ArgumentError, "connection_pool gem not loaded. Add `gem 'connection_pool'` to your Gemfile."
142
+ end
143
+
144
+ def build_redis(options)
145
+ if defined?(::Redis)
146
+ ::Redis.new(**options)
147
+ else
148
+ raise ArgumentError, "Redis gem not loaded. Add `gem 'redis'` to your Gemfile."
149
+ end
150
+ end
151
+
152
+ def events_key
153
+ "#{@key_prefix}:events"
154
+ end
155
+
156
+ def names_key
157
+ "#{@key_prefix}:name_index"
158
+ end
159
+
160
+ def prune_names(conn)
161
+ conn.watch(events_key) do
162
+ oldest = conn.zrange(events_key, 0, 0, with_scores: true).first
163
+
164
+ conn.multi do |tx|
165
+ if oldest
166
+ tx.zremrangebyscore(names_key, "-inf", "(#{oldest.last.to_i}")
167
+ else
168
+ tx.del(names_key)
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ def serialize_event(event, timestamp_usec = nil)
175
+ 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)
178
+ end
179
+
180
+ def deserialize_event(json)
181
+ data = JSON.parse(json, symbolize_names: true)
182
+
183
+ if data[:occurred_at].is_a?(String)
184
+ data[:occurred_at] = Time.parse(data[:occurred_at])
185
+ elsif data[:occurred_at].is_a?(Numeric)
186
+ data[:occurred_at] = Time.at(data[:occurred_at])
187
+ end
188
+
189
+ data
190
+ end
191
+
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
+ def fetch_all_events
199
+ serialized_events = @redis.with { |conn| conn.zrevrange(events_key, 0, -1) }
200
+ serialized_events.map { |json| deserialize_event(json) }
201
+ end
202
+
203
+ def fetch_all_filtered(relation)
204
+ events = fetch_all_events
205
+ filter_events(events, relation)
206
+ end
207
+
208
+ def in_memory_events
209
+ fetch_all_events
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,24 @@
1
+ module RailsEventViewer
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsEventViewer
4
+
5
+ initializer "rails_event_viewer.middleware" do |app|
6
+ if app.config.api_only
7
+ config.middleware.use ActionDispatch::Flash
8
+ config.middleware.use Rack::MethodOverride
9
+ end
10
+ end
11
+
12
+ initializer "rails_event_viewer.assets" do |app|
13
+ if app.config.respond_to?(:assets)
14
+ app.config.assets.paths << root.join("app/assets/stylesheets")
15
+ app.config.assets.precompile += %w[rails_event_viewer/application.css]
16
+ end
17
+ end
18
+
19
+ config.after_initialize do
20
+ RailsEventViewer.subscribe!
21
+ at_exit { RailsEventViewer.shutdown! }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,130 @@
1
+ module RailsEventViewer
2
+ class EventsRelation
3
+ include Enumerable
4
+
5
+ attr_reader :names, :tags, :contexts, :query, :since_time, :until_time
6
+ attr_reader :limit_value, :offset_value
7
+
8
+ def initialize(adapter: RailsEventViewer.adapter)
9
+ @adapter = adapter
10
+ @names = []
11
+ @tags = {}
12
+ @contexts = {}
13
+ @query = nil
14
+ @since_time = nil
15
+ @until_time = nil
16
+ @limit_value = RailsEventViewer.per_page
17
+ @offset_value = 0
18
+ end
19
+
20
+ def with_name(*event_names)
21
+ event_names = event_names.flatten.compact
22
+ return self if event_names.empty?
23
+
24
+ clone_with { |rel| rel.names.concat(event_names) }
25
+ end
26
+
27
+ def with_tag(key, value = nil)
28
+ return self if key.blank?
29
+
30
+ clone_with { |rel| rel.tags[key.to_s] = value }
31
+ end
32
+
33
+ def with_context(key, value = nil)
34
+ return self if key.blank?
35
+
36
+ clone_with { |rel| rel.contexts[key.to_s] = value }
37
+ end
38
+
39
+ def search(term)
40
+ return self if term.blank?
41
+
42
+ clone_with { |rel| rel.query = term.to_s }
43
+ end
44
+
45
+ def since(time)
46
+ return self if time.nil?
47
+
48
+ clone_with { |rel| rel.since_time = time }
49
+ end
50
+
51
+ def until(time)
52
+ return self if time.nil?
53
+
54
+ clone_with { |rel| rel.until_time = time }
55
+ end
56
+
57
+ def limit(value)
58
+ clone_with { |rel| rel.limit_value = value.to_i }
59
+ end
60
+
61
+ def offset(value)
62
+ clone_with { |rel| rel.offset_value = value.to_i }
63
+ end
64
+
65
+ def each(&block)
66
+ return to_enum(:each) unless block_given?
67
+
68
+ @adapter.fetch_events(self).each(&block)
69
+ end
70
+
71
+ def count
72
+ @adapter.count_events(self)
73
+ end
74
+
75
+ alias_method :size, :count
76
+ alias_method :length, :count
77
+
78
+ def to_a
79
+ @adapter.fetch_events(self).to_a
80
+ end
81
+
82
+ def any?
83
+ count > 0
84
+ end
85
+
86
+ def empty?
87
+ count == 0
88
+ end
89
+
90
+ alias_method :none?, :empty?
91
+
92
+ def first
93
+ limit(1).to_a.first
94
+ end
95
+
96
+ def last
97
+ return @adapter.fetch_last(self) if @adapter.respond_to?(:fetch_last)
98
+
99
+ n = count
100
+ offset(n - 1).first if n.positive?
101
+ end
102
+
103
+ def filtered?
104
+ names.any? || tags.any? || contexts.any? || query.present? || since_time.present? || until_time.present?
105
+ end
106
+
107
+ def inspect
108
+ "#<#{self.class.name} names=#{names.inspect} tags=#{tags.inspect} " \
109
+ "contexts=#{contexts.inspect} query=#{query.inspect} " \
110
+ "since=#{since_time.inspect} until=#{until_time.inspect} " \
111
+ "limit=#{limit_value} offset=#{offset_value}>"
112
+ end
113
+
114
+ private
115
+
116
+ def clone_with
117
+ dup.tap do |rel|
118
+ rel.instance_variable_set(:@names, @names.dup)
119
+ rel.instance_variable_set(:@tags, @tags.dup)
120
+ rel.instance_variable_set(:@contexts, @contexts.dup)
121
+ yield rel if block_given?
122
+ end
123
+ end
124
+
125
+ protected
126
+
127
+ attr_writer :names, :tags, :contexts, :query, :since_time, :until_time
128
+ attr_writer :limit_value, :offset_value
129
+ end
130
+ end
@@ -0,0 +1,111 @@
1
+ module RailsEventViewer
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
+ SAFE_KEY_PATTERN = /\A[\w.-]+\z/
6
+
7
+ module_function
8
+
9
+ def contains(column, key, value = nil)
10
+ adapter = detect_adapter
11
+
12
+ case adapter
13
+ when :postgresql
14
+ postgresql_contains(column, key, value)
15
+ when :mysql
16
+ mysql_contains(column, key, value)
17
+ when :sqlite
18
+ sqlite_contains(column, key, value)
19
+ else
20
+ fallback_contains(column, key, value)
21
+ end
22
+ end
23
+
24
+ def detect_adapter
25
+ adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
26
+
27
+ case adapter_name
28
+ when /postgresql/, /postgis/
29
+ :postgresql
30
+ when /mysql/, /trilogy/
31
+ :mysql
32
+ when /sqlite/
33
+ :sqlite
34
+ else
35
+ :fallback
36
+ end
37
+ end
38
+
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
48
+
49
+ def mysql_contains(column, key, value = nil)
50
+ if value.present?
51
+ ["JSON_CONTAINS(#{column}, ?, ?)", value.to_json, "$.#{key}"]
52
+ else
53
+ ["JSON_CONTAINS_PATH(#{column}, 'one', ?)", "$.#{key}"]
54
+ end
55
+ end
56
+
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
67
+
68
+ def normalize_sqlite_value(value)
69
+ return value unless value.is_a?(String)
70
+
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
78
+ end
79
+
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}%"]
84
+ else
85
+ escaped_key = key.to_s.gsub(/[%_\\]/) { |m| "\\#{m}" }
86
+ ["CAST(#{column} AS TEXT) LIKE ?", "%\"#{escaped_key}\"%"]
87
+ end
88
+ end
89
+
90
+ def extract_path(column, key)
91
+ raise ArgumentError, "Invalid JSON key: #{key.inspect}" unless key.to_s.match?(SAFE_KEY_PATTERN)
92
+
93
+ adapter = detect_adapter
94
+
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
105
+ end
106
+
107
+ def extract_path_not_null(column, key)
108
+ "#{extract_path(column, key)} IS NOT NULL"
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,31 @@
1
+ module RailsEventViewer
2
+ class PagyCountable
3
+ def initialize(relation)
4
+ @relation = relation
5
+ end
6
+
7
+ def count(*)
8
+ @relation.count
9
+ end
10
+
11
+ def offset(value)
12
+ OffsetWrapper.new(@relation, value)
13
+ end
14
+
15
+ # Intermediate wrapper that captures offset and applies limit
16
+ class OffsetWrapper
17
+ def initialize(relation, offset_value)
18
+ @relation = relation
19
+ @offset_value = offset_value
20
+ end
21
+
22
+ def limit(value)
23
+ @relation.offset(@offset_value).limit(value).to_a
24
+ end
25
+
26
+ def to_a
27
+ @relation.offset(@offset_value).to_a
28
+ end
29
+ end
30
+ end
31
+ end