From b2ad66ba6b8fb793e7d299099b4cda52b008b09b Mon Sep 17 00:00:00 2001 From: Matt Smith Date: Mon, 9 Dec 2024 12:33:17 -0500 Subject: [PATCH] YNGJ-1058: Aether Observatory 0.0.1 --- packages/aether_observatory/.rubocop.yml | 7 + packages/aether_observatory/Gemfile | 7 + packages/aether_observatory/Rakefile | 26 ++ .../aether_observatory.gemspec | 45 +++ .../doc/dependency_decisions.yml | 3 + packages/aether_observatory/docs/CHANGELOG.md | 3 + packages/aether_observatory/docs/README.md | 368 ++++++++++++++++++ .../lib/aether_observatory.rb | 15 + .../lib/aether_observatory/README.md | 318 +++++++++++++++ .../lib/aether_observatory/configuration.rb | 11 + .../lib/aether_observatory/event_base.rb | 66 ++++ .../lib/aether_observatory/observer_base.rb | 106 +++++ .../lib/aether_observatory/version.rb | 5 + packages/aether_observatory/mkdocs.yml | 6 + .../aether_observatory/event_base_spec.rb | 131 +++++++ .../aether_observatory/observer_base_spec.rb | 113 ++++++ .../aether_observatory/spec/spec_helper.rb | 57 +++ 17 files changed, 1287 insertions(+) create mode 100644 packages/aether_observatory/.rubocop.yml create mode 100644 packages/aether_observatory/Gemfile create mode 100644 packages/aether_observatory/Rakefile create mode 100644 packages/aether_observatory/aether_observatory.gemspec create mode 100644 packages/aether_observatory/doc/dependency_decisions.yml create mode 100644 packages/aether_observatory/docs/CHANGELOG.md create mode 100644 packages/aether_observatory/docs/README.md create mode 100644 packages/aether_observatory/lib/aether_observatory.rb create mode 100644 packages/aether_observatory/lib/aether_observatory/README.md create mode 100644 packages/aether_observatory/lib/aether_observatory/configuration.rb create mode 100644 packages/aether_observatory/lib/aether_observatory/event_base.rb create mode 100644 packages/aether_observatory/lib/aether_observatory/observer_base.rb create mode 100644 packages/aether_observatory/lib/aether_observatory/version.rb create mode 100644 packages/aether_observatory/mkdocs.yml create mode 100644 packages/aether_observatory/spec/aether_observatory/event_base_spec.rb create mode 100644 packages/aether_observatory/spec/aether_observatory/observer_base_spec.rb create mode 100644 packages/aether_observatory/spec/spec_helper.rb diff --git a/packages/aether_observatory/.rubocop.yml b/packages/aether_observatory/.rubocop.yml new file mode 100644 index 00000000..90cd42df --- /dev/null +++ b/packages/aether_observatory/.rubocop.yml @@ -0,0 +1,7 @@ +require: + - rubocop-powerhome + + +Metrics/MethodLength: + Exclude: + - spec/**/*_spec.rb \ No newline at end of file diff --git a/packages/aether_observatory/Gemfile b/packages/aether_observatory/Gemfile new file mode 100644 index 00000000..b24b97bb --- /dev/null +++ b/packages/aether_observatory/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "rubocop-powerhome", path: "../rubocop-powerhome" diff --git a/packages/aether_observatory/Rakefile b/packages/aether_observatory/Rakefile new file mode 100644 index 00000000..3b11eaa3 --- /dev/null +++ b/packages/aether_observatory/Rakefile @@ -0,0 +1,26 @@ +#!/usr/bin/env rake + +# frozen_string_literal: true + +begin + require "bundler/setup" +rescue LoadError + puts "You must `gem install bundler` and `bundle install` to run rake tasks" +end +Bundler::GemHelper.install_tasks + +require "rspec/core/rake_task" +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" +RuboCop::RakeTask.new(:rubocop) + +require "yard" +YARD::Rake::YardocTask.new do |t| + t.files = ["lib/**/*.rb"] + t.options = [ + "--no-private", + ] +end + +task default: %i[rubocop spec] diff --git a/packages/aether_observatory/aether_observatory.gemspec b/packages/aether_observatory/aether_observatory.gemspec new file mode 100644 index 00000000..760c2fca --- /dev/null +++ b/packages/aether_observatory/aether_observatory.gemspec @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "lib/aether_observatory/version" + +Gem::Specification.new do |spec| + spec.name = "aether_observatory" + spec.version = AetherObservatory::VERSION + spec.authors = ["Terry Finn", "Justin Stanczak"] + spec.email = ["terry.finn@powerhrg.com", "justin.stanczak@powerhrg.com"] + + spec.summary = "Aether Observatory" + spec.description = "Aether Observatory provides an event broadcast system." + spec.homepage = "https://github.com/powerhome/power-tools" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.0" + + spec.metadata["rubygems_mfa_required"] = "true" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/packages/aether_observatory/docs/CHANGELOG.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) + end + end + spec.require_paths = ["lib"] + + spec.add_dependency "activemodel", "~> 6.0", ">= 6.0.6.1" + spec.add_dependency "activesupport", "~> 6.0", ">= 6.0.6.1" + spec.add_development_dependency "appraisal", "~> 2.5.0" + + spec.add_development_dependency "bundler", "~> 2.1" + spec.add_development_dependency "license_finder", "~> 7.0" + spec.add_development_dependency "pry", ">= 0.14" + spec.add_development_dependency "pry-byebug", "3.10.1" + spec.add_development_dependency "rainbow", "2.2.2" + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "simplecov", "0.15.1" + spec.add_development_dependency "yard", "0.9.21" + spec.metadata["rubygems_mfa_required"] = "true" +end diff --git a/packages/aether_observatory/doc/dependency_decisions.yml b/packages/aether_observatory/doc/dependency_decisions.yml new file mode 100644 index 00000000..f734baa9 --- /dev/null +++ b/packages/aether_observatory/doc/dependency_decisions.yml @@ -0,0 +1,3 @@ +--- +- - :inherit_from + - https://raw.githubusercontent.com/powerhome/oss-guide/master/license_rules.yml diff --git a/packages/aether_observatory/docs/CHANGELOG.md b/packages/aether_observatory/docs/CHANGELOG.md new file mode 100644 index 00000000..f85bf0c3 --- /dev/null +++ b/packages/aether_observatory/docs/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - 2024-12-06 + +- Extracts AetherObservatory from Talkbox engine. \ No newline at end of file diff --git a/packages/aether_observatory/docs/README.md b/packages/aether_observatory/docs/README.md new file mode 100644 index 00000000..d31a64f2 --- /dev/null +++ b/packages/aether_observatory/docs/README.md @@ -0,0 +1,368 @@ +# AetherObservatory Guide + +In this guide we are going to walk through example code to illustrate the +usage of the `AetherObservatory::`. When finished you will have a class to +create events and a class that subscribes to those events. + +#### Table of Contents +- [Creating Events](#creating-events) +- [Creating an Observer and Subscribing to Events](#creating-an-observer-and-subscribing-to-events) +- [Sending an Event to your Observer](#sending-an-event-to-your-observer) +- [Stopping Observers](#stopping-observers) +- [Using Dynamic Event Names](#using-dynamic-event-names) +- [Multiple Event Topics](#multiple-event-topics) + +## Creating Events + +To begin create an `ApplicationEvent` class that extends the +`AetherObservatory::EventBase` class. Next configure a prefix for event +names using `event_prefix`. This is optional, but encouraged to help prevent +naming collisions with other domains. Every domain event we define as a +sub-class to the `ApplicationEvent` will inherit this prefix. + +```ruby +module AetherObservatory + module Examples + class ApplicationEvent < AetherObservatory::EventBase + event_prefix 'talkbox' + end + end +end +``` + +Next we create an event class called `ExampleEvent` that extends our +`ApplicationEvent`. In this class we define the topic we would like our +event sent to using the `event_name` method. Lastly we will define our +data using the `attribute` method. + +```ruby +module AetherObservatory + module Examples + class ExampleEvent < AetherObservatory::Examples::ApplicationEvent + event_name 'example1' + + attribute :message + attribute :timestamp, default: -> { Time.current } + end + end +end +``` + +Now we have a class to create new events. Each time you create a new event, +it will be sent to each topic you added via the `event_name` method. + +```ruby +AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') +``` + +Running the command above will display a log message like you see below. + +```irb +irb(main):018:0> AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') +[AetherObservatory::Examples::ExampleEvent] Create event for topic: [talkbox.example1] +=> nil +irb(main):019:0> +``` + +Now that we have an `ExampleEvent` class to create events we need to create +an observer to listen for those events. + +
+ Top +
+ +## Creating an Observer and Subscribing to Events + +Our new event class `ExampleEvent` creates a new event on the +`talkbox.example1` topic so this is the topic we need to create a observer for. + +We start by creating another class called `ExampleObserver` that extends +the `AetherObservatory::ObserverBase` class. Next we use the `subscribe_to` +method to register this observer to the topic `talkbox.example1`. We also +need to define a `process` method that will be called each time your observer +receives an event. In this `process` method you have access to `event_payload` +and `event_name` objects for your own logic. + +```ruby +module AetherObservatory + module Examples + class ExampleObserver < AetherObservatory::ObserverBase + subscribe_to 'talkbox.example1' + + def process + puts <<-EVENT + ************************************ + Event processed: + Name: #{event_name.inspect} + Message: #{event_payload.message} + Timestamp: #{event_payload.timestamp} + Event Payload: #{event_payload.inspect} + ************************************ + EVENT + end + end + end +end +``` +Now that we have a new observer named `ExampleObserver`, we will need to +start our observer before it will process any events. Observers default +to `stopped`, so we need to call `start` on each observer before they will +recieve events. Inside an initilizer is the recommended location to start +your observers. + +```ruby +AetherObservatory::Examples::ExampleObserver.start +``` + +
+ Top +
+ +## Sending an Event to your Observer + +Now that you have all your classes created you can send events to your +observer via the `create` method. + +```ruby +AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') +``` + +Calling create on your `ExampleEvent` class will trigger the `process` +method in the `ExampleObserver` class. You should see the following logged +output. + +```irb +irb(main):040:0> AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') + ************************************ + Event processed: + Name: "talkbox.example1" + Message: hello world + Timestamp: 2024-05-23 15:17:16 UTC + Event Payload: ##, @original_attribute=#, @original_attribute=nil>, @value="hello world">, "timestamp"=>#, @name="timestamp", @value_before_type_cast=#, @type=#, @original_attribute=nil, @memoized_value_before_type_cast=Thu, 23 May 2024 15:17:16.082153128 UTC +00:00, @value=Thu, 23 May 2024 15:17:16.082153128 UTC +00:00>}>> + ************************************ +[AetherObservatory::Examples::ExampleEvent] Create event for topic: [talkbox.example1] +=> nil +``` + +
+ Top +
+ +## Stopping Observers + +To stop your observer from processing events you can call the `stop` method +on your observer class. This stops only that observer class from processing +events. + +```ruby +AetherObservatory::Examples::ExampleObserver.stop +``` + +
+ Top +
+ +## Using Dynamic Event Names + +Create a new class called `RandomEvent` that extends `ApplicationEvent`. +Then pass a block to the `event_name` method. This allows you to dynamiclly +select your topic at the time of event creation. + +*Note: [ApplicationEvent](#creating-events) class was created at the +beginning of this guide.* + +```ruby +module AetherObservatory + module Examples + class RandomEvent < AetherObservatory::Examples::ApplicationEvent + event_name { select_a_topic_at_random } + + attribute :message + + private + + def select_a_topic_at_random + %w(test support customer).sample + end + end + end +end +``` + +You can now create a few events with your new class using the `create` +method of that class. + +```ruby +AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +``` + +As you can see from the following output a random event name is selected +each time you call `create`. + +```irb +irb(main):078:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.support] +=> nil +irb(main):079:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.test] +=> nil +irb(main):080:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.support] +=> nil +irb(main):081:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.customer] +=> nil +``` + +
+ Top +
+ +## Multiple Event Topics + +In this example we are going to create an event class that sends events to +two different topics based on the `level` attribute from the event class. +We are also going to make two observer classes that subscribe to different +events based on their role in the system. + +*Note: [ApplicationEvent](#creating-events) class was created at the +beginning of this guide.* + +We first create the `TalkboxCallQueueEvent` class. This class will send each +event to the `talkbox.call_queues.events.all` topic and to the `level` scoped +topic. + +```ruby +module AetherObservatory + module Examples + class TalkboxCallQueueEvent < AetherObservatory::Examples::ApplicationEvent + event_name 'call_queues.events.all' + event_name { "call_queues.events.#{level}" } + + attribute :level, default: 'info' + end + end +end +``` + +The new `TalkboxCallQueueEvent` class will send all events to the `all` +topic. However the events will also be sent to their specific event `level` +scoped topic. This allows us to have one observer logging call history and +a second observer that handles events with the scoped `level` or error for +topic `talkbox.call_queues.events.error`. + +Next we need to create a new class called `TalkboxCallHistoryObserver`. This +observer will subscribe to the `talkbox.call_queues.events.all` topic. This +classes function is to record all call queue events. + +```ruby +module AetherObservatory + module Examples + class TalkboxCallHistoryObserver < AetherObservatory::ObserverBase + subscribe_to 'talkbox.call_queues.events.all' + + delegate :level, to: :event_payload + + def process + puts <<-EVENT + ************************************ + Event processed: + Name: #{event_name.inspect} + Level: #{event_payload.level} + Event Payload: #{event_payload.inspect} + ************************************ + EVENT + end + end + end +end +``` + +Next we need a class called `TalkboxCallErrorObserver`. This class only +subscribes to the `talkbox.call_queues.events.error` topic. It only cares +about `error` level events and nothing else. + +```ruby +module AetherObservatory + module Examples + class TalkboxCallErrorObserver < AetherObservatory::ObserverBase + subscribe_to 'talkbox.call_queues.events.error' + + def process + puts <<-EVENT + ************************************ + Error Event processed: + Name: #{event_name.inspect} + Level: #{event_payload.level} + Event Payload: #{event_payload.inspect} + ************************************ + EVENT + end + end + end +end +``` + +We need to be sure to start our new observers before they will recieve +any events. + +```ruby +AetherObservatory::Examples::TalkboxCallHistoryObserver.start +AetherObservatory::Examples::TalkboxCallErrorObserver.start +``` + +Finally we are ready to create a new event and see what happens. First we +create an event with a default level. + +```ruby +AetherObservatory::Examples::TalkboxCallQueueEvent.create +``` + +Running the create with no parameters will have a default level of `info`. +You will see the following output. + +```irb +irb(main):058:0> AetherObservatory::Examples::TalkboxCallQueueEvent.create + ************************************ + Event processed: + Name: "talkbox.call_queues.events.all" + Level: info + Event Payload: ##, @original_attribute=nil, @value="info">}>> + ************************************ +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.all] +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.info] +=> nil +``` + +Next we will try creating a new event but this time we set the `level` +to `error`. + +```ruby +AetherObservatory::Examples::TalkboxCallQueueEvent.create(level: 'error') +``` + +As you can see from the output, setting the `level` to `error` will send +an event to both classes. + +```irb +irb(main):059:0> AetherObservatory::Examples::TalkboxCallQueueEvent.create(level: 'error') + ************************************ + Event processed: + Name: "talkbox.call_queues.events.all" + Level: error + Event Payload: ##, @original_attribute=#, @original_attribute=nil>, @value="error">}>> + ************************************ +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.all] + ************************************ + Error Event processed: + Name: "talkbox.call_queues.events.error" + Level: error + Event Payload: ##, @original_attribute=#, @original_attribute=nil>, @value="error">}>> + ************************************ +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.error] +=> nil +``` + +
+ Top +
diff --git a/packages/aether_observatory/lib/aether_observatory.rb b/packages/aether_observatory/lib/aether_observatory.rb new file mode 100644 index 00000000..0db94ca4 --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "active_support/all" +require "aether_observatory/configuration" + +module AetherObservatory + mattr_accessor :configuration, default: Configuration + + class << self + delegate :configure, :config, to: :configuration + end +end + +require "aether_observatory/event_base" +require "aether_observatory/observer_base" diff --git a/packages/aether_observatory/lib/aether_observatory/README.md b/packages/aether_observatory/lib/aether_observatory/README.md new file mode 100644 index 00000000..49a392b1 --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory/README.md @@ -0,0 +1,318 @@ +# AetherObservatory Guide + +In this guide we are going to walk through example code to illustrate the usage of the `AetherObservatory::`. When finished you will have a class to create events and a class that subscribes to those events. + +#### Table of Contents +- [Creating Events](#creating-events) +- [Creating an Observer and Subscribing to Events](#creating-an-observer-and-subscribing-to-events) +- [Sending an Event to your Observer](#sending-an-event-to-your-observer) +- [Stopping Observers](#stopping-observers) +- [Using Dynamic Event Names](#using-dynamic-event-names) +- [Multiple Event Topics](#multiple-event-topics) + +## Creating Events + +To begin create an `ApplicationEvent` class that extends the `AetherObservatory::EventBase` class. Next configure a prefix for event names using `event_prefix`. This is optional, but encouraged to help prevent naming collisions with other domains. Every domain event we define as a sub-class to the `ApplicationEvent` will inherit this prefix. + +```ruby +module AetherObservatory + module Examples + class ApplicationEvent < AetherObservatory::EventBase + event_prefix 'talkbox' + end + end +end +``` + +Next we create an event class called `ExampleEvent` that extends our `ApplicationEvent`. In this class we define the topic we would like our event sent to using the `event_name` method. Lastly we will define our data using the `attribute` method. + +```ruby +module AetherObservatory + module Examples + class ExampleEvent < AetherObservatory::Examples::ApplicationEvent + event_name 'example1' + + attribute :message + attribute :timestamp, default: -> { Time.current } + end + end +end +``` + +Now we have a class to create new events. Each time you create a new event, it will be sent to each topic you added via the `event_name` method. + +```ruby +AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') +``` + +Running the command above will display a log message like you see below. + +```irb +irb(main):018:0> AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') +[AetherObservatory::Examples::ExampleEvent] Create event for topic: [talkbox.example1] +=> nil +irb(main):019:0> +``` + +Now that we have an `ExampleEvent` class to create events we need to create an observer to listen for those events. + +
+ Top +
+ +## Creating an Observer and Subscribing to Events + +Our new event class `ExampleEvent` creates a new event on the `talkbox.example1` topic so this is the topic we need to create a observer for. + +We start by creating another class called `ExampleObserver` that extends the `AetherObservatory::ObserverBase` class. Next we use the `subscribe_to` method to register this observer to the topic `talkbox.example1`. We also need to define a `process` method that will be called each time your observer receives an event. In this `process` method you have access to `event_payload` and `event_name` objects for your own logic. + +```ruby +module AetherObservatory + module Examples + class ExampleObserver < AetherObservatory::ObserverBase + subscribe_to 'talkbox.example1' + + def process + puts <<-EVENT + ************************************ + Event processed: + Name: #{event_name.inspect} + Message: #{event_payload.message} + Timestamp: #{event_payload.timestamp} + Event Payload: #{event_payload.inspect} + ************************************ + EVENT + end + end + end +end +``` +Now that we have a new observer named `ExampleObserver`, we will need to start our observer before it will process any events. Observers default to `stopped`, so we need to call `start` on each observer before they will recieve events. Inside an initilizer is the recommended location to start your observers. + +```ruby +AetherObservatory::Examples::ExampleObserver.start +``` + +
+ Top +
+ +## Sending an Event to your Observer + +Now that you have all your classes created you can send events to your observer via the `create` method. + +```ruby +AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') +``` + +Calling create on your `ExampleEvent` class will trigger the `process` method in the `ExampleObserver` class. You should see the following logged output. + +```irb +irb(main):040:0> AetherObservatory::Examples::ExampleEvent.create(message: 'hello world') + ************************************ + Event processed: + Name: "talkbox.example1" + Message: hello world + Timestamp: 2024-05-23 15:17:16 UTC + Event Payload: ##, @original_attribute=#, @original_attribute=nil>, @value="hello world">, "timestamp"=>#, @name="timestamp", @value_before_type_cast=#, @type=#, @original_attribute=nil, @memoized_value_before_type_cast=Thu, 23 May 2024 15:17:16.082153128 UTC +00:00, @value=Thu, 23 May 2024 15:17:16.082153128 UTC +00:00>}>> + ************************************ +[AetherObservatory::Examples::ExampleEvent] Create event for topic: [talkbox.example1] +=> nil +``` + +
+ Top +
+ +## Stopping Observers + +To stop your observer from processing events you can call the `stop` method on your observer class. This stops only that observer class from processing events. + +```ruby +AetherObservatory::Examples::ExampleObserver.stop +``` + +
+ Top +
+ +## Using Dynamic Event Names + +Create a new class called `RandomEvent` that extends `ApplicationEvent`. Then pass a block to the `event_name` method. This allows you to dynamiclly select your topic at the time of event creation. + +*Note: [ApplicationEvent](#creating-events) class was created at the beginning of this guide.* + +```ruby +module AetherObservatory + module Examples + class RandomEvent < AetherObservatory::Examples::ApplicationEvent + event_name { select_a_topic_at_random } + + attribute :message + + private + + def select_a_topic_at_random + %w(test support customer).sample + end + end + end +end +``` + +You can now create a few events with your new class using the `create` method of that class. + +```ruby +AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +``` + +As you can see from the following output a random event name is selected each time you call `create`. + +```irb +irb(main):078:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.support] +=> nil +irb(main):079:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.test] +=> nil +irb(main):080:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.support] +=> nil +irb(main):081:0> AetherObservatory::Examples::RandomEvent.create(message: 'hello world') +[AetherObservatory::Examples::RandomEvent] Create event for topic: [talkbox.customer] +=> nil +``` + +
+ Top +
+ +## Multiple Event Topics + +In this example we are going to create an event class that sends events to two different topics based on the `level` attribute from the event class. We are also going to make two observer classes that subscribe to different events based on their role in the system. + +*Note: [ApplicationEvent](#creating-events) class was created at the beginning of this guide.* + +We first create the `TalkboxCallQueueEvent` class. This class will send each event to the `talkbox.call_queues.events.all` topic and to the `level` scoped topic. + +```ruby +module AetherObservatory + module Examples + class TalkboxCallQueueEvent < AetherObservatory::Examples::ApplicationEvent + event_name 'call_queues.events.all' + event_name { "call_queues.events.#{level}" } + + attribute :level, default: 'info' + end + end +end +``` + +The new `TalkboxCallQueueEvent` class will send all events to the `all` topic. However the events will also be sent to their specific event `level` scoped topic. This allows us to have one observer logging call history and a second observer that handles events with the scoped `level` or error for topic `talkbox.call_queues.events.error`. + +Next we need to create a new class called `TalkboxCallHistoryObserver`. This observer will subscribe to the `talkbox.call_queues.events.all` topic. This classes function is to record all call queue events. + +```ruby +module AetherObservatory + module Examples + class TalkboxCallHistoryObserver < AetherObservatory::ObserverBase + subscribe_to 'talkbox.call_queues.events.all' + + delegate :level, to: :event_payload + + def process + puts <<-EVENT + ************************************ + Event processed: + Name: #{event_name.inspect} + Level: #{event_payload.level} + Event Payload: #{event_payload.inspect} + ************************************ + EVENT + end + end + end +end +``` + +Next we need a class called `TalkboxCallErrorObserver`. This class only subscribes to the `talkbox.call_queues.events.error` topic. It only cares about `error` level events and nothing else. + +```ruby +module AetherObservatory + module Examples + class TalkboxCallErrorObserver < AetherObservatory::ObserverBase + subscribe_to 'talkbox.call_queues.events.error' + + def process + puts <<-EVENT + ************************************ + Error Event processed: + Name: #{event_name.inspect} + Level: #{event_payload.level} + Event Payload: #{event_payload.inspect} + ************************************ + EVENT + end + end + end +end +``` + +We need to be sure to start our new observers before they will recieve any events. + +```ruby +AetherObservatory::Examples::TalkboxCallHistoryObserver.start +AetherObservatory::Examples::TalkboxCallErrorObserver.start +``` + +Finally we are ready to create a new event and see what happens. First we create an event with a default level. + +```ruby +AetherObservatory::Examples::TalkboxCallQueueEvent.create +``` + +Running the create with no parameters will have a default level of `info`. You will see the following output. + +```irb +irb(main):058:0> AetherObservatory::Examples::TalkboxCallQueueEvent.create + ************************************ + Event processed: + Name: "talkbox.call_queues.events.all" + Level: info + Event Payload: ##, @original_attribute=nil, @value="info">}>> + ************************************ +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.all] +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.info] +=> nil +``` + +Next we will try creating a new event but this time we set the `level` to `error`. + +```ruby +AetherObservatory::Examples::TalkboxCallQueueEvent.create(level: 'error') +``` + +As you can see from the output, setting the `level` to `error` will send an event to both classes. + +```irb +irb(main):059:0> AetherObservatory::Examples::TalkboxCallQueueEvent.create(level: 'error') + ************************************ + Event processed: + Name: "talkbox.call_queues.events.all" + Level: error + Event Payload: ##, @original_attribute=#, @original_attribute=nil>, @value="error">}>> + ************************************ +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.all] + ************************************ + Error Event processed: + Name: "talkbox.call_queues.events.error" + Level: error + Event Payload: ##, @original_attribute=#, @original_attribute=nil>, @value="error">}>> + ************************************ +[AetherObservatory::Examples::TalkboxCallQueueEvent] Create event for topic: [talkbox.call_queues.events.error] +=> nil +``` + +
+ Top +
diff --git a/packages/aether_observatory/lib/aether_observatory/configuration.rb b/packages/aether_observatory/lib/aether_observatory/configuration.rb new file mode 100644 index 00000000..e267b28c --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory/configuration.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module AetherObservatory + module Configuration + include ActiveSupport::Configurable + + config_accessor(:logger) do + defined?(Rails) ? Rails.logger : Logger.new($stdout) + end + end +end diff --git a/packages/aether_observatory/lib/aether_observatory/event_base.rb b/packages/aether_observatory/lib/aether_observatory/event_base.rb new file mode 100644 index 00000000..2ba540c5 --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory/event_base.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "active_model" + +module AetherObservatory + class EventBase + include ActiveModel::AttributeAssignment + include ActiveModel::Attributes + + class << self + def inherited(subclass) + super + subclass.event_prefix(&event_prefix) + end + + def create(**attributes) + event = new(**attributes) + event_names_with_prefix.each do |event_name_parts| + event_name = event_name_parts.filter_map do |part| + event.instance_exec(&part) unless part.nil? + end.join(".") + logger.debug("[#{name}] Create event for topic: [#{event_name}]") + ActiveSupport::Notifications.instrument(event_name, event) + end + + nil + end + + def event_prefix(value = nil, &block) + @event_prefix = -> { value } if value.present? + @event_prefix = block if block.present? + + @event_prefix + end + + def event_name(value = nil, &block) + event_names << -> { value } if value.present? + event_names << block if block.present? + + nil + end + + def event_names_with_prefix + event_names.map { |event_name| [event_prefix, event_name] } + end + + def event_names + @event_names ||= [] + end + + def logger(value = nil) + @logger = value if value.present? + + @logger || AetherObservatory.config.logger + end + end + + delegate :event_name, to: "self.class" + delegate :logger, to: "self.class" + + def initialize(attributes = {}) + super() + assign_attributes(attributes) if attributes + end + end +end diff --git a/packages/aether_observatory/lib/aether_observatory/observer_base.rb b/packages/aether_observatory/lib/aether_observatory/observer_base.rb new file mode 100644 index 00000000..5949b7de --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory/observer_base.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module AetherObservatory + class ObserverBase + class << self + def inherited(subclass) + super + subclass.instance_variable_set(:@subscribed_topics, Set.new) + subclass.instance_variable_set(:@state, :stopped) + subclass.instance_variable_set(:@subscriptions, {}) + end + + def start + return if started? + + logger.debug("[#{name}] Starting") + + subscribed_to.each do |topic| + next if subscriptions.include?(topic) + + register_subscription_to(topic) + end + + self.state = :started + end + + def stop + return if stopped? + + logger.debug("[#{name}] Stopping") + + subscriptions.each_key do |topic| + unregister_subscription_to(topic) + end + + self.state = :stopped + end + + def subscribe_to(topic) + subscribed_topics.add(topic) + + return if stopped? + + register_subscription_to(topic) + end + + def unsubscribe_from(topic) + subscribed_topics.delete(topic) + + return if stopped? + + unregister_subscription_to(topic) + end + + def subscribed_to + subscribed_topics.to_a + end + + def started? + state == :started + end + + def stopped? + state == :stopped + end + + private + + attr_reader :subscribed_topics, :subscriptions + attr_accessor :state + + def register_subscription_to(topic) + return if subscriptions.include?(topic) + + logger.debug("[#{name}] Registering subscription to topic: #{topic.inspect}") + + subscriptions[topic] = ActiveSupport::Notifications.subscribe(topic) do |*args| + name.constantize.new(ActiveSupport::Notifications::Event.new(*args)).process + end + end + + def unregister_subscription_to(topic) + return if subscriptions.exclude?(topic) + + logger.debug("[#{name}] Unregistering subscription to topic: #{topic.inspect}") + + ActiveSupport::Notifications.unsubscribe(subscriptions.delete(topic)) + end + + def logger(value = nil) + @logger = value if value.present? + + @logger || AetherObservatory.config.logger + end + end + + attr_accessor :event + + def initialize(event) + self.event = event + end + + delegate :name, to: :event, prefix: true + delegate :payload, to: :event, prefix: true + end +end diff --git a/packages/aether_observatory/lib/aether_observatory/version.rb b/packages/aether_observatory/lib/aether_observatory/version.rb new file mode 100644 index 00000000..10d6e7d5 --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module AetherObservatory + VERSION = "0.0.1" +end diff --git a/packages/aether_observatory/mkdocs.yml b/packages/aether_observatory/mkdocs.yml new file mode 100644 index 00000000..2229ed00 --- /dev/null +++ b/packages/aether_observatory/mkdocs.yml @@ -0,0 +1,6 @@ +site_name: AetherObservatory +nav: + - "Home": "README.md" + - "Changelog": "CHANGELOG.md" +plugins: + - techdocs-core diff --git a/packages/aether_observatory/spec/aether_observatory/event_base_spec.rb b/packages/aether_observatory/spec/aether_observatory/event_base_spec.rb new file mode 100644 index 00000000..2886ee3d --- /dev/null +++ b/packages/aether_observatory/spec/aether_observatory/event_base_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "spec_helper" + +module AetherObservatory + RSpec.describe EventBase do + after(:each) { teardown } + + describe ".create" do + it "sends event to a single observer" do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "zero", prefix: prefix) + observer = + a_started_observer( + name: "zero", + listening_to: ["#{prefix}.zero"] + ) + + # When + event.create(message: "message") + + # Then + expect(observer.returned_payload.message).to eq("message") + end + + it "sends event to multiple observers", :aggregate_failures do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "zero", prefix: prefix) + observer_zero = + a_started_observer( + name: "zero", + listening_to: ["#{prefix}.zero"] + ) + observer_both = + a_started_observer( + name: "both", + listening_to: ["#{prefix}.zero", "#{prefix}.one"] + ) + + # When + event.create(message: "message") + + # Then + expect(observer_zero.returned_payload.message).to eq("message") + expect(observer_both.returned_payload.message).to eq("message") + end + + it "sends event to only one of multiple observers", :aggregate_failures do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "one", prefix: prefix) + observer_zero = + a_started_observer( + name: "zero", + listening_to: ["#{prefix}.zero"] + ) + observer_both = + a_started_observer( + name: "both", + listening_to: ["#{prefix}.zero", "#{prefix}.one"] + ) + + # When + event.create(message: "message") + + # Then + expect(observer_zero.returned_payload).to eq(nil) + expect(observer_both.returned_payload.message).to eq("message") + end + + context "without a prefix" do + it "processes a single event" do + # Given + event = a_fake_event(named: "zero", prefix: nil) + observer = + a_started_observer( + name: "zero", + listening_to: ["zero"] + ) + + # When + event.create(message: "message") + + # Then + expect(observer.returned_payload.message).to eq("message") + end + end + end + + private + + def a_fake_event(prefix:, named: "event_name") + stub_const( + "FakeEventTopic#{named.capitalize}", + Class.new(EventBase) do + attribute :message + event_prefix prefix if prefix.present? + event_name { named } + end + ) + end + + def a_started_observer(**kwargs) + a_fake_observer(**kwargs).tap(&:start) + end + + def a_fake_observer(name:, listening_to: []) + stub_const( + name.classify, + Class.new(ObserverBase) do + class << self + attr_accessor :returned_payload + end + + listening_to.each { |event_name| subscribe_to(event_name) } + + def process + self.class.returned_payload = event_payload + end + end + ) + end + + def teardown + ActiveSupport::Notifications.notifier = + ActiveSupport::Notifications::Fanout.new + end + end +end diff --git a/packages/aether_observatory/spec/aether_observatory/observer_base_spec.rb b/packages/aether_observatory/spec/aether_observatory/observer_base_spec.rb new file mode 100644 index 00000000..01758f1a --- /dev/null +++ b/packages/aether_observatory/spec/aether_observatory/observer_base_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "spec_helper" + +module AetherObservatory + RSpec.describe ObserverBase do + after(:each) { teardown } + + describe "#process" do + it "processes a single event" do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "zero", prefix: prefix) + observer = a_fake_observer(listening_to: ["#{prefix}.zero"]) + + # When + observer.start + event.create(message: "message") + + # Then + expect(observer.returned_payload.message).to eq("message") + end + + it "processed multiple events", :aggregate_failures do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "zero", prefix: prefix) + other_event = a_fake_event(named: "one", prefix: prefix) + observer = a_fake_observer(listening_to: ["#{prefix}.zero", "#{prefix}.one"]) + + # When + observer.start + event.create(message: "message zero") + + # Then + expect(observer.returned_payload.message).to eq("message zero") + + # When + other_event.create(message: "message one") + + # Then + expect(observer.returned_payload.message).to eq("message one") + end + end + + describe "#stop" do + it "event is not processed" do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "zero", prefix: prefix) + observer = a_fake_observer(listening_to: ["#{prefix}.zero"]) + + # When + observer.stop + event.create(message: "message") + + # Then + expect(observer.returned_payload).to eq(nil) + end + end + + describe "#start" do + it "event is processed" do + # Given + prefix = "fake_prefix" + event = a_fake_event(named: "zero", prefix: prefix) + observer = a_fake_observer(listening_to: ["#{prefix}.zero"]) + + # When + observer.start + event.create(message: "message") + + # Then + expect(observer.returned_payload.message).to eq("message") + end + end + + private + + def a_fake_event(named: "event_name", prefix: "fake_test_topic") + stub_const( + "FakeEventTopic#{named.capitalize}", + Class.new(EventBase) do + attribute :message + event_prefix prefix + event_name { named } + end + ) + end + + def a_fake_observer(listening_to: []) + stub_const( + "FakeObserver", + Class.new(ObserverBase) do + class << self + attr_accessor :returned_payload + end + + listening_to.each { |event_name| subscribe_to(event_name) } + + def process + self.class.returned_payload = event_payload + end + end + ) + end + + def teardown + ActiveSupport::Notifications.notifier = + ActiveSupport::Notifications::Fanout.new + end + end +end diff --git a/packages/aether_observatory/spec/spec_helper.rb b/packages/aether_observatory/spec/spec_helper.rb new file mode 100644 index 00000000..580be07f --- /dev/null +++ b/packages/aether_observatory/spec/spec_helper.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "aether_observatory" +require "pry-byebug" + +RSpec.configure do |config| + if ENV["CI"] + config.before(:example, :focus) { raise "Should not commit focused specs" } + else + config.filter_run :focus + config.run_all_when_everything_filtered = true + end + config.warnings = false + + config.default_formatter = "doc" if config.files_to_run.one? + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax + expectations.syntax = :expect + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Enable only the newer, non-monkey-patching expect syntax. + # For more details, see: + # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + mocks.syntax = :expect + + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended. + mocks.verify_partial_doubles = false + end +end