behavior_analytics 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e4f126ff83e60165d7b93e152c0d3ebd20f354c0d25aad69aaa88503359ea8a
4
+ data.tar.gz: 1222c310f8447a36c79566f5d5aeaea504c057eecdda81f4d2b569fa62c1bc99
5
+ SHA512:
6
+ metadata.gz: 3fe2f936597d8c9b5016e527aa5b3ed86fb3b82f2b7cf5ba41df66c56a5d557a05758069bbaf5051e4960e2f01ad9e41fb4c3251c1f7c7d1808691c90a3272ec
7
+ data.tar.gz: 3e1c42b6e12f012333a44085395f070f7f66ea40851ed73d4f994dcda2acd7013cf0db22ad908461665cc9dd04977c1412e6d6bcdf7f8c334cc4889a22b848a6
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+
data/.rspec_status ADDED
@@ -0,0 +1,32 @@
1
+ example_id | status | run_time |
2
+ ----------------------------------------------- | ------ | --------------- |
3
+ ./spec/analytics/engine_spec.rb[1:1:1] | passed | 0.00095 seconds |
4
+ ./spec/analytics/engine_spec.rb[1:2:1] | passed | 0.0003 seconds |
5
+ ./spec/analytics/engine_spec.rb[1:3:1] | passed | 0.00026 seconds |
6
+ ./spec/analytics/engine_spec.rb[1:4:1] | passed | 0.00118 seconds |
7
+ ./spec/analytics/engine_spec.rb[1:5:1] | passed | 0.00021 seconds |
8
+ ./spec/analytics/engine_spec.rb[1:6:1] | passed | 0.00019 seconds |
9
+ ./spec/behavior_analytics_spec.rb[1:1] | passed | 0.0005 seconds |
10
+ ./spec/behavior_analytics_spec.rb[1:2:1] | passed | 0.00035 seconds |
11
+ ./spec/behavior_analytics_spec.rb[1:3:1] | passed | 0.00009 seconds |
12
+ ./spec/context_spec.rb[1:1:1] | passed | 0.00005 seconds |
13
+ ./spec/context_spec.rb[1:1:2] | passed | 0.00004 seconds |
14
+ ./spec/context_spec.rb[1:1:3] | passed | 0.00004 seconds |
15
+ ./spec/context_spec.rb[1:2:1] | passed | 0.00005 seconds |
16
+ ./spec/context_spec.rb[1:2:2] | passed | 0.00005 seconds |
17
+ ./spec/context_spec.rb[1:3:1] | passed | 0.00106 seconds |
18
+ ./spec/event_spec.rb[1:1:1] | passed | 0.0001 seconds |
19
+ ./spec/event_spec.rb[1:1:2] | passed | 0.00013 seconds |
20
+ ./spec/event_spec.rb[1:1:3] | passed | 0.00012 seconds |
21
+ ./spec/event_spec.rb[1:1:4] | passed | 0.00008 seconds |
22
+ ./spec/event_spec.rb[1:2:1] | passed | 0.00007 seconds |
23
+ ./spec/storage/in_memory_adapter_spec.rb[1:1:1] | passed | 0.00009 seconds |
24
+ ./spec/storage/in_memory_adapter_spec.rb[1:2:1] | passed | 0.00009 seconds |
25
+ ./spec/storage/in_memory_adapter_spec.rb[1:2:2] | passed | 0.00016 seconds |
26
+ ./spec/storage/in_memory_adapter_spec.rb[1:3:1] | passed | 0.00012 seconds |
27
+ ./spec/tracker_spec.rb[1:1:1] | passed | 0.00056 seconds |
28
+ ./spec/tracker_spec.rb[1:1:2] | passed | 0.00067 seconds |
29
+ ./spec/tracker_spec.rb[1:2:1] | passed | 0.0003 seconds |
30
+ ./spec/tracker_spec.rb[1:3:1] | passed | 0.00025 seconds |
31
+ ./spec/tracker_spec.rb[1:4:1] | passed | 0.00024 seconds |
32
+ ./spec/tracker_spec.rb[1:5:1] | passed | 0.00019 seconds |
data/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # Behavior Analytics
2
+
3
+ A Ruby gem for tracking user behavior events with multi-tenant support, computing analytics (engagement scores, time-based trends, feature usage), and supporting API calls, feature usage, and custom events.
4
+
5
+ ## Features
6
+
7
+ - **Flexible Context Tracking**: Track events with multi-tenant support, user types, and custom filters
8
+ - **Event Buffering**: Efficient batch processing with configurable buffer size and flush intervals
9
+ - **Comprehensive Analytics**:
10
+ - Event counts and aggregations
11
+ - Engagement scoring with customizable weights
12
+ - Time-based analytics (hourly, daily, weekly, monthly)
13
+ - Feature usage statistics
14
+ - **Storage Adapters**:
15
+ - ActiveRecord adapter for production use
16
+ - In-memory adapter for testing
17
+ - **Rails Integration**: Automatic API call tracking via middleware
18
+ - **Query Interface**: Fluent query builder for filtering events
19
+
20
+ ## Installation
21
+
22
+ Add this line to your application's Gemfile:
23
+
24
+ ```ruby
25
+ gem 'behavior_analytics'
26
+ ```
27
+
28
+ And then execute:
29
+
30
+ ```bash
31
+ $ bundle install
32
+ ```
33
+
34
+ Or install it yourself as:
35
+
36
+ ```bash
37
+ $ gem install behavior_analytics
38
+ ```
39
+
40
+ ## Rails Setup
41
+
42
+ ### 1. Run the generator
43
+
44
+ ```bash
45
+ rails generate behavior_analytics:install
46
+ ```
47
+
48
+ This will:
49
+ - Create a migration for the `behavior_events` table
50
+ - Create an initializer at `config/initializers/behavior_analytics.rb`
51
+ - Create a model at `app/models/behavior_analytics_event.rb`
52
+
53
+ ### 2. Run the migration
54
+
55
+ ```bash
56
+ rails db:migrate
57
+ ```
58
+
59
+ ### 3. Configure the initializer
60
+
61
+ Edit `config/initializers/behavior_analytics.rb`:
62
+
63
+ ```ruby
64
+ BehaviorAnalytics.configure do |config|
65
+ # Configure storage adapter (required)
66
+ config.storage_adapter = BehaviorAnalytics::Storage::ActiveRecordAdapter.new(
67
+ model_class: BehaviorAnalyticsEvent
68
+ )
69
+
70
+ # Configure batching
71
+ config.batch_size = 100
72
+ config.flush_interval = 300 # 5 minutes
73
+
74
+ # Configure context resolver (optional)
75
+ config.context_resolver = ->(request) {
76
+ {
77
+ tenant_id: current_tenant&.id,
78
+ user_id: current_user&.id,
79
+ user_type: current_user&.account_type
80
+ }
81
+ }
82
+
83
+ # Configure engagement scoring weights
84
+ config.scoring_weights = {
85
+ activity: 0.4,
86
+ unique_users: 0.3,
87
+ feature_diversity: 0.2,
88
+ time_in_trial: 0.1
89
+ }
90
+ end
91
+ ```
92
+
93
+ ### 4. Include in ApplicationController
94
+
95
+ ```ruby
96
+ class ApplicationController < ActionController::Base
97
+ include BehaviorAnalytics::Integrations::Rails
98
+ end
99
+ ```
100
+
101
+ ## Usage
102
+
103
+ ### Basic Tracking
104
+
105
+ ```ruby
106
+ # Create a tracker
107
+ tracker = BehaviorAnalytics.create_tracker
108
+
109
+ # Create a context
110
+ context = BehaviorAnalytics::Context.new(
111
+ tenant_id: "org_123",
112
+ user_id: "user_456",
113
+ user_type: "trial"
114
+ )
115
+
116
+ # Track a custom event
117
+ tracker.track(
118
+ context: context,
119
+ event_name: "project_created",
120
+ metadata: { project_id: 789 }
121
+ )
122
+
123
+ # Track an API call
124
+ tracker.track_api_call(
125
+ context: context,
126
+ method: "POST",
127
+ path: "/api/projects",
128
+ status_code: 201,
129
+ duration_ms: 150
130
+ )
131
+
132
+ # Track feature usage
133
+ tracker.track_feature_usage(
134
+ context: context,
135
+ feature: "advanced_search",
136
+ metadata: { query: "..." }
137
+ )
138
+
139
+ # Flush buffered events
140
+ tracker.flush
141
+ ```
142
+
143
+ ### Analytics
144
+
145
+ ```ruby
146
+ analytics = tracker.analytics
147
+
148
+ # Basic counts
149
+ event_count = analytics.event_count(context, since: 7.days.ago)
150
+ unique_users = analytics.unique_users(context)
151
+ active_days = analytics.active_days(context)
152
+
153
+ # Engagement scoring
154
+ score = analytics.engagement_score(context)
155
+ # => 75.5
156
+
157
+ # Time-based analytics
158
+ timeline = analytics.activity_timeline(context, period: :daily)
159
+ # => { 2024-01-01 => 10, 2024-01-02 => 15, ... }
160
+
161
+ daily = analytics.daily_activity(context, date_range: 7.days.ago..Time.current)
162
+
163
+ # Feature usage
164
+ feature_stats = analytics.feature_usage_stats(context)
165
+ # => { "projects" => 25, "search" => 10, ... }
166
+
167
+ top_features = analytics.top_features(context, limit: 10)
168
+ ```
169
+
170
+ ### Query Interface
171
+
172
+ ```ruby
173
+ query = tracker.query
174
+
175
+ # Build complex queries
176
+ events = query
177
+ .for_tenant("org_123")
178
+ .for_user_type("trial")
179
+ .with_event_type(:feature_usage)
180
+ .since(7.days.ago)
181
+ .limit(100)
182
+ .execute
183
+
184
+ # Count events
185
+ count = query
186
+ .for_tenant("org_123")
187
+ .with_event_name("project_created")
188
+ .count
189
+ ```
190
+
191
+ ### Custom Storage Adapter
192
+
193
+ ```ruby
194
+ class MyCustomAdapter < BehaviorAnalytics::Storage::Adapter
195
+ def save_events(events)
196
+ # Your implementation
197
+ end
198
+
199
+ def events_for_context(context, options = {})
200
+ # Your implementation
201
+ end
202
+
203
+ # ... implement other required methods
204
+ end
205
+
206
+ tracker = BehaviorAnalytics.create_tracker(
207
+ storage_adapter: MyCustomAdapter.new
208
+ )
209
+ ```
210
+
211
+ ## Configuration Options
212
+
213
+ - `storage_adapter`: Storage adapter instance (required)
214
+ - `batch_size`: Number of events to buffer before flushing (default: 100)
215
+ - `flush_interval`: Seconds between automatic flushes (default: 300)
216
+ - `context_resolver`: Lambda/proc to resolve context from requests
217
+ - `scoring_weights`: Hash of weights for engagement scoring
218
+
219
+ ## Event Types
220
+
221
+ - `:api_call` - HTTP API requests
222
+ - `:feature_usage` - Feature usage events
223
+ - `:custom` - Custom business events
224
+
225
+ ## Context
226
+
227
+ The `Context` class encapsulates tracking context:
228
+
229
+ - `tenant_id` (required) - Multi-tenant identifier
230
+ - `user_id` (optional) - User identifier
231
+ - `user_type` (optional) - User type (e.g., "trial", "premium", "admin")
232
+ - `filters` (optional) - Hash of custom filter criteria
233
+
234
+ ## Development
235
+
236
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
237
+
238
+ To install this gem onto your local machine, run `bundle exec rake install`.
239
+
240
+ ## Contributing
241
+
242
+ Bug reports and pull requests are welcome on GitHub at https://github.com/nerdawey/behavior_analytics.
243
+
244
+ ## License
245
+
246
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/behavior_analytics/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "behavior_analytics"
7
+ spec.version = BehaviorAnalytics::VERSION
8
+ spec.authors = ["nerdawey"]
9
+ spec.email = ["nerdawy@icloud.com"]
10
+
11
+ spec.summary = "Track user behavior events with flexible context filtering and comprehensive analytics"
12
+ spec.description = "A Ruby gem for tracking user behavior events with multi-tenant support, " \
13
+ "computing analytics (engagement scores, time-based trends, feature usage), " \
14
+ "and supporting API calls, feature usage, and custom events."
15
+ spec.homepage = "https://github.com/nerdawey/behavior_analytics"
16
+ spec.required_ruby_version = ">= 3.0.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ # Runtime dependencies
35
+ spec.add_dependency "activesupport", ">= 6.0"
36
+
37
+ # Development dependencies
38
+ spec.add_development_dependency "activerecord", ">= 6.0"
39
+ spec.add_development_dependency "rspec", "~> 3.12"
40
+ spec.add_development_dependency "sqlite3", "~> 1.6"
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBehaviorEvents < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :behavior_events do |t|
6
+ t.string :tenant_id, null: false
7
+ t.string :user_id
8
+ t.string :user_type
9
+ t.string :event_name, null: false
10
+ t.string :event_type, null: false
11
+ t.jsonb :metadata, default: {}
12
+ t.string :session_id
13
+ t.string :ip
14
+ t.string :user_agent
15
+ t.integer :duration_ms
16
+ t.datetime :created_at, null: false
17
+ end
18
+
19
+ add_index :behavior_events, :tenant_id
20
+ add_index :behavior_events, :user_id
21
+ add_index :behavior_events, :user_type
22
+ add_index :behavior_events, :event_name
23
+ add_index :behavior_events, :event_type
24
+ add_index :behavior_events, :session_id
25
+ add_index :behavior_events, :created_at
26
+ add_index :behavior_events, [:tenant_id, :created_at]
27
+ add_index :behavior_events, [:tenant_id, :user_id, :created_at]
28
+ add_index :behavior_events, [:tenant_id, :event_name, :created_at]
29
+ end
30
+ end
31
+
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "active_support/core_ext/date"
6
+ require "active_support/core_ext/time"
7
+
8
+ module BehaviorAnalytics
9
+ module Analytics
10
+ class Engine
11
+ def initialize(storage_adapter)
12
+ @storage_adapter = storage_adapter
13
+ end
14
+
15
+ def event_count(context, options = {})
16
+ normalized_context = normalize_context(context)
17
+ normalized_context.validate!
18
+ @storage_adapter.event_count(normalized_context, options)
19
+ end
20
+
21
+ def unique_users(context, options = {})
22
+ normalized_context = normalize_context(context)
23
+ normalized_context.validate!
24
+ @storage_adapter.unique_users(normalized_context, options)
25
+ end
26
+
27
+ def active_days(context, options = {})
28
+ normalized_context = normalize_context(context)
29
+ normalized_context.validate!
30
+
31
+ events = @storage_adapter.events_for_context(normalized_context, options)
32
+ return 0 if events.empty?
33
+
34
+ dates = events.map { |e| date_from_event(e) }.compact.uniq
35
+ dates.count
36
+ end
37
+
38
+ def engagement_score(context, options = {})
39
+ normalized_context = normalize_context(context)
40
+ normalized_context.validate!
41
+
42
+ weights = options[:weights] || BehaviorAnalytics.configuration.scoring_weights
43
+
44
+ total_events = event_count(normalized_context, options)
45
+ unique_users_count = unique_users(normalized_context, options)
46
+ active_days_count = active_days(normalized_context, options)
47
+ feature_diversity = feature_count(normalized_context, options)
48
+
49
+ events_score = [total_events / 100.0, 1.0].min
50
+ users_score = [unique_users_count / 10.0, 1.0].min
51
+ days_score = [active_days_count / 7.0, 1.0].min
52
+ features_score = [feature_diversity / 5.0, 1.0].min
53
+
54
+ score = (events_score * weights[:activity]) +
55
+ (users_score * weights[:unique_users]) +
56
+ (days_score * weights[:time_in_trial]) +
57
+ (features_score * weights[:feature_diversity])
58
+
59
+ (score * 100).round(2)
60
+ end
61
+
62
+ def activity_timeline(context, options = {})
63
+ period = options.delete(:period) || :daily
64
+ normalized_context = normalize_context(context)
65
+ normalized_context.validate!
66
+
67
+ events = @storage_adapter.events_for_context(normalized_context, options)
68
+ return [] if events.empty?
69
+
70
+ grouped = case period
71
+ when :hourly
72
+ group_by_hour(events)
73
+ when :daily
74
+ group_by_day(events)
75
+ when :weekly
76
+ group_by_week(events)
77
+ when :monthly
78
+ group_by_month(events)
79
+ else
80
+ group_by_day(events)
81
+ end
82
+
83
+ grouped.map { |period_key, period_events| [period_key, period_events.count] }.to_h
84
+ end
85
+
86
+ def daily_activity(context, options = {})
87
+ normalized_context = normalize_context(context)
88
+ normalized_context.validate!
89
+
90
+ options = options.dup
91
+ if options[:date_range]
92
+ date_range = options.delete(:date_range)
93
+ options[:since] = date_range.begin
94
+ options[:until] = date_range.end
95
+ end
96
+
97
+ activity_timeline(normalized_context, options.merge(period: :daily))
98
+ end
99
+
100
+ def feature_usage_stats(context, options = {})
101
+ normalized_context = normalize_context(context)
102
+ normalized_context.validate!
103
+
104
+ events = @storage_adapter.events_for_context(
105
+ normalized_context,
106
+ options.merge(event_type: :feature_usage)
107
+ )
108
+
109
+ feature_counts = {}
110
+ events.each do |event|
111
+ feature = event[:metadata]&.dig("feature") || event[:metadata]&.dig(:feature)
112
+ next unless feature
113
+
114
+ feature_counts[feature] ||= 0
115
+ feature_counts[feature] += 1
116
+ end
117
+
118
+ feature_counts
119
+ end
120
+
121
+ def top_features(context, options = {})
122
+ limit = options.delete(:limit) || 10
123
+ stats = feature_usage_stats(context, options)
124
+ stats.sort_by { |_feature, count| -count }.first(limit).to_h
125
+ end
126
+
127
+ private
128
+
129
+ def normalize_context(context)
130
+ return context if context.is_a?(Context)
131
+ Context.new(context)
132
+ end
133
+
134
+ def date_from_event(event)
135
+ created_at = event[:created_at] || event["created_at"]
136
+ return nil unless created_at
137
+
138
+ if created_at.is_a?(String)
139
+ created_at = Time.parse(created_at)
140
+ elsif created_at.is_a?(Time)
141
+ created_at = created_at
142
+ end
143
+
144
+ created_at.to_date if created_at.respond_to?(:to_date)
145
+ rescue
146
+ nil
147
+ end
148
+
149
+ def group_by_hour(events)
150
+ events.group_by do |event|
151
+ created_at = event[:created_at] || event["created_at"]
152
+ next nil unless created_at
153
+ time = created_at.is_a?(Time) ? created_at : Time.parse(created_at.to_s)
154
+ time.beginning_of_hour
155
+ end.compact
156
+ end
157
+
158
+ def group_by_day(events)
159
+ events.group_by { |event| date_from_event(event) }.compact
160
+ end
161
+
162
+ def group_by_week(events)
163
+ events.group_by do |event|
164
+ date = date_from_event(event)
165
+ date&.beginning_of_week if date
166
+ end.compact
167
+ end
168
+
169
+ def group_by_month(events)
170
+ events.group_by do |event|
171
+ date = date_from_event(event)
172
+ date&.beginning_of_month if date
173
+ end.compact
174
+ end
175
+
176
+ def feature_count(context, options = {})
177
+ stats = feature_usage_stats(context, options)
178
+ stats.keys.count
179
+ end
180
+ end
181
+ end
182
+ end
183
+
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ class Context
5
+ attr_accessor :tenant_id, :user_id, :user_type, :filters
6
+
7
+ def initialize(attributes = {})
8
+ @tenant_id = attributes[:tenant_id] || attributes[:tenant]
9
+ @user_id = attributes[:user_id] || attributes[:user]
10
+ @user_type = attributes[:user_type]
11
+ @filters = attributes[:filters] || {}
12
+ end
13
+
14
+ def to_h
15
+ {
16
+ tenant_id: tenant_id,
17
+ user_id: user_id,
18
+ user_type: user_type,
19
+ filters: filters
20
+ }.compact
21
+ end
22
+
23
+ def valid?
24
+ !tenant_id.nil? && !tenant_id.empty?
25
+ end
26
+
27
+ def validate!
28
+ raise Error, "tenant_id is required in context" unless valid?
29
+ end
30
+ end
31
+ end
32
+
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BehaviorAnalytics
6
+ class Event
7
+ EVENT_TYPES = %i[api_call feature_usage custom].freeze
8
+
9
+ attr_accessor :id, :tenant_id, :user_id, :user_type, :event_name, :event_type,
10
+ :metadata, :session_id, :ip, :user_agent, :duration_ms, :created_at
11
+
12
+ def initialize(attributes = {})
13
+ @id = attributes[:id] || SecureRandom.uuid
14
+ @tenant_id = attributes[:tenant_id]
15
+ @user_id = attributes[:user_id]
16
+ @user_type = attributes[:user_type]
17
+ @event_name = attributes[:event_name]
18
+ @event_type = attributes[:event_type] || :custom
19
+ @metadata = attributes[:metadata] || {}
20
+ @session_id = attributes[:session_id]
21
+ @ip = attributes[:ip]
22
+ @user_agent = attributes[:user_agent]
23
+ @duration_ms = attributes[:duration_ms]
24
+ @created_at = attributes[:created_at] || Time.now
25
+
26
+ validate!
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ id: id,
32
+ tenant_id: tenant_id,
33
+ user_id: user_id,
34
+ user_type: user_type,
35
+ event_name: event_name,
36
+ event_type: event_type,
37
+ metadata: metadata,
38
+ session_id: session_id,
39
+ ip: ip,
40
+ user_agent: user_agent,
41
+ duration_ms: duration_ms,
42
+ created_at: created_at
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def validate!
49
+ raise Error, "tenant_id is required" if tenant_id.nil? || tenant_id.empty?
50
+ raise Error, "event_name is required" if event_name.nil? || event_name.empty?
51
+ raise Error, "event_type must be one of: #{EVENT_TYPES.join(', ')}" unless EVENT_TYPES.include?(event_type)
52
+ end
53
+ end
54
+ end
55
+