event_meter 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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1081 -0
  4. data/exe/event_meter +5 -0
  5. data/lib/event_meter/auto_cleanup.rb +93 -0
  6. data/lib/event_meter/cli.rb +124 -0
  7. data/lib/event_meter/configuration.rb +244 -0
  8. data/lib/event_meter/errors.rb +9 -0
  9. data/lib/event_meter/event.rb +180 -0
  10. data/lib/event_meter/event_payload.rb +103 -0
  11. data/lib/event_meter/hash_input.rb +20 -0
  12. data/lib/event_meter/index_key.rb +19 -0
  13. data/lib/event_meter/keys.rb +63 -0
  14. data/lib/event_meter/path_name.rb +37 -0
  15. data/lib/event_meter/processor.rb +305 -0
  16. data/lib/event_meter/rails.rb +79 -0
  17. data/lib/event_meter/report_definition.rb +184 -0
  18. data/lib/event_meter/reports.rb +143 -0
  19. data/lib/event_meter/rollup.rb +148 -0
  20. data/lib/event_meter/stores/cleanup_helpers.rb +76 -0
  21. data/lib/event_meter/stores/file_helpers.rb +47 -0
  22. data/lib/event_meter/stores/lock_refresher.rb +75 -0
  23. data/lib/event_meter/stores/namespace.rb +14 -0
  24. data/lib/event_meter/stores/redis_lock.rb +77 -0
  25. data/lib/event_meter/stores/rollup/active_record_postgres.rb +135 -0
  26. data/lib/event_meter/stores/rollup/file.rb +736 -0
  27. data/lib/event_meter/stores/rollup/postgres.rb +813 -0
  28. data/lib/event_meter/stores/rollup/redis.rb +349 -0
  29. data/lib/event_meter/stores/stream/file.rb +98 -0
  30. data/lib/event_meter/stores/stream/redis.rb +79 -0
  31. data/lib/event_meter/time_buckets.rb +56 -0
  32. data/lib/event_meter/version.rb +3 -0
  33. data/lib/event_meter/write_result.rb +26 -0
  34. data/lib/event_meter.rb +150 -0
  35. data/lib/generators/event_meter/install_generator.rb +57 -0
  36. data/lib/generators/event_meter/templates/create_event_meter_tables.rb.erb +12 -0
  37. data/lib/generators/event_meter/templates/event_meter.rb.erb +12 -0
  38. metadata +156 -0
