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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSubscriber
4
+ class Configuration
5
+ attr_reader :subscriber_paths, :namespace
6
+ attr_accessor :enabled, :event_filter, :auto_load, :async
7
+
8
+ def initialize
9
+ @enabled = true
10
+ @subscriber_paths = ['app/subscribers']
11
+ @event_filter = nil
12
+ @auto_load = true
13
+ @namespace = 'active_subscriber'
14
+ @async = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.production? : false
15
+ end
16
+
17
+ def subscriber_paths=(value)
18
+ raise ArgumentError, "subscriber_paths must be an Array, got #{value.class}" unless value.is_a?(Array)
19
+
20
+ @subscriber_paths = value
21
+ end
22
+
23
+ def namespace=(value)
24
+ unless value.nil? || value.is_a?(String)
25
+ raise ArgumentError, "namespace must be a String or nil, got #{value.class}"
26
+ end
27
+
28
+ @namespace = value
29
+ end
30
+
31
+ def event_allowed?(event_name)
32
+ return true unless event_filter
33
+
34
+ case event_filter
35
+ when Proc
36
+ event_filter.call(event_name)
37
+ when String, Regexp
38
+ event_name.match?(event_filter)
39
+ else
40
+ true
41
+ end
42
+ end
43
+
44
+ def reset!
45
+ @enabled = true
46
+ @subscriber_paths = ['app/subscribers']
47
+ @event_filter = nil
48
+ @auto_load = true
49
+ @namespace = 'active_subscriber'
50
+ @async = false
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/engine'
4
+
5
+ module ActiveSubscriber
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace ActiveSubscriber
8
+
9
+ config.active_subscriber = ActiveSupport::OrderedOptions.new
10
+
11
+ initializer 'active_subscriber.configure' do |app|
12
+ ActiveSubscriber.configure do |config|
13
+ # Apply any configuration from Rails config
14
+ config.enabled = app.config.active_subscriber.enabled if app.config.active_subscriber.enabled.present?
15
+
16
+ if app.config.active_subscriber.subscriber_paths.present?
17
+ config.subscriber_paths = app.config.active_subscriber.subscriber_paths
18
+ end
19
+
20
+ if app.config.active_subscriber.event_filter.present?
21
+ config.event_filter = app.config.active_subscriber.event_filter
22
+ end
23
+
24
+ config.auto_load = app.config.active_subscriber.auto_load if app.config.active_subscriber.auto_load.present?
25
+ end
26
+ end
27
+
28
+ initializer 'active_subscriber.load_subscribers', after: :load_config_initializers do
29
+ ActiveSupport.on_load(:active_record) do
30
+ ActiveSubscriber.loader.load_subscribers
31
+ end
32
+ end
33
+
34
+ # Reload subscribers in development when files change
35
+ config.to_prepare do
36
+ ActiveSubscriber.loader.reload_subscribers if Rails.env.development?
37
+ end
38
+
39
+ # Add app/subscribers to autoload paths
40
+ config.before_configuration do
41
+ config.autoload_paths << Rails.root.join('app', 'subscribers') if Rails.root
42
+ end
43
+
44
+ # Ensure cleanup on application shutdown
45
+ at_exit do
46
+ ActiveSubscriber.registry.clear if defined?(ActiveSubscriber)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSubscriber
4
+ module Helpers
5
+ module AnalyticsHelper
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include ActiveSubscriber::Publisher
10
+ end
11
+
12
+ def track_analytics(event_name, properties = {}, context = {})
13
+ analytics_context = build_analytics_context.merge(context)
14
+ publish_event(event_name, properties, analytics_context)
15
+ end
16
+
17
+ def track_analytics_with_timing(event_name, properties = {}, context = {}, &block)
18
+ analytics_context = build_analytics_context.merge(context)
19
+ publish_event_with_timing(event_name, properties, analytics_context, &block)
20
+ end
21
+
22
+ private
23
+
24
+ def build_analytics_context
25
+ context = {
26
+ controller: controller_name,
27
+ action: action_name,
28
+ timestamp: Time.current
29
+ }
30
+
31
+ # Add request information if available
32
+ if defined?(request) && request
33
+ context.merge!(
34
+ user_agent: request.user_agent,
35
+ ip_address: request.remote_ip,
36
+ referer: request.referer,
37
+ request_id: request.request_id
38
+ )
39
+ end
40
+
41
+ # Add user information if current_user is available
42
+ if respond_to?(:current_user) && current_user
43
+ context[:user_id] = current_user.id
44
+ context[:user_type] = current_user.class.name
45
+ end
46
+
47
+ # Add session information if available
48
+ context[:session_id] = session.id if respond_to?(:session) && session.respond_to?(:id)
49
+
50
+ context
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ActiveSubscriber
6
+ class Loader
7
+ def initialize
8
+ @loaded_paths = Set.new
9
+ end
10
+
11
+ def load_subscribers
12
+ return unless ActiveSubscriber.configuration.auto_load
13
+
14
+ ActiveSubscriber.configuration.subscriber_paths.each do |path|
15
+ load_subscribers_from_path(path)
16
+ end
17
+ end
18
+
19
+ def reload_subscribers
20
+ ActiveSubscriber.registry.clear
21
+ @loaded_paths.clear
22
+ load_subscribers
23
+ end
24
+
25
+ private
26
+
27
+ def load_subscribers_from_path(path)
28
+ return unless Dir.exist?(path)
29
+ return if @loaded_paths.include?(path)
30
+
31
+ @loaded_paths << path
32
+
33
+ Dir.glob(File.join(path, '**', '*.rb')).each do |file|
34
+ load_subscriber_file(file)
35
+ end
36
+ end
37
+
38
+ def load_subscriber_file(file_path)
39
+ if defined?(Rails) && Rails.respond_to?(:env)
40
+ return if Rails.env.production? && !File.exist?(file_path)
41
+
42
+ if Rails.env.development?
43
+ load file_path
44
+ else
45
+ require file_path
46
+ end
47
+ else
48
+ require file_path
49
+ end
50
+ rescue LoadError, StandardError => e
51
+ ActiveSubscriber.logger.error "ActiveSubscriber: Failed to load subscriber file #{file_path}: #{e.message}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSubscriber
4
+ module Publisher
5
+ def publish_event(event_name, payload = {}, context = {})
6
+ return unless ActiveSubscriber.enabled?
7
+
8
+ namespaced_event = namespace_event(event_name)
9
+ return unless ActiveSubscriber.configuration.event_allowed?(namespaced_event)
10
+
11
+ event_data = {
12
+ payload: payload,
13
+ context: context,
14
+ published_at: Time.current,
15
+ publisher: self.class.name || 'Anonymous'
16
+ }
17
+
18
+ ActiveSupport::Notifications.instrument(namespaced_event, event_data)
19
+ end
20
+
21
+ def publish_event_with_timing(event_name, payload = {}, context = {})
22
+ return yield unless ActiveSubscriber.enabled?
23
+
24
+ namespaced_event = namespace_event(event_name)
25
+ return yield unless ActiveSubscriber.configuration.event_allowed?(namespaced_event)
26
+
27
+ start_time = Time.current
28
+ result = yield
29
+ end_time = Time.current
30
+
31
+ event_data = {
32
+ payload: payload,
33
+ context: context,
34
+ published_at: start_time,
35
+ completed_at: end_time,
36
+ duration: (end_time - start_time) * 1000,
37
+ publisher: self.class.name || 'Anonymous'
38
+ }
39
+
40
+ ActiveSupport::Notifications.instrument(namespaced_event, event_data)
41
+ result
42
+ end
43
+
44
+ private
45
+
46
+ def namespace_event(event_name)
47
+ namespace = ActiveSubscriber.configuration.namespace
48
+ return event_name.to_s if namespace.nil? || namespace.empty?
49
+
50
+ "#{namespace}.#{event_name}"
51
+ end
52
+
53
+ module ClassMethods
54
+ def publish_event(event_name, payload = {}, context = {})
55
+ return unless ActiveSubscriber.enabled?
56
+
57
+ namespaced_event = namespace_event(event_name)
58
+ return unless ActiveSubscriber.configuration.event_allowed?(namespaced_event)
59
+
60
+ event_data = {
61
+ payload: payload,
62
+ context: context,
63
+ published_at: Time.current,
64
+ publisher: name || 'Anonymous'
65
+ }
66
+
67
+ ActiveSupport::Notifications.instrument(namespaced_event, event_data)
68
+ end
69
+
70
+ def publish_event_with_timing(event_name, payload = {}, context = {})
71
+ return yield unless ActiveSubscriber.enabled?
72
+
73
+ namespaced_event = namespace_event(event_name)
74
+ return yield unless ActiveSubscriber.configuration.event_allowed?(namespaced_event)
75
+
76
+ start_time = Time.current
77
+ result = yield
78
+ end_time = Time.current
79
+
80
+ event_data = {
81
+ payload: payload,
82
+ context: context,
83
+ published_at: start_time,
84
+ completed_at: end_time,
85
+ duration: (end_time - start_time) * 1000,
86
+ publisher: name || 'Anonymous'
87
+ }
88
+
89
+ ActiveSupport::Notifications.instrument(namespaced_event, event_data)
90
+ result
91
+ end
92
+
93
+ private
94
+
95
+ def namespace_event(event_name)
96
+ namespace = ActiveSubscriber.configuration.namespace
97
+ return event_name.to_s if namespace.nil? || namespace.empty?
98
+
99
+ "#{namespace}.#{event_name}"
100
+ end
101
+ end
102
+
103
+ def self.included(base)
104
+ base.extend(ClassMethods)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSubscriber
4
+ class Registry
5
+ def initialize
6
+ @subscribers = []
7
+ @subscriptions = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def register(subscriber_class)
12
+ @mutex.synchronize do
13
+ if @subscribers.include?(subscriber_class)
14
+ cleanup_subscriptions(subscriber_class)
15
+ else
16
+ @subscribers << subscriber_class
17
+ end
18
+
19
+ setup_subscriptions(subscriber_class)
20
+ end
21
+ end
22
+
23
+ def unregister(subscriber_class)
24
+ @mutex.synchronize do
25
+ @subscribers.delete(subscriber_class)
26
+ cleanup_subscriptions(subscriber_class)
27
+ end
28
+ end
29
+
30
+ def subscribers
31
+ @subscribers.dup
32
+ end
33
+
34
+ def clear
35
+ @mutex.synchronize do
36
+ @subscriptions.each_value do |subscription|
37
+ ActiveSupport::Notifications.unsubscribe(subscription)
38
+ end
39
+ @subscriptions.clear
40
+ @subscribers.clear
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def setup_subscriptions(subscriber_class)
47
+ subscriber_class.subscribed_events.each do |event_name|
48
+ namespaced_event = namespace_event(event_name)
49
+ subscription = ActiveSupport::Notifications.subscribe(namespaced_event) do |name, _started, _finished, _unique_id, data|
50
+ next unless ActiveSubscriber.enabled?
51
+ next unless ActiveSubscriber.configuration.event_allowed?(name)
52
+
53
+ instance = subscriber_class.new
54
+ handle_event(instance, event_name, data)
55
+ end
56
+
57
+ if subscription
58
+ @subscriptions["#{subscriber_class.name}:#{event_name}"] = subscription
59
+ else
60
+ ActiveSubscriber.logger.warn "ActiveSubscriber: Failed to create subscription for #{event_name}"
61
+ end
62
+ rescue StandardError => e
63
+ ActiveSubscriber.logger.error "ActiveSubscriber: Error setting up subscription for #{event_name}: #{e.message}"
64
+ end
65
+
66
+ subscriber_class.subscribed_patterns.each do |pattern|
67
+ namespaced_pattern = namespace_pattern(pattern)
68
+ subscription = ActiveSupport::Notifications.subscribe(namespaced_pattern) do |name, _started, _finished, _unique_id, data|
69
+ next unless ActiveSubscriber.enabled?
70
+ next unless ActiveSubscriber.configuration.event_allowed?(name)
71
+
72
+ instance = subscriber_class.new
73
+ event_name = name.sub(/^#{ActiveSubscriber.configuration.namespace}\./, '')
74
+ handle_event(instance, event_name, data)
75
+ end
76
+
77
+ if subscription
78
+ @subscriptions["#{subscriber_class.name}:pattern:#{pattern}"] = subscription
79
+ else
80
+ ActiveSubscriber.logger.warn "ActiveSubscriber: Failed to create subscription for pattern #{pattern}"
81
+ end
82
+ rescue StandardError => e
83
+ ActiveSubscriber.logger.error "ActiveSubscriber: Error setting up subscription for pattern #{pattern}: #{e.message}"
84
+ end
85
+ end
86
+
87
+ def namespace_event(event_name)
88
+ namespace = ActiveSubscriber.configuration.namespace
89
+ return event_name.to_s if namespace.nil? || namespace.empty?
90
+
91
+ "#{namespace}.#{event_name}"
92
+ end
93
+
94
+ def namespace_pattern(pattern)
95
+ namespace = ActiveSubscriber.configuration.namespace
96
+ return pattern if namespace.nil? || namespace.empty?
97
+
98
+ case pattern
99
+ when Regexp
100
+ Regexp.new("#{Regexp.escape(namespace)}\\.#{pattern.source}", pattern.options)
101
+ else
102
+ Regexp.new("#{Regexp.escape(namespace)}\\.#{pattern}")
103
+ end
104
+ end
105
+
106
+ def cleanup_subscriptions(subscriber_class)
107
+ keys_to_remove = @subscriptions.keys.select { |key| key.start_with?("#{subscriber_class.name}:") }
108
+ keys_to_remove.each do |key|
109
+ ActiveSupport::Notifications.unsubscribe(@subscriptions[key])
110
+ @subscriptions.delete(key)
111
+ end
112
+ end
113
+
114
+ def handle_event(instance, event_name, data)
115
+ if ActiveSubscriber.configuration.async
116
+ ActiveSubscriber::SubscriberJob.perform_later(instance.class.name, event_name, data)
117
+ else
118
+ instance.handle_event(event_name, data)
119
+ end
120
+ rescue StandardError => e
121
+ ActiveSubscriber.logger.error "ActiveSubscriber: Error handling event '#{event_name}': #{e.message}"
122
+ ActiveSubscriber.logger.error e.backtrace.join("\n") if e.backtrace
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+
5
+ module ActiveSubscriber
6
+ class SubscriberJob < ActiveJob::Base
7
+ queue_as :default
8
+
9
+ def perform(subscriber_class_name, event_name, data)
10
+ subscriber_class = subscriber_class_name.safe_constantize
11
+
12
+ if subscriber_class.nil?
13
+ raise ArgumentError, "ActiveSubscriber::SubscriberJob: Unknown subscriber class '#{subscriber_class_name}'"
14
+ end
15
+
16
+ instance = subscriber_class.new
17
+ instance.handle_event(event_name, data)
18
+ rescue StandardError => e
19
+ ActiveSubscriber.logger.error "ActiveSubscriber::SubscriberJob: Error handling event '#{event_name}' with #{subscriber_class_name}: #{e.message}"
20
+ ActiveSubscriber.logger.error e.backtrace.join("\n") if e.backtrace
21
+ raise e
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSubscriber
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'active_support'
5
+ require 'active_support/notifications'
6
+
7
+ require_relative 'active_subscriber/version'
8
+ require_relative 'active_subscriber/configuration'
9
+ require_relative 'active_subscriber/subscriber_job'
10
+ require_relative 'active_subscriber/registry'
11
+ require_relative 'active_subscriber/publisher'
12
+ require_relative 'active_subscriber/base'
13
+ require_relative 'active_subscriber/loader'
14
+ require_relative 'active_subscriber/helpers/analytics_helper'
15
+
16
+ if defined?(Rails)
17
+ require_relative 'active_subscriber/engine'
18
+ end
19
+
20
+ module ActiveSubscriber
21
+ class << self
22
+ def configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ def configure
27
+ yield(configuration)
28
+ end
29
+
30
+ def registry
31
+ @registry ||= Registry.new
32
+ end
33
+
34
+ def loader
35
+ @loader ||= Loader.new
36
+ end
37
+
38
+ def enabled?
39
+ configuration.enabled
40
+ end
41
+
42
+ def logger
43
+ @logger ||= if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
44
+ Rails.logger
45
+ else
46
+ Logger.new($stdout)
47
+ end
48
+ end
49
+
50
+ attr_writer :logger
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ActiveSubscriber
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc 'Install ActiveSubscriber in your Rails application'
11
+
12
+ def create_initializer
13
+ template 'initializer.rb', 'config/initializers/active_subscriber.rb'
14
+ end
15
+
16
+ def create_subscribers_directory
17
+ empty_directory 'app/subscribers'
18
+ create_file 'app/subscribers/.keep', ''
19
+ end
20
+
21
+ def show_readme
22
+ readme 'README' if behavior == :invoke
23
+ end
24
+
25
+ private
26
+
27
+ def readme(path)
28
+ say File.read(File.join(File.dirname(__FILE__), 'templates', path))
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ActiveSubscriber
6
+ module Generators
7
+ class SubscriberGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc 'Generate a new ActiveSubscriber subscriber'
11
+
12
+ class_option :events, type: :array, default: [], desc: 'Events to subscribe to'
13
+ class_option :patterns, type: :array, default: [], desc: 'Event patterns to subscribe to'
14
+
15
+ def create_subscriber_file
16
+ template 'subscriber.rb', File.join('app/subscribers', class_path, "#{file_name}_subscriber.rb")
17
+ end
18
+
19
+ private
20
+
21
+ def events_list
22
+ options[:events].map { |event| ":#{event}" }.join(', ')
23
+ end
24
+
25
+ def patterns_list
26
+ options[:patterns].map { |pattern| "\"#{pattern}\"" }.join(', ')
27
+ end
28
+
29
+ def has_events?
30
+ options[:events].any?
31
+ end
32
+
33
+ def has_patterns?
34
+ options[:patterns].any?
35
+ end
36
+
37
+ def handler_methods
38
+ options[:events].map do |event|
39
+ method_name = "handle_#{event.gsub('.', '_').gsub('-', '_')}"
40
+ <<~METHOD
41
+ def #{method_name}(data)
42
+ # Handle #{event} event
43
+ Rails.logger.info "#{class_name}Subscriber: Handling #{event} event"
44
+ end
45
+ METHOD
46
+ end.join("\n")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ ===============================================================================
2
+
3
+ ActiveSubscriber has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Configure ActiveSubscriber in config/initializers/active_subscriber.rb
8
+ 2. Create your first subscriber:
9
+
10
+ rails generate active_subscriber:subscriber Analytics --events user_signed_in user_signed_up
11
+
12
+ 3. Include the Publisher module in your services:
13
+
14
+ class AnalyticsService
15
+ include ActiveSubscriber::Publisher
16
+
17
+ def self.track_event(name, data = {})
18
+ publish_event(name, data)
19
+ end
20
+ end
21
+
22
+ 4. Include the AnalyticsHelper in your controllers:
23
+
24
+ class ApplicationController < ActionController::Base
25
+ include ActiveSubscriber::Helpers::AnalyticsHelper
26
+ end
27
+
28
+ 5. Start publishing events and let your subscribers handle them!
29
+
30
+ For more information, visit: https://github.com/afomera/active_subscriber
31
+
32
+ ===============================================================================
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveSubscriber.configure do |config|
4
+ # Enable or disable ActiveSubscriber
5
+ # config.enabled = true
6
+
7
+ # Namespace for all events (recommended to avoid conflicts)
8
+ # config.namespace = "my_app"
9
+
10
+ # Paths where subscribers are located
11
+ # config.subscriber_paths = ["app/subscribers"]
12
+
13
+ # Filter events (optional)
14
+ # config.event_filter = ->(event_name) { !event_name.start_with?("internal.") }
15
+
16
+ # Auto-load subscribers
17
+ # config.auto_load = true
18
+
19
+ # Process events asynchronously using background jobs (defaults to Rails.env.production?)
20
+ # config.async = Rails.env.production?
21
+ end