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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +451 -0
- data/Rakefile +16 -0
- data/app/assets/stylesheets/rails_event_viewer/application.css +32 -0
- data/app/assets/stylesheets/rails_event_viewer/tailwind.css +54 -0
- data/app/controllers/rails_event_viewer/analytics_controller.rb +78 -0
- data/app/controllers/rails_event_viewer/application_controller.rb +84 -0
- data/app/controllers/rails_event_viewer/dashboard_controller.rb +18 -0
- data/app/controllers/rails_event_viewer/event_types_controller.rb +16 -0
- data/app/controllers/rails_event_viewer/events_controller.rb +72 -0
- data/app/controllers/rails_event_viewer/groups_controller.rb +52 -0
- data/app/helpers/rails_event_viewer/application_helper.rb +163 -0
- data/app/models/rails_event_viewer/application_record.rb +5 -0
- data/app/models/rails_event_viewer/entry.rb +34 -0
- data/app/views/layouts/rails_event_viewer/application.html.erb +45 -0
- data/app/views/rails_event_viewer/analytics/overview.html.erb +134 -0
- data/app/views/rails_event_viewer/dashboard/index.html.erb +113 -0
- data/app/views/rails_event_viewer/event_types/index.html.erb +59 -0
- data/app/views/rails_event_viewer/event_types/show.html.erb +102 -0
- data/app/views/rails_event_viewer/events/_event_row.html.erb +42 -0
- data/app/views/rails_event_viewer/events/_filters_sidebar.html.erb +66 -0
- data/app/views/rails_event_viewer/events/index.html.erb +65 -0
- data/app/views/rails_event_viewer/events/show.html.erb +155 -0
- data/app/views/rails_event_viewer/groups/index.html.erb +115 -0
- data/app/views/rails_event_viewer/groups/show.html.erb +134 -0
- data/app/views/rails_event_viewer/shared/_navigation.html.erb +47 -0
- data/app/views/rails_event_viewer/shared/_pagination.html.erb +60 -0
- data/app/views/rails_event_viewer/shared/_stat_card.html.erb +36 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20241208000001_create_rails_event_viewer_entries.rb +36 -0
- data/lib/generators/rails_event_viewer/install/USAGE +33 -0
- data/lib/generators/rails_event_viewer/install/install_generator.rb +86 -0
- data/lib/generators/rails_event_viewer/install/templates/create_rails_event_viewer_entries.rb.erb +35 -0
- data/lib/generators/rails_event_viewer/install/templates/initializer.rb.erb +90 -0
- data/lib/rails_event_viewer/adapter.rb +93 -0
- data/lib/rails_event_viewer/adapters/active_record.rb +227 -0
- data/lib/rails_event_viewer/adapters/concerns/in_memory_filtering.rb +136 -0
- data/lib/rails_event_viewer/adapters/memory.rb +128 -0
- data/lib/rails_event_viewer/adapters/null.rb +63 -0
- data/lib/rails_event_viewer/adapters/redis.rb +213 -0
- data/lib/rails_event_viewer/engine.rb +24 -0
- data/lib/rails_event_viewer/events_relation.rb +130 -0
- data/lib/rails_event_viewer/json_query.rb +111 -0
- data/lib/rails_event_viewer/pagy_countable.rb +31 -0
- data/lib/rails_event_viewer/subscriber.rb +255 -0
- data/lib/rails_event_viewer/time_utils.rb +24 -0
- data/lib/rails_event_viewer/version.rb +3 -0
- data/lib/rails_event_viewer.rb +122 -0
- data/lib/tasks/rails_event_viewer_tasks.rake +81 -0
- 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
|