data/exe/event_meter ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "event_meter/cli"
4
+
5
+ exit EventMeter::CLI.call(ARGV)
@@ -0,0 +1,93 @@
1
+ require "time"
2
+
3
+ module EventMeter
4
+ class AutoCleanup
5
+ WATERMARK_KEY_SUFFIX = "auto_cleanup:history:last_run"
6
+
7
+ attr_reader :configuration, :rollup_storage
8
+
9
+ def initialize(configuration:, rollup_storage:)
10
+ @configuration = configuration
11
+ @rollup_storage = rollup_storage
12
+ end
13
+
14
+ def run
15
+ return false unless configuration.auto_cleanup_history
16
+ return false unless rollup_storage.respond_to?(:cleanup_history)
17
+
18
+ with_cleanup_lock { cleanup_if_due }
19
+ rescue StandardError => error
20
+ report_error(error)
21
+ false
22
+ end
23
+
24
+ private
25
+
26
+ def with_cleanup_lock
27
+ return yield unless rollup_storage.respond_to?(:with_lock)
28
+
29
+ result = false
30
+ locked = rollup_storage.with_lock(ttl: configuration.lock_ttl) do
31
+ result = yield
32
+ end
33
+
34
+ locked ? result : false
35
+ end
36
+
37
+ def cleanup_if_due
38
+ return false unless cleanup_due?
39
+
40
+ result = rollup_storage.cleanup_history(
41
+ before: cleanup_before,
42
+ events: nil,
43
+ interval_state: true
44
+ )
45
+ write_watermark
46
+ result
47
+ end
48
+
49
+ def cleanup_due?
50
+ last_cleanup_at.nil? ||
51
+ last_cleanup_at <= current_time - configuration.cleanup_history_interval
52
+ end
53
+
54
+ def last_cleanup_at
55
+ return unless rollup_storage.respond_to?(:cleanup_watermark)
56
+
57
+ parse_time(rollup_storage.cleanup_watermark(watermark_key))
58
+ end
59
+
60
+ def write_watermark
61
+ return unless rollup_storage.respond_to?(:write_cleanup_watermark)
62
+
63
+ rollup_storage.write_cleanup_watermark(watermark_key, current_time.iso8601(6))
64
+ end
65
+
66
+ def cleanup_before
67
+ current_time - configuration.cleanup_history_retention
68
+ end
69
+
70
+ def watermark_key
71
+ [configuration.namespace, WATERMARK_KEY_SUFFIX].join(":")
72
+ end
73
+
74
+ def current_time
75
+ Time.now.utc
76
+ end
77
+
78
+ def parse_time(value)
79
+ return if value.nil?
80
+
81
+ Time.parse(value.to_s).utc
82
+ rescue ArgumentError, TypeError, RangeError
83
+ nil
84
+ end
85
+
86
+ def report_error(error)
87
+ handler = configuration.auto_cleanup_error_handler
88
+ handler&.call(error)
89
+ rescue StandardError
90
+ nil
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,124 @@
1
+ require "optparse"
2
+
3
+ require_relative "../event_meter"
4
+
5
+ module EventMeter
6
+ class CLI
7
+ def self.call(argv, out: $stdout, err: $stderr)
8
+ new(argv, out: out, err: err).call
9
+ end
10
+
11
+ def initialize(argv, out:, err:)
12
+ @argv = argv.dup
13
+ @out = out
14
+ @err = err
15
+ end
16
+
17
+ def call
18
+ case argv.shift
19
+ when "postgres"
20
+ postgres
21
+ when "help", "-h", "--help", nil
22
+ out.puts usage
23
+ 0
24
+ else
25
+ err.puts usage
26
+ 1
27
+ end
28
+ rescue OptionParser::ParseError, ArgumentError => error
29
+ err.puts error.message
30
+ 1
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :argv, :out, :err
36
+
37
+ def postgres
38
+ case argv.shift
39
+ when "install"
40
+ postgres_install
41
+ when "schema"
42
+ postgres_schema
43
+ when "help", "-h", "--help", nil
44
+ out.puts postgres_usage
45
+ 0
46
+ else
47
+ err.puts postgres_usage
48
+ 1
49
+ end
50
+ end
51
+
52
+ def postgres_install
53
+ options = postgres_options
54
+ parse_postgres_options!(options)
55
+
56
+ url = options.fetch(:url) || ENV["DATABASE_URL"]
57
+ raise ArgumentError, "missing database URL; pass --url or set DATABASE_URL" if url.to_s.strip.empty?
58
+
59
+ require_pg!
60
+
61
+ connection = PG.connect(url)
62
+ EventMeter::Stores::Rollup::Postgres.install!(
63
+ connection: connection,
64
+ table_prefix: options.fetch(:table_prefix)
65
+ )
66
+ out.puts "Installed EventMeter PostgreSQL tables with prefix #{options.fetch(:table_prefix)}"
67
+ 0
68
+ ensure
69
+ connection&.close
70
+ end
71
+
72
+ def postgres_schema
73
+ options = postgres_options
74
+ parse_postgres_options!(options)
75
+
76
+ out.puts EventMeter::Stores::Rollup::Postgres.schema_sql(
77
+ table_prefix: options.fetch(:table_prefix)
78
+ )
79
+ 0
80
+ end
81
+
82
+ def postgres_options
83
+ {
84
+ table_prefix: "event_meter",
85
+ url: nil
86
+ }
87
+ end
88
+
89
+ def parse_postgres_options!(options)
90
+ parser = OptionParser.new do |parser_config|
91
+ parser_config.on("--table-prefix PREFIX", "PostgreSQL table prefix") do |value|
92
+ options[:table_prefix] = value
93
+ end
94
+ parser_config.on("--url URL", "PostgreSQL connection URL") do |value|
95
+ options[:url] = value
96
+ end
97
+ end
98
+ parser.parse!(argv)
99
+ raise OptionParser::InvalidArgument, argv.join(" ") unless argv.empty?
100
+ end
101
+
102
+ def require_pg!
103
+ require "pg"
104
+ rescue LoadError
105
+ raise ArgumentError, "pg is required for postgres install; add gem \"pg\" to your app"
106
+ end
107
+
108
+ def usage
109
+ <<~TEXT
110
+ Usage:
111
+ event_meter postgres schema [--table-prefix PREFIX]
112
+ event_meter postgres install [--url DATABASE_URL] [--table-prefix PREFIX]
113
+ TEXT
114
+ end
115
+
116
+ def postgres_usage
117
+ <<~TEXT
118
+ Usage:
119
+ event_meter postgres schema [--table-prefix PREFIX]
120
+ event_meter postgres install [--url DATABASE_URL] [--table-prefix PREFIX]
121
+ TEXT
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,244 @@
1
+ require "monitor"
2
+
3
+ module EventMeter
4
+ class Configuration
5
+ DEFAULT_NAMESPACE = "event_meter:v1"
6
+ DEFAULT_REDIS_READ_LIMIT = nil
7
+ DEFAULT_ROLLUP_TTL = 31 * 24 * 60 * 60
8
+ DEFAULT_LOCK_TTL = 30
9
+ DEFAULT_AUTO_CLEANUP_HISTORY = false
10
+ DEFAULT_CLEANUP_HISTORY_RETENTION = DEFAULT_ROLLUP_TTL
11
+ DEFAULT_CLEANUP_HISTORY_INTERVAL = 60 * 60
12
+ DEFAULT_SUMMARY_KEY_LIMIT = 10_000
13
+ DEFAULT_AUTO_CLEANUP_ERROR_HANDLER = lambda do |error|
14
+ warn "EventMeter auto cleanup failed: #{error.class}: #{error.message}"
15
+ end
16
+
17
+ attr_reader :namespace, :redis, :redis_read_limit, :rollup_ttl, :lock_ttl,
18
+ :auto_cleanup_history, :cleanup_history_retention, :cleanup_history_interval,
19
+ :summary_key_limit, :auto_cleanup_error_handler
20
+
21
+ def initialize
22
+ @configuration_lock = Monitor.new
23
+ @namespace = DEFAULT_NAMESPACE
24
+ @redis_read_limit = DEFAULT_REDIS_READ_LIMIT
25
+ @rollup_ttl = DEFAULT_ROLLUP_TTL
26
+ @lock_ttl = DEFAULT_LOCK_TTL
27
+ @auto_cleanup_history = DEFAULT_AUTO_CLEANUP_HISTORY
28
+ @cleanup_history_retention = DEFAULT_CLEANUP_HISTORY_RETENTION
29
+ @cleanup_history_interval = DEFAULT_CLEANUP_HISTORY_INTERVAL
30
+ @summary_key_limit = DEFAULT_SUMMARY_KEY_LIMIT
31
+ @auto_cleanup_error_handler = DEFAULT_AUTO_CLEANUP_ERROR_HANDLER
32
+ end
33
+
34
+ def namespace=(value)
35
+ namespace = value.to_s
36
+ raise ArgumentError, "namespace cannot be blank" if namespace.strip.empty?
37
+
38
+ synchronize do
39
+ @namespace = namespace
40
+ reset_default_storages
41
+ end
42
+ end
43
+
44
+ def redis=(client_or_factory)
45
+ synchronize do
46
+ @redis = client_or_factory
47
+ reset_redis_client_cache
48
+ reset_default_storages
49
+ end
50
+ end
51
+
52
+ def redis_read_limit=(value)
53
+ redis_read_limit = positive_integer_or_nil(value, "redis_read_limit")
54
+
55
+ synchronize do
56
+ @redis_read_limit = redis_read_limit
57
+ reset_default_storages
58
+ end
59
+ end
60
+
61
+ def rollup_ttl=(value)
62
+ rollup_ttl = positive_integer(value, "rollup_ttl")
63
+
64
+ synchronize do
65
+ @rollup_ttl = rollup_ttl
66
+ reset_default_rollup_storage
67
+ end
68
+ end
69
+
70
+ def lock_ttl=(value)
71
+ @lock_ttl = positive_integer(value, "lock_ttl")
72
+ end
73
+
74
+ def auto_cleanup_history=(value)
75
+ @auto_cleanup_history = boolean(value, "auto_cleanup_history")
76
+ end
77
+
78
+ def cleanup_history_retention=(value)
79
+ @cleanup_history_retention = positive_integer(value, "cleanup_history_retention")
80
+ end
81
+
82
+ def cleanup_history_interval=(value)
83
+ @cleanup_history_interval = positive_integer(value, "cleanup_history_interval")
84
+ end
85
+
86
+ def summary_key_limit=(value)
87
+ @summary_key_limit = positive_integer_or_nil(value, "summary_key_limit")
88
+ end
89
+
90
+ def auto_cleanup_error_handler=(handler)
91
+ unless handler.nil? || handler.respond_to?(:call)
92
+ raise ArgumentError, "auto_cleanup_error_handler must respond to call"
93
+ end
94
+
95
+ @auto_cleanup_error_handler = handler
96
+ end
97
+
98
+ def stream_storage
99
+ synchronize do
100
+ reset_redis_clients_if_stale
101
+ @stream_storage ||= default_stream_storage
102
+ end
103
+ end
104
+
105
+ def stream_storage=(storage)
106
+ raise ArgumentError, "stream_storage cannot be nil" if storage.nil?
107
+
108
+ synchronize do
109
+ @stream_storage = storage
110
+ @stream_storage_default = false
111
+ end
112
+ end
113
+
114
+ def rollup_storage
115
+ synchronize do
116
+ reset_redis_clients_if_stale
117
+ @rollup_storage ||= default_rollup_storage
118
+ end
119
+ end
120
+
121
+ def rollup_storage=(storage)
122
+ raise ArgumentError, "rollup_storage cannot be nil" if storage.nil?
123
+
124
+ synchronize do
125
+ @rollup_storage = storage
126
+ @rollup_storage_default = false
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def synchronize(&block)
133
+ @configuration_lock.synchronize(&block)
134
+ end
135
+
136
+ def default_stream_storage
137
+ return missing_storage!("stream_storage") unless redis
138
+
139
+ @stream_storage_default = true
140
+ Stores::Stream::Redis.new(
141
+ redis: redis_client(:stream),
142
+ lock_redis: redis_client(:stream_lock),
143
+ namespace: namespace,
144
+ redis_read_limit: redis_read_limit
145
+ )
146
+ end
147
+
148
+ def default_rollup_storage
149
+ return missing_storage!("rollup_storage") unless redis
150
+
151
+ @rollup_storage_default = true
152
+ Stores::Rollup::Redis.new(
153
+ redis: redis_client(:rollup),
154
+ lock_redis: redis_client(:rollup_lock),
155
+ namespace: namespace,
156
+ rollup_ttl: rollup_ttl
157
+ )
158
+ end
159
+
160
+ def reset_default_storages
161
+ @stream_storage = nil if @stream_storage_default
162
+ reset_default_rollup_storage
163
+ end
164
+
165
+ def reset_default_rollup_storage
166
+ @rollup_storage = nil if @rollup_storage_default
167
+ end
168
+
169
+ def redis_client(purpose = :default)
170
+ synchronize do
171
+ reset_redis_clients_if_stale
172
+ initialize_redis_client_cache
173
+ @redis_clients[purpose] ||= begin
174
+ client = redis.is_a?(Proc) ? redis.call : redis
175
+ raise ConfigurationError, "redis client cannot be nil" if client.nil?
176
+ raise ConfigurationError, "redis client cannot be closed" if redis_client_closed?(client)
177
+
178
+ client
179
+ end
180
+ end
181
+ end
182
+
183
+ def reset_redis_clients_if_stale
184
+ return unless @redis_clients
185
+ return unless redis_client_cache_stale?
186
+
187
+ reset_redis_client_cache
188
+ reset_default_storages
189
+ end
190
+
191
+ def reset_redis_client_cache
192
+ @redis_clients = nil
193
+ @redis_client_pid = nil
194
+ end
195
+
196
+ def initialize_redis_client_cache
197
+ return if @redis_clients
198
+
199
+ @redis_clients = {}
200
+ @redis_client_pid = process_id
201
+ end
202
+
203
+ def redis_client_cache_stale?
204
+ @redis_client_pid != process_id || @redis_clients.any? do |_purpose, client|
205
+ redis_client_closed?(client)
206
+ end
207
+ end
208
+
209
+ def process_id
210
+ Process.pid
211
+ end
212
+
213
+ def redis_client_closed?(client)
214
+ client.respond_to?(:closed?) && client.closed?
215
+ rescue StandardError
216
+ false
217
+ end
218
+
219
+ def missing_storage!(name)
220
+ raise ConfigurationError, "configure #{name}, or set config.redis to use Redis storage"
221
+ end
222
+
223
+ def positive_integer(value, name)
224
+ integer = Integer(value)
225
+ return integer if integer.positive?
226
+
227
+ raise ArgumentError, "#{name} must be positive"
228
+ rescue ArgumentError, TypeError, RangeError
229
+ raise ArgumentError, "#{name} must be positive"
230
+ end
231
+
232
+ def positive_integer_or_nil(value, name)
233
+ return nil if value.nil?
234
+
235
+ positive_integer(value, name)
236
+ end
237
+
238
+ def boolean(value, name)
239
+ return value if value == true || value == false
240
+
241
+ raise ArgumentError, "#{name} must be true or false"
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,9 @@
1
+ module EventMeter
2
+ class Error < StandardError; end
3
+ class UnsupportedQueryError < Error; end
4
+ class AlreadyRecordedError < Error; end
5
+ class ConfigurationError < Error; end
6
+ class DefinitionChangedError < Error; end
7
+ class DefinitionNotFoundError < Error; end
8
+ class LockLostError < Error; end
9
+ end
@@ -0,0 +1,180 @@
1
+ require "time"
2
+
3
+ require_relative "errors"
4
+ require_relative "event_payload"
5
+ require_relative "hash_input"
6
+ require_relative "write_result"
7
+
8
+ module EventMeter
9
+ class Event
10
+ INVALID_EVENT_NAME = "event_meter.invalid"
11
+
12
+ attr_reader :name, :started_at, :finished_at, :status, :error
13
+
14
+ def self.start(name, attributes, keyword_attributes, started_at:)
15
+ new(name, attributes, keyword_attributes, started_at: started_at)
16
+ end
17
+
18
+ def self.failed(error)
19
+ new(INVALID_EVENT_NAME, error: error)
20
+ end
21
+
22
+ def initialize(name, attributes = nil, keyword_attributes = {}, started_at: nil, error: nil)
23
+ @started_at = nil
24
+ @finished_at = nil
25
+ @status = nil
26
+ @recorded = false
27
+ @error = nil
28
+
29
+ if error
30
+ @name = INVALID_EVENT_NAME
31
+ @base_attributes = {}
32
+ @error = error
33
+ return
34
+ end
35
+
36
+ @started_at = started_at&.utc
37
+ @name = normalize_name(name)
38
+ @base_attributes = normalize_start_attributes(attributes, keyword_attributes)
39
+ rescue StandardError => error
40
+ @name = fallback_name(name)
41
+ @base_attributes = {}
42
+ @error = error
43
+ end
44
+
45
+ def success(attributes = {})
46
+ finish("success") { attributes }
47
+ end
48
+
49
+ def skip(reason_or_attributes = nil, attributes = {})
50
+ finish("skipped") do
51
+ normalize_reason_attributes(reason_or_attributes, attributes, :skip_reason)
52
+ end
53
+ end
54
+
55
+ def failure(error_or_attributes = nil, attributes = {})
56
+ finish("failure") do
57
+ normalize_error_attributes(error_or_attributes, attributes)
58
+ end
59
+ end
60
+
61
+ def error?
62
+ !error.nil?
63
+ end
64
+
65
+ private
66
+
67
+ def finish(status)
68
+ if recorded?
69
+ return WriteResult.failed(
70
+ payload: nil,
71
+ error: AlreadyRecordedError.new("event has already been recorded")
72
+ )
73
+ end
74
+
75
+ @started_at ||= current_time
76
+ @status = status
77
+ @finished_at = current_time
78
+
79
+ return WriteResult.failed(payload: nil, error: error) if error?
80
+
81
+ payload = nil
82
+ payload_hash = nil
83
+ attributes = yield
84
+
85
+ payload = EventPayload.build(
86
+ name,
87
+ params: record_attributes(attributes),
88
+ status: status,
89
+ started_at: started_at,
90
+ duration_ms: duration_ms
91
+ )
92
+ payload_hash = payload.to_h
93
+
94
+ EventMeter.stream_storage.append(payload)
95
+ @recorded = true
96
+ WriteResult.recorded(payload_hash)
97
+ rescue StandardError => error
98
+ WriteResult.failed(payload: payload_hash, error: error)
99
+ end
100
+
101
+ def record_attributes(attributes)
102
+ @base_attributes.merge(normalize_attributes(attributes))
103
+ end
104
+
105
+ def duration_ms
106
+ [((finished_at.to_f - started_at.to_f) * 1000).round, 0].max
107
+ end
108
+
109
+ def normalize_reason_attributes(reason_or_attributes, attributes, key)
110
+ attributes = normalize_attributes(attributes)
111
+
112
+ case reason_or_attributes
113
+ when nil
114
+ attributes
115
+ when Hash
116
+ normalize_attributes(reason_or_attributes).merge(attributes)
117
+ else
118
+ { key => reason_or_attributes.to_s }.merge(attributes)
119
+ end
120
+ end
121
+
122
+ def normalize_error_attributes(error_or_attributes, attributes)
123
+ attributes = normalize_attributes(attributes)
124
+
125
+ case error_or_attributes
126
+ when nil
127
+ attributes
128
+ when Hash
129
+ normalize_attributes(error_or_attributes).merge(attributes)
130
+ else
131
+ {
132
+ error_class: error_or_attributes.class.name,
133
+ error_message: error_message(error_or_attributes)
134
+ }.merge(attributes)
135
+ end
136
+ end
137
+
138
+ def normalize_attributes(attributes)
139
+ HashInput.coerce(attributes, "event attributes")
140
+ end
141
+
142
+ def normalize_start_attributes(attributes, keyword_attributes)
143
+ normalize_attributes(attributes).merge(normalize_attributes(keyword_attributes))
144
+ end
145
+
146
+ def normalize_name(value)
147
+ name = value.to_s
148
+ raise ArgumentError, "event name cannot be blank" if name.strip.empty?
149
+
150
+ name
151
+ end
152
+
153
+ def fallback_name(value)
154
+ name = safe_string(value)
155
+ return INVALID_EVENT_NAME if name.strip.empty?
156
+
157
+ name
158
+ end
159
+
160
+ def safe_string(value)
161
+ value.to_s
162
+ rescue StandardError
163
+ INVALID_EVENT_NAME
164
+ end
165
+
166
+ def error_message(error)
167
+ return error.message if error.respond_to?(:message)
168
+
169
+ error.to_s
170
+ end
171
+
172
+ def recorded?
173
+ @recorded
174
+ end
175
+
176
+ def current_time
177
+ Time.now.utc
178
+ end
179
+ end
180
+ end