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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsEventViewer.configure do |config|
4
+ # ==========================================================================
5
+ # Storage Configuration
6
+ # ==========================================================================
7
+
8
+ # Choose your storage adapter:
9
+ # :active_record - Stores events in your database (default)
10
+ # :redis - Stores events in Redis (ephemeral, high-volume)
11
+ # :memory - In-memory storage (development/testing only)
12
+ # :null - Discards all events (useful for disabling in production)
13
+ # CustomAdapter - Pass a custom class that includes RailsEventViewer::Adapter
14
+ #
15
+ config.storage_adapter = :<%= storage_adapter %>
16
+ <% if storage_adapter == "redis" %>
17
+
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
22
+ }
23
+ <% elsif storage_adapter == "active_record" %>
24
+
25
+ # ActiveRecord adapter options (none required for default setup)
26
+ # config.adapter_options = {}
27
+ <% end %>
28
+
29
+ # ==========================================================================
30
+ # Performance Configuration
31
+ # ==========================================================================
32
+
33
+ # Enable async buffered writes for better performance
34
+ # When true, events are buffered and flushed in batches
35
+ # config.async = true
36
+
37
+ # Number of events to buffer before auto-flushing
38
+ # config.buffer_size = 100
39
+
40
+ # Flush interval in seconds (flushes even if buffer isn't full)
41
+ # config.flush_interval = 2
42
+
43
+ # Sample rate: 1.0 = capture all events, 0.1 = capture 10% of events
44
+ # Useful for high-volume production environments
45
+ # config.sample_rate = 1.0
46
+
47
+ # ==========================================================================
48
+ # General Configuration
49
+ # ==========================================================================
50
+
51
+ # How long to keep events before cleanup (used by retention task)
52
+ # config.retention_period = 7.days
53
+
54
+ # Only capture specific events (empty = capture all Rails.event.notify() events)
55
+ # Note: This only affects events emitted via Rails.event.notify(), NOT internal
56
+ # Rails instrumentation events (sql.active_record, process_action.action_controller, etc.)
57
+ # which use ActiveSupport::Notifications and are not captured by this gem.
58
+ # config.captured_events = ["user.created", "order.placed"]
59
+
60
+ # Ignore specific events (supports strings and regex patterns)
61
+ # config.ignored_events = ["internal.heartbeat", /^debug\./]
62
+
63
+ # Default pagination limit
64
+ # config.per_page = 25
65
+
66
+ # ==========================================================================
67
+ # Group Tracking
68
+ # ==========================================================================
69
+
70
+ # Context/tag keys used to group related events together
71
+ # Events sharing the same value for these keys are shown together
72
+ # Example: All events with the same request_id form a "request group"
73
+ # config.group_keys = [:request_id, :order_id, :job_id, :user_id]
74
+
75
+ # ==========================================================================
76
+ # Authentication
77
+ # ==========================================================================
78
+
79
+ # Option 1: HTTP Basic Auth (simple, good for internal tools)
80
+ # config.http_basic_auth_enabled = true
81
+ # config.http_basic_auth_user = ENV["RAILS_EVENT_VIEWER_USER"]
82
+ # config.http_basic_auth_password = ENV["RAILS_EVENT_VIEWER_PASSWORD"]
83
+
84
+ # Option 2: Custom authentication (flexible, integrates with your auth system)
85
+ # The block receives the controller instance
86
+ # config.authentication = ->(controller) {
87
+ # controller.authenticate_user! # Devise example
88
+ # controller.current_user.admin? # Require admin role
89
+ # }
90
+ end
@@ -0,0 +1,93 @@
1
+ module RailsEventViewer
2
+ module Adapter
3
+ extend ActiveSupport::Concern
4
+
5
+ def supports_persistence?
6
+ true
7
+ end
8
+
9
+ def supports_analytics?
10
+ true
11
+ end
12
+
13
+ def supports_retention?
14
+ true
15
+ end
16
+
17
+ def write_events(events)
18
+ raise NotImplementedError, "#{self.class} must implement #write_events"
19
+ end
20
+
21
+ def fetch_events(relation)
22
+ raise NotImplementedError, "#{self.class} must implement #fetch_events"
23
+ end
24
+
25
+ def count_events(relation)
26
+ raise NotImplementedError, "#{self.class} must implement #count_events"
27
+ end
28
+
29
+ def distinct_event_names
30
+ raise NotImplementedError, "#{self.class} must implement #distinct_event_names"
31
+ end
32
+
33
+ def delete_before(timestamp)
34
+ raise NotImplementedError, "#{self.class} must implement #delete_before"
35
+ end
36
+
37
+ def find_event(id)
38
+ raise NotImplementedError, "#{self.class} must implement #find_event"
39
+ end
40
+
41
+ def events_over_time(since:, interval:)
42
+ raise NotImplementedError, "#{self.class} must implement #events_over_time" if supports_analytics?
43
+ {}
44
+ end
45
+
46
+ def counts_by_name(limit:)
47
+ raise NotImplementedError, "#{self.class} must implement #counts_by_name" if supports_analytics?
48
+ {}
49
+ end
50
+
51
+ def count_since(since)
52
+ raise NotImplementedError, "#{self.class} must implement #count_since" if supports_analytics?
53
+ 0
54
+ end
55
+
56
+ def event_type_statistics
57
+ distinct_event_names.map do |name|
58
+ build_event_type_stats(name)
59
+ end.sort_by { |s| -s[:count] }
60
+ end
61
+
62
+ def distinct_group_values(key, source: :context)
63
+ raise NotImplementedError, "#{self.class} must implement #distinct_group_values"
64
+ end
65
+
66
+ def group_instances(key, source: :context, limit: 100)
67
+ raise NotImplementedError, "#{self.class} must implement #group_instances"
68
+ end
69
+
70
+ def event_time_span(relation)
71
+ [nil, nil]
72
+ end
73
+
74
+ private
75
+
76
+ def build_event_type_stats(name)
77
+ relation = RailsEventViewer.events.with_name(name)
78
+ last_event = relation.first
79
+
80
+ {
81
+ name: name,
82
+ count: relation.count,
83
+ last_event_at: extract_occurred_at(last_event)
84
+ }
85
+ end
86
+
87
+ def extract_occurred_at(event)
88
+ return nil unless event
89
+
90
+ event.respond_to?(:occurred_at) ? event.occurred_at : event[:occurred_at]
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,227 @@
1
+ module RailsEventViewer
2
+ module Adapters
3
+ class ActiveRecord
4
+ include Adapter
5
+
6
+ def initialize(**options)
7
+ @options = options
8
+ end
9
+
10
+ def table_exists?
11
+ Entry.table_exists?
12
+ rescue ::ActiveRecord::NoDatabaseError, ::ActiveRecord::StatementInvalid
13
+ false
14
+ end
15
+
16
+ def write_events(events)
17
+ return if events.empty?
18
+
19
+ # Use insert_all for bulk inserts (more efficient)
20
+ records = events.map do |event|
21
+ {
22
+ name: event[:name],
23
+ payload: event[:payload] || {},
24
+ tags: event[:tags] || {},
25
+ context: event[:context] || {},
26
+ source_file: event[:source_file],
27
+ source_line: event[:source_line],
28
+ source_label: event[:source_label],
29
+ occurred_at: event[:occurred_at],
30
+ created_at: Time.current,
31
+ updated_at: Time.current
32
+ }
33
+ end
34
+
35
+ Entry.insert_all(records)
36
+ end
37
+
38
+ def fetch_events(relation)
39
+ build_scope(relation)
40
+ .offset(relation.offset_value)
41
+ .limit(relation.limit_value)
42
+ end
43
+
44
+ def count_events(relation)
45
+ build_scope(relation).count
46
+ end
47
+
48
+ def distinct_event_names
49
+ Entry.distinct.pluck(:name).compact.sort
50
+ end
51
+
52
+ def find_event(id)
53
+ Entry.find_by(id: id)
54
+ end
55
+
56
+ def delete_before(timestamp)
57
+ Entry.where("occurred_at < ?", timestamp).in_batches.delete_all
58
+ end
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
75
+ end
76
+
77
+ def counts_by_name(limit:)
78
+ Entry.group(:name)
79
+ .order(Arel.sql("COUNT(*) DESC"))
80
+ .limit(limit)
81
+ .count
82
+ end
83
+
84
+ def count_since(since)
85
+ Entry.where("occurred_at >= ?", since).count
86
+ end
87
+
88
+ def event_type_statistics
89
+ Entry
90
+ .group(:name)
91
+ .order(Arel.sql("COUNT(*) DESC"))
92
+ .pluck(:name, Arel.sql("COUNT(*)"), Arel.sql("MAX(occurred_at)"))
93
+ .map do |name, count, last_event_at|
94
+ {
95
+ name: name,
96
+ count: count,
97
+ last_event_at: parse_timestamp(last_event_at)
98
+ }
99
+ end
100
+ end
101
+
102
+ def distinct_group_values(key, source: :context)
103
+ column = source == :tags ? :tags : :context
104
+ json_path = JsonQuery.extract_path(column, key)
105
+
106
+ Entry
107
+ .where(JsonQuery.extract_path_not_null(column, key))
108
+ .distinct
109
+ .pluck(Arel.sql(json_path))
110
+ .compact
111
+ .map { |v| v.is_a?(String) ? v.gsub(/^"|"$/, "") : v.to_s }
112
+ .uniq
113
+ end
114
+
115
+ def group_instances(key, source: :context, limit: 100)
116
+ column = source == :tags ? :tags : :context
117
+ json_path = JsonQuery.extract_path(column, key)
118
+
119
+ Entry
120
+ .select(
121
+ Arel.sql("#{json_path} as group_value"),
122
+ Arel.sql("COUNT(*) as event_count"),
123
+ Arel.sql("MIN(occurred_at) as first_event_at"),
124
+ Arel.sql("MAX(occurred_at) as last_event_at")
125
+ )
126
+ .where(JsonQuery.extract_path_not_null(column, key))
127
+ .group(Arel.sql(json_path))
128
+ .order(Arel.sql("MAX(occurred_at) DESC"))
129
+ .limit(limit)
130
+ .map do |row|
131
+ {
132
+ value: normalize_group_value(row.group_value),
133
+ count: row.event_count,
134
+ first_event_at: parse_timestamp(row.first_event_at),
135
+ last_event_at: parse_timestamp(row.last_event_at)
136
+ }
137
+ end
138
+ end
139
+
140
+ def fetch_last(relation)
141
+ build_scope(relation).reorder(occurred_at: :asc, id: :asc).first
142
+ end
143
+
144
+ def event_time_span(relation)
145
+ row = build_scope(relation).pick(
146
+ Arel.sql("MIN(occurred_at)"),
147
+ Arel.sql("MAX(occurred_at)")
148
+ )
149
+ return [nil, nil] unless row
150
+ [parse_timestamp(row[0]), parse_timestamp(row[1])]
151
+ end
152
+
153
+ private
154
+
155
+ def parse_timestamp(value)
156
+ return nil if value.nil?
157
+ return value if value.is_a?(Time)
158
+
159
+ ActiveSupport::TimeZone["UTC"].parse(value.to_s)
160
+ rescue ArgumentError
161
+ nil
162
+ end
163
+
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
+ def build_scope(relation)
172
+ scope = Entry.order(occurred_at: :desc)
173
+ scope = apply_name_filters(scope, relation)
174
+ scope = apply_tag_filters(scope, relation)
175
+ scope = apply_context_filters(scope, relation)
176
+ scope = apply_time_filters(scope, relation)
177
+ scope = apply_search(scope, relation)
178
+ scope
179
+ end
180
+
181
+ def apply_name_filters(scope, relation)
182
+ return scope if relation.names.blank?
183
+
184
+ if relation.names.size == 1
185
+ scope.where(name: relation.names.first)
186
+ else
187
+ scope.where(name: relation.names)
188
+ end
189
+ end
190
+
191
+ def apply_tag_filters(scope, relation)
192
+ return scope if relation.tags.blank?
193
+
194
+ relation.tags.each do |key, value|
195
+ scope = scope.where(*JsonQuery.contains(:tags, key, value))
196
+ end
197
+ scope
198
+ end
199
+
200
+ def apply_context_filters(scope, relation)
201
+ return scope if relation.contexts.blank?
202
+
203
+ relation.contexts.each do |key, value|
204
+ scope = scope.where(*JsonQuery.contains(:context, key, value))
205
+ end
206
+ scope
207
+ end
208
+
209
+ def apply_time_filters(scope, relation)
210
+ scope = scope.where("occurred_at >= ?", relation.since_time) if relation.since_time
211
+ scope = scope.where("occurred_at <= ?", relation.until_time) if relation.until_time
212
+ scope
213
+ end
214
+
215
+ def apply_search(scope, relation)
216
+ return scope if relation.query.blank?
217
+
218
+ sanitized = "%#{Entry.sanitize_sql_like(relation.query.downcase)}%"
219
+ text_type = JsonQuery.detect_adapter == :mysql ? "CHAR" : "TEXT"
220
+ scope.where(
221
+ "LOWER(name) LIKE :q ESCAPE '\\' OR LOWER(CAST(payload AS #{text_type})) LIKE :q ESCAPE '\\'",
222
+ q: sanitized
223
+ )
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsEventViewer
4
+ module Adapters
5
+ module Concerns
6
+ module InMemoryFiltering
7
+ extend ActiveSupport::Concern
8
+
9
+ def distinct_group_values(key, source: :context)
10
+ in_memory_events
11
+ .map { |e| group_value_for(e, key, source) }
12
+ .compact
13
+ .uniq
14
+ end
15
+
16
+ def group_instances(key, source: :context, limit: 100)
17
+ in_memory_events
18
+ .group_by { |e| group_value_for(e, key, source) }
19
+ .reject { |value, _| value.nil? }
20
+ .map { |value, events| build_group_instance(value, events) }
21
+ .sort_by { |s| -(s[:last_event_at]&.to_f || 0) }
22
+ .take(limit)
23
+ end
24
+
25
+ def event_time_span(relation)
26
+ filtered = filter_events(in_memory_events, relation)
27
+ timestamps = filtered.filter_map { |e| e[:occurred_at] }
28
+ [timestamps.min, timestamps.max]
29
+ end
30
+
31
+ private
32
+
33
+ def group_value_for(event, key, source)
34
+ data = event[source]
35
+ data&.dig(key.to_s) || data&.dig(key.to_sym)
36
+ end
37
+
38
+ def build_group_instance(value, events)
39
+ timestamps = events.map { |e| e[:occurred_at] }.compact
40
+ {
41
+ value: value,
42
+ count: events.size,
43
+ first_event_at: timestamps.min,
44
+ last_event_at: timestamps.max
45
+ }
46
+ end
47
+
48
+ def filter_events(events, relation)
49
+ result = events
50
+
51
+ result = filter_by_names(result, relation.names)
52
+ result = filter_by_tags(result, relation.tags)
53
+ result = filter_by_contexts(result, relation.contexts)
54
+ result = filter_by_time_range(result, relation.since_time, relation.until_time)
55
+ result = filter_by_search_query(result, relation.query)
56
+
57
+ result
58
+ end
59
+
60
+ def filter_by_names(events, names)
61
+ return events if names.blank?
62
+
63
+ events.select { |e| names.include?(e[:name]) }
64
+ end
65
+
66
+ def filter_by_tags(events, tags)
67
+ return events if tags.blank?
68
+
69
+ events.select do |e|
70
+ event_tags = e[:tags] || {}
71
+ tags.all? do |key, value|
72
+ hash_key_matches?(event_tags, key, value)
73
+ end
74
+ end
75
+ end
76
+
77
+ def filter_by_contexts(events, contexts)
78
+ return events if contexts.blank?
79
+
80
+ events.select do |e|
81
+ event_context = e[:context] || {}
82
+ contexts.all? do |key, value|
83
+ hash_key_matches?(event_context, key, value)
84
+ end
85
+ end
86
+ end
87
+
88
+ def hash_key_matches?(hash, key, value)
89
+ if value.nil?
90
+ hash.key?(key.to_s) || hash.key?(key.to_sym)
91
+ else
92
+ values_match?(hash[key.to_s], value) || values_match?(hash[key.to_sym], value)
93
+ end
94
+ end
95
+
96
+ def values_match?(actual, expected)
97
+ return false if actual.nil?
98
+
99
+ actual == expected || actual.to_s == expected.to_s
100
+ end
101
+
102
+ def filter_by_time_range(events, since_time, until_time)
103
+ result = events
104
+
105
+ if since_time
106
+ result = result.select { |e| e[:occurred_at] && e[:occurred_at] >= since_time }
107
+ end
108
+
109
+ if until_time
110
+ result = result.select { |e| e[:occurred_at] && e[:occurred_at] <= until_time }
111
+ end
112
+
113
+ result
114
+ end
115
+
116
+ def filter_by_search_query(events, query)
117
+ return events if query.blank?
118
+
119
+ query_downcase = query.downcase
120
+ events.select do |e|
121
+ name_matches?(e[:name], query_downcase) ||
122
+ payload_matches?(e[:payload], query_downcase)
123
+ end
124
+ end
125
+
126
+ def name_matches?(name, query)
127
+ name&.downcase&.include?(query)
128
+ end
129
+
130
+ def payload_matches?(payload, query)
131
+ payload.to_s.downcase.include?(query)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "concerns/in_memory_filtering"
4
+
5
+ module RailsEventViewer
6
+ module Adapters
7
+ class Memory
8
+ include Adapter
9
+ include Concerns::InMemoryFiltering
10
+
11
+ @events = []
12
+ @id_counter = 0
13
+ @mutex = Mutex.new
14
+
15
+ class << self
16
+ attr_accessor :events, :id_counter, :mutex
17
+ end
18
+
19
+ def events
20
+ self.class.events
21
+ end
22
+
23
+ def initialize(max_events: 1000, **)
24
+ @max_events = max_events
25
+ end
26
+
27
+ def supports_retention?
28
+ true
29
+ end
30
+
31
+ def write_events(new_events)
32
+ return if new_events.empty?
33
+
34
+ mutex.synchronize do
35
+ new_events.each do |event|
36
+ self.class.id_counter += 1
37
+ events << event.merge(id: self.class.id_counter)
38
+ end
39
+ trim_to_max
40
+ end
41
+ end
42
+
43
+ def fetch_events(relation)
44
+ sorted = sort_by_occurred_at(mutex.synchronize { events.dup })
45
+ filtered = filter_events(sorted, relation)
46
+ filtered.drop(relation.offset_value).take(relation.limit_value)
47
+ end
48
+
49
+ def count_events(relation)
50
+ filter_events(mutex.synchronize { events.dup }, relation).size
51
+ end
52
+
53
+ def distinct_event_names
54
+ mutex.synchronize { events.map { |e| e[:name] } }.uniq.compact.sort
55
+ end
56
+
57
+ def find_event(id)
58
+ parsed = Integer(id, exception: false)
59
+ return nil unless parsed
60
+ mutex.synchronize { events.find { |e| e[:id] == parsed } }
61
+ end
62
+
63
+ def delete_before(timestamp)
64
+ mutex.synchronize do
65
+ original_size = events.size
66
+ events.reject! { |e| e[:occurred_at] && e[:occurred_at] < timestamp }
67
+ original_size - events.size
68
+ end
69
+ end
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
+ def count_since(since)
92
+ mutex.synchronize { events.count { |e| e[:occurred_at] && e[:occurred_at] >= since } }
93
+ end
94
+
95
+ def clear!
96
+ mutex.synchronize do
97
+ events.clear
98
+ self.class.id_counter = 0
99
+ end
100
+ end
101
+
102
+ def size
103
+ mutex.synchronize { events.size }
104
+ end
105
+
106
+ private
107
+
108
+ def in_memory_events
109
+ mutex.synchronize { events.dup }
110
+ end
111
+
112
+ def mutex
113
+ self.class.mutex
114
+ end
115
+
116
+ def sort_by_occurred_at(evts)
117
+ evts.sort_by { |e| -(e[:occurred_at]&.to_f || 0) }
118
+ end
119
+
120
+ def trim_to_max
121
+ return unless events.size > @max_events
122
+
123
+ events.sort_by! { |e| e[:occurred_at]&.to_f || 0 }
124
+ events.slice!(0, events.size - @max_events)
125
+ end
126
+ end
127
+ end
128
+ end