active_subscriber 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.
@@ -0,0 +1,193 @@
1
+ # ActiveSubscriber Installation Guide for Rails Apps
2
+
3
+ ## Step 1: Add to Gemfile
4
+
5
+ Add this line to your Rails application's Gemfile:
6
+
7
+ ```ruby
8
+ # If you're testing locally, use the path to this gem
9
+ gem 'active_subscriber', path: '/path/to/active_subscriber'
10
+
11
+ # Or if you've published it to a git repository
12
+ # gem 'active_subscriber', git: 'https://github.com/afomera/active_subscriber.git'
13
+ ```
14
+
15
+ ## Step 2: Bundle Install
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Step 3: Run the Install Generator
22
+
23
+ ```bash
24
+ rails generate active_subscriber:install
25
+ ```
26
+
27
+ This will create:
28
+
29
+ - `config/initializers/active_subscriber.rb` - Configuration file
30
+ - `app/subscribers/` directory - Where your subscribers go
31
+
32
+ ## Step 4: Test the Installation
33
+
34
+ ### Option A: Use the Rails Console Test Script
35
+
36
+ 1. Copy the contents of `rails_console_test.rb`
37
+ 2. Open your Rails console: `rails console`
38
+ 3. Paste and run the test script
39
+
40
+ ### Option B: Manual Testing
41
+
42
+ In your Rails console:
43
+
44
+ ```ruby
45
+ # 1. Check if ActiveSubscriber is loaded
46
+ ActiveSubscriber::VERSION
47
+
48
+ # 2. Create a test subscriber
49
+ class MyTestSubscriber < ActiveSubscriber::Base
50
+ subscribe_to :my_test_event
51
+
52
+ def handle_my_test_event(data)
53
+ puts "Received event: #{data}"
54
+ end
55
+ end
56
+
57
+ # 3. Register it
58
+ ActiveSubscriber.registry.register(MyTestSubscriber)
59
+
60
+ # 4. Create a publisher
61
+ class MyPublisher
62
+ include ActiveSubscriber::Publisher
63
+ end
64
+
65
+ # 5. Publish an event
66
+ MyPublisher.publish_event("my_test_event", { message: "Hello!" })
67
+ ```
68
+
69
+ ## Step 5: Create Your First Real Subscriber
70
+
71
+ ```bash
72
+ rails generate active_subscriber:subscriber Analytics --events user_signed_in user_signed_up
73
+ ```
74
+
75
+ This creates `app/subscribers/analytics_subscriber.rb`
76
+
77
+ ## Step 6: Use in Your Application
78
+
79
+ ### In a Service:
80
+
81
+ ```ruby
82
+ class UserService
83
+ include ActiveSubscriber::Publisher
84
+
85
+ def sign_in_user(user)
86
+ # ... sign in logic ...
87
+ publish_event("user_signed_in", { user_id: user.id })
88
+ end
89
+ end
90
+ ```
91
+
92
+ ### In a Controller:
93
+
94
+ ```ruby
95
+ class ApplicationController < ActionController::Base
96
+ include ActiveSubscriber::Helpers::AnalyticsHelper
97
+ end
98
+
99
+ class PostsController < ApplicationController
100
+ def show
101
+ @post = Post.find(params[:id])
102
+ track_analytics("post.viewed", { post_id: @post.id })
103
+ end
104
+ end
105
+ ```
106
+
107
+ ## Troubleshooting
108
+
109
+ ### Issue: Events not being received
110
+
111
+ 1. **Check if ActiveSubscriber is enabled:**
112
+
113
+ ```ruby
114
+ ActiveSubscriber.enabled?
115
+ ```
116
+
117
+ 2. **Check if subscribers are registered:**
118
+
119
+ ```ruby
120
+ ActiveSubscriber.registry.subscribers
121
+ ```
122
+
123
+ 3. **Check ActiveSupport::Notifications directly:**
124
+
125
+ ```ruby
126
+ ActiveSupport::Notifications.instrument("test", { data: "test" })
127
+ ```
128
+
129
+ 4. **Check Rails logs** for any error messages
130
+
131
+ ### Issue: Subscribers not auto-loading
132
+
133
+ 1. **Check configuration:**
134
+
135
+ ```ruby
136
+ ActiveSubscriber.configuration.auto_load
137
+ ActiveSubscriber.configuration.subscriber_paths
138
+ ```
139
+
140
+ 2. **Manually load subscribers:**
141
+
142
+ ```ruby
143
+ ActiveSubscriber.loader.load_subscribers
144
+ ```
145
+
146
+ 3. **Check if files exist:**
147
+ ```ruby
148
+ Dir.glob(Rails.root.join("app/subscribers/**/*.rb"))
149
+ ```
150
+
151
+ ### Issue: Rails engine not loading
152
+
153
+ 1. **Check if the engine is loaded:**
154
+
155
+ ```ruby
156
+ Rails.application.railties.map(&:class)
157
+ ```
158
+
159
+ 2. **Manually require the engine:**
160
+ ```ruby
161
+ require 'active_subscriber/engine'
162
+ ```
163
+
164
+ ## Configuration Options
165
+
166
+ In `config/initializers/active_subscriber.rb`:
167
+
168
+ ```ruby
169
+ ActiveSubscriber.configure do |config|
170
+ # Enable/disable the entire system
171
+ config.enabled = true
172
+
173
+ # Paths to look for subscribers
174
+ config.subscriber_paths = ["app/subscribers", "lib/subscribers"]
175
+
176
+ # Filter events (optional)
177
+ config.event_filter = ->(event_name) {
178
+ !event_name.start_with?("internal.")
179
+ }
180
+
181
+ # Auto-load subscribers on startup
182
+ config.auto_load = true
183
+ end
184
+ ```
185
+
186
+ ## Next Steps
187
+
188
+ 1. Run the test script to verify everything works
189
+ 2. Create your first subscriber using the generator
190
+ 3. Start publishing events from your services/controllers
191
+ 4. Monitor Rails logs to see events being processed
192
+
193
+ If you encounter any issues, run the Rails console test script first to diagnose the problem.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Andrea Fomera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # ActiveSubscriber
2
+
3
+ ActiveSubscriber provides a clean interface for implementing the publisher-subscriber pattern in Rails applications using ActiveSupport Notifications, with automatic subscriber discovery and lifecycle callbacks.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'active_subscriber'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install active_subscriber
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ 1. **Install ActiveSubscriber:**
28
+
29
+ ```bash
30
+ rails generate active_subscriber:install
31
+ ```
32
+
33
+ 2. **Create a subscriber:**
34
+
35
+ ```bash
36
+ rails generate active_subscriber:subscriber Analytics --events user_signed_in user_signed_up
37
+ ```
38
+
39
+ 3. **Include the Publisher module in your services:**
40
+
41
+ ```ruby
42
+ class AnalyticsService
43
+ include ActiveSubscriber::Publisher
44
+
45
+ def self.track_analytics(event_name, properties = {}, context: analytics_context)
46
+ publish_event(event_name, properties, context)
47
+ end
48
+
49
+ def self.track_analytics_with_timing(event_name, properties = {}, context = {}, &block)
50
+ publish_event_with_timing(event_name, properties, context, &block)
51
+ end
52
+ end
53
+ ```
54
+
55
+ 4. **Include the helper in your controllers:**
56
+
57
+ ```ruby
58
+ class PostController < ApplicationController
59
+ include ActiveSubscriber::Helpers::AnalyticsHelper
60
+
61
+ def index
62
+ @posts = Post.all
63
+ track_analytics("posts.viewed", { count: @posts.count })
64
+ end
65
+ end
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ ### Creating Subscribers
71
+
72
+ Subscribers inherit from `ActiveSubscriber::Base` and can subscribe to specific events or event patterns:
73
+
74
+ ```ruby
75
+ class LoggingSubscriber < ActiveSubscriber::Base
76
+ subscribe_to :user_signed_in, :user_signed_up
77
+ subscribe_to_pattern "analytics.*"
78
+
79
+ # Lifecycle callbacks
80
+ after_handle_event :increment_stats
81
+ around_handle_event :with_timing
82
+ before_handle_event :setup_context
83
+
84
+ def handle_user_signed_in(data)
85
+ Rails.logger.info "User signed in: #{data[:payload][:user_id]}"
86
+ end
87
+
88
+ def handle_user_signed_up(data)
89
+ Rails.logger.info "New user signed up: #{data[:payload][:user_id]}"
90
+ end
91
+
92
+ def handle_all_events(event_name, data)
93
+ Rails.logger.info "Analytics event: #{event_name}"
94
+ end
95
+
96
+ private
97
+
98
+ def increment_stats
99
+ # Called after handling any event
100
+ end
101
+
102
+ def with_timing
103
+ start_time = Time.current
104
+ yield
105
+ duration = Time.current - start_time
106
+ Rails.logger.debug "Event handled in #{duration * 1000}ms"
107
+ end
108
+
109
+ def setup_context
110
+ # Called before handling any event
111
+ end
112
+ end
113
+ ```
114
+
115
+ ### Publishing Events
116
+
117
+ #### Using the Publisher Module
118
+
119
+ ```ruby
120
+ class UserService
121
+ include ActiveSubscriber::Publisher
122
+
123
+ def sign_in_user(user)
124
+ # ... sign in logic ...
125
+
126
+ publish_event("user_signed_in", { user_id: user.id }, { ip: request.ip })
127
+ end
128
+
129
+ def create_user(params)
130
+ result = publish_event_with_timing("user_creation", { email: params[:email] }) do
131
+ User.create!(params)
132
+ end
133
+
134
+ publish_event("user_signed_up", { user_id: result.id })
135
+ result
136
+ end
137
+ end
138
+ ```
139
+
140
+ #### Using the Analytics Helper
141
+
142
+ ```ruby
143
+ class ApplicationController < ActionController::Base
144
+ include ActiveSubscriber::Helpers::AnalyticsHelper
145
+
146
+ def show
147
+ @post = Post.find(params[:id])
148
+ track_analytics("post.viewed", {
149
+ post_id: @post.id,
150
+ category: @post.category
151
+ })
152
+ end
153
+ end
154
+ ```
155
+
156
+ ### Event Data Structure
157
+
158
+ Events published through ActiveSubscriber include:
159
+
160
+ ```ruby
161
+ {
162
+ payload: { /* your custom data */ },
163
+ context: { /* additional context */ },
164
+ published_at: Time.current,
165
+ publisher: "YourServiceClass"
166
+ }
167
+ ```
168
+
169
+ When using the analytics helper, additional context is automatically added:
170
+
171
+ ```ruby
172
+ {
173
+ controller: "posts",
174
+ action: "show",
175
+ user_id: current_user&.id,
176
+ user_agent: request.user_agent,
177
+ ip_address: request.remote_ip,
178
+ # ... more request context
179
+ }
180
+ ```
181
+
182
+ ## Configuration
183
+
184
+ Configure ActiveSubscriber in `config/initializers/active_subscriber.rb`:
185
+
186
+ ```ruby
187
+ ActiveSubscriber.configure do |config|
188
+ # Enable or disable ActiveSubscriber (default: true)
189
+ config.enabled = true
190
+
191
+ # Namespace for Notification events
192
+ config.namespace = "my_app"
193
+
194
+ # Paths where subscribers are located (default: ["app/subscribers"])
195
+ config.subscriber_paths = ["app/subscribers", "lib/subscribers"]
196
+
197
+ # Filter events (optional)
198
+ config.event_filter = ->(event_name) { !event_name.start_with?("internal.") }
199
+
200
+ # Auto-load subscribers (default: true)
201
+ config.auto_load = true
202
+ end
203
+ ```
204
+
205
+ ## Lifecycle Callbacks
206
+
207
+ Subscribers support three types of lifecycle callbacks:
208
+
209
+ - `before_handle_event` - Called before handling any event
210
+ - `after_handle_event` - Called after handling any event
211
+ - `around_handle_event` - Wraps the event handling (must yield)
212
+
213
+ ```ruby
214
+ class TimingSubscriber < ActiveSubscriber::Base
215
+ subscribe_to_pattern ".*"
216
+
217
+ around_handle_event :with_timing
218
+ after_handle_event :log_completion
219
+
220
+ def handle_all_events(event_name, data)
221
+ # Handle the event
222
+ end
223
+
224
+ private
225
+
226
+ def with_timing
227
+ start_time = Time.current
228
+ yield
229
+ @duration = Time.current - start_time
230
+ end
231
+
232
+ def log_completion
233
+ Rails.logger.info "Event completed in #{@duration * 1000}ms"
234
+ end
235
+ end
236
+ ```
237
+
238
+ ## Pattern Matching
239
+
240
+ Subscribe to events using patterns with wildcards:
241
+
242
+ ```ruby
243
+ class PatternSubscriber < ActiveSubscriber::Base
244
+ subscribe_to_pattern "user.*" # Matches user.created, user.updated, etc.
245
+ subscribe_to_pattern "analytics.*" # Matches analytics.page_view, analytics.click, etc.
246
+ subscribe_to_pattern ".*" # Matches all events
247
+
248
+ def handle_all_events(event_name, data)
249
+ case event_name
250
+ when /^user\./
251
+ handle_user_event(event_name, data)
252
+ when /^analytics\./
253
+ handle_analytics_event(event_name, data)
254
+ end
255
+ end
256
+ end
257
+ ```
258
+
259
+ ## Error Handling
260
+
261
+ ActiveSubscriber includes built-in error handling. If a subscriber raises an exception, it will be logged but won't prevent other subscribers from processing the event.
262
+
263
+ Errors are logged to `Rails.logger` with the format:
264
+
265
+ ```
266
+ ActiveSubscriber: Error handling event 'event_name': Error message
267
+ ```
268
+
269
+ ## Thread Safety
270
+
271
+ ActiveSubscriber is designed to be thread-safe:
272
+
273
+ - The subscriber registry uses a mutex for thread-safe registration
274
+ - No shared mutable state in core components
275
+ - Each subscriber instance handles events independently
276
+
277
+ ## Acknowledgements
278
+
279
+ This gem was inspired by a project at a previous role that needed a clean way to handle pub/sub notifications for third-party analytics. ActiveSubscriber is written entirely on personal time and resources, but the design is heavily informed by the needs of that work — along with my own.
280
+
281
+ ## Development
282
+
283
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
284
+
285
+ ## Contributing
286
+
287
+ Bug reports and pull requests are welcome on GitHub at https://github.com/afomera/active_subscriber.
data/REQUIREMENTS.md ADDED
@@ -0,0 +1,60 @@
1
+ # ActiveSubscriber requirements
2
+
3
+ Implement pub/sub pattern with ActiveSupport Notifications for apps to implement Subscribers to handle event logic.
4
+
5
+ So ideally..
6
+
7
+ ```ruby
8
+ class AnalyticsService
9
+ include ActiveSubscriber::Publisher
10
+
11
+ def self.track_analytics(event_name, properties = {}, context: analytics_context)
12
+ publish_event(event_name, properties, context)
13
+ end
14
+
15
+ def self.track_analytics_with_timing(event_name, properties..., &block)
16
+ # ...
17
+ end
18
+ end
19
+ ```
20
+
21
+ ```ruby
22
+ class PostController
23
+ include AnalyticsHelper
24
+
25
+ def index
26
+ @post = Post.all
27
+ track_analytics("viewed_post", { post_id: @post.id })
28
+ end
29
+ end
30
+ ```
31
+
32
+ ```ruby
33
+ # app/subscribers/logging_subscriber.rb
34
+ class LoggingSubscriber < ActiveSubscriber::Base
35
+ subscribe_to :user_signed_in, :user_signed_up
36
+ subscribe_to_pattern ".*"
37
+
38
+ after_handle_event :increment_stats
39
+ around_handle_event :....
40
+ before_handle_event :....
41
+
42
+ def handle_user_signed_in(context)
43
+ # called only on user_signed_in event automatically if it responds to it
44
+ end
45
+
46
+ def handle_all_events(event_name, context)
47
+ # called for all events
48
+ end
49
+
50
+ private
51
+
52
+ def increment_stats
53
+ puts "Increment stats counter"
54
+ end
55
+ end
56
+ ```
57
+
58
+ There should be a job that handles sending the handle\_ methods, and it should be able to be put in the background for performance reasons.
59
+
60
+ Also needs a way to configure the namespace for the active support notifications and whatnot. We'd have external subscribers like UserPilot and Mixpanel in the future
data/Rakefile ADDED
@@ -0,0 +1,12 @@
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
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSubscriber
4
+ class Base
5
+ class << self
6
+ def subscribed_events
7
+ @subscribed_events ||= []
8
+ end
9
+
10
+ def subscribed_patterns
11
+ @subscribed_patterns ||= []
12
+ end
13
+
14
+ def before_handle_callbacks
15
+ @before_handle_callbacks ||= []
16
+ end
17
+
18
+ def after_handle_callbacks
19
+ @after_handle_callbacks ||= []
20
+ end
21
+
22
+ def around_handle_callbacks
23
+ @around_handle_callbacks ||= []
24
+ end
25
+
26
+ def subscribe_to(*event_names)
27
+ @subscribed_events ||= []
28
+ @subscribed_events.concat(event_names.map(&:to_s))
29
+ @subscribed_events.uniq!
30
+
31
+ if defined?(ActiveSubscriber) && ActiveSubscriber.respond_to?(:configuration) && ActiveSubscriber.configuration.auto_load
32
+ ActiveSubscriber.registry.register(self)
33
+ end
34
+ end
35
+
36
+ def subscribe_to_pattern(pattern)
37
+ @subscribed_patterns ||= []
38
+ pattern_regex = case pattern
39
+ when String
40
+ Regexp.new(pattern.gsub('*', '.*'))
41
+ when Regexp
42
+ pattern
43
+ else
44
+ Regexp.new(pattern.to_s.gsub('*', '.*'))
45
+ end
46
+ @subscribed_patterns << pattern_regex
47
+ @subscribed_patterns.uniq!
48
+
49
+ if defined?(ActiveSubscriber) && ActiveSubscriber.respond_to?(:configuration) && ActiveSubscriber.configuration.auto_load
50
+ ActiveSubscriber.registry.register(self)
51
+ end
52
+ end
53
+
54
+ def before_handle_event(method_name)
55
+ @before_handle_callbacks ||= []
56
+ @before_handle_callbacks << method_name
57
+ end
58
+
59
+ def after_handle_event(method_name)
60
+ @after_handle_callbacks ||= []
61
+ @after_handle_callbacks << method_name
62
+ end
63
+
64
+ def around_handle_event(method_name)
65
+ @around_handle_callbacks ||= []
66
+ @around_handle_callbacks << method_name
67
+ end
68
+ end
69
+
70
+ def handle_event(event_name, data)
71
+ run_callbacks do
72
+ handler_method = "handle_#{event_name.gsub('.', '_').gsub('-', '_')}"
73
+
74
+ if respond_to?(handler_method, true)
75
+ send(handler_method, data)
76
+ elsif respond_to?(:handle_all_events, true)
77
+ handle_all_events(event_name, data)
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def run_callbacks(&block)
85
+ # Run before callbacks
86
+ self.class.before_handle_callbacks.each do |callback|
87
+ next unless respond_to?(callback, true)
88
+
89
+ begin
90
+ send(callback)
91
+ rescue StandardError => e
92
+ ActiveSubscriber.logger.error "ActiveSubscriber: Error in before callback '#{callback}': #{e.message}"
93
+ ActiveSubscriber.logger.error e.backtrace.join("\n") if e.backtrace
94
+ end
95
+ end
96
+
97
+ # Run around callbacks or yield directly
98
+ if self.class.around_handle_callbacks.any?
99
+ run_around_callbacks(0, &block)
100
+ else
101
+ yield
102
+ end
103
+
104
+ # Run after callbacks
105
+ self.class.after_handle_callbacks.each do |callback|
106
+ next unless respond_to?(callback, true)
107
+
108
+ begin
109
+ send(callback)
110
+ rescue StandardError => e
111
+ ActiveSubscriber.logger.error "ActiveSubscriber: Error in after callback '#{callback}': #{e.message}"
112
+ ActiveSubscriber.logger.error e.backtrace.join("\n") if e.backtrace
113
+ end
114
+ end
115
+ end
116
+
117
+ def run_around_callbacks(index, &block)
118
+ if index < self.class.around_handle_callbacks.length
119
+ callback = self.class.around_handle_callbacks[index]
120
+ if respond_to?(callback, true)
121
+ begin
122
+ send(callback) do
123
+ run_around_callbacks(index + 1, &block)
124
+ end
125
+ rescue StandardError => e
126
+ ActiveSubscriber.logger.error "ActiveSubscriber: Error in around callback '#{callback}': #{e.message}"
127
+ ActiveSubscriber.logger.error e.backtrace.join("\n") if e.backtrace
128
+ run_around_callbacks(index + 1, &block)
129
+ end
130
+ else
131
+ run_around_callbacks(index + 1, &block)
132
+ end
133
+ else
134
+ yield
135
+ end
136
+ end
137
+ end
138
+ end