From 8f1c380f9bbd40564041bb4344ab5f09db3ea204 Mon Sep 17 00:00:00 2001 From: Matt Smith Date: Thu, 19 Dec 2024 16:33:35 -0500 Subject: [PATCH] YNGJ-1058: Create Aether Observatory (#297) Extracting `AetherObservatory` from `Talkbox`. Internal PubSub system base upon `ActiveSupport::Notifications` --- .github/workflows/aether_observatory.yml | 14 + docs/README.md | 4 + packages/aether_observatory/.rubocop.yml | 9 + packages/aether_observatory/Appraisals | 21 + packages/aether_observatory/Gemfile | 7 + packages/aether_observatory/Rakefile | 26 ++ .../aether_observatory.gemspec | 44 +++ .../doc/dependency_decisions.yml | 3 + packages/aether_observatory/docs/CHANGELOG.md | 3 + packages/aether_observatory/docs/README.md | 368 ++++++++++++++++++ .../gemfiles/rails_6_0.gemfile | 11 + .../gemfiles/rails_6_0.gemfile.lock | 150 +++++++ .../gemfiles/rails_6_1.gemfile | 11 + .../gemfiles/rails_6_1.gemfile.lock | 149 +++++++ .../gemfiles/rails_7_0.gemfile | 11 + .../gemfiles/rails_7_0.gemfile.lock | 147 +++++++ .../gemfiles/rails_7_1.gemfile | 11 + .../gemfiles/rails_7_1.gemfile.lock | 163 ++++++++ .../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 | 138 +++++++ .../aether_observatory/observer_base_spec.rb | 118 ++++++ .../aether_observatory/spec/spec_helper.rb | 57 +++ portal.yml | 17 + 29 files changed, 2009 insertions(+) create mode 100644 .github/workflows/aether_observatory.yml create mode 100644 packages/aether_observatory/.rubocop.yml create mode 100644 packages/aether_observatory/Appraisals 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/gemfiles/rails_6_0.gemfile create mode 100644 packages/aether_observatory/gemfiles/rails_6_0.gemfile.lock create mode 100644 packages/aether_observatory/gemfiles/rails_6_1.gemfile create mode 100644 packages/aether_observatory/gemfiles/rails_6_1.gemfile.lock create mode 100644 packages/aether_observatory/gemfiles/rails_7_0.gemfile create mode 100644 packages/aether_observatory/gemfiles/rails_7_0.gemfile.lock create mode 100644 packages/aether_observatory/gemfiles/rails_7_1.gemfile create mode 100644 packages/aether_observatory/gemfiles/rails_7_1.gemfile.lock 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/.github/workflows/aether_observatory.yml b/.github/workflows/aether_observatory.yml new file mode 100644 index 00000000..4fbd3093 --- /dev/null +++ b/.github/workflows/aether_observatory.yml @@ -0,0 +1,14 @@ +name: aether_observatory + +on: + push: + +jobs: + ruby: + uses: powerhome/github-actions-workflows/.github/workflows/ruby-gem.yml@main + with: + package: ${{ github.workflow }} + workdir: "packages/${{ github.workflow }}" + gemfiles: "['gemfiles/rails_6_0.gemfile','gemfiles/rails_6_1.gemfile','gemfiles/rails_7_0.gemfile','gemfiles/rails_7_1.gemfile']" + ruby: '["3.0","3.3"]' + secrets: inherit diff --git a/docs/README.md b/docs/README.md index 24784aa0..a4f8d1b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,10 @@ At [Power Home Remodeling](https://powerhrg.com/), we have created foundational power-tools currently contains the following packages (marked for release to rubygems 💎 or npm ☕️): +[aether_observatory](https://github.com/powerhome/power-tools/blob/main/packages/aether_observatory/docs/README.md) 💎 + +AetherObservatory provides an event broadcast and subscription system based around ActiveSupport::Notifications. + [api_chai](https://github.com/powerhome/power-tools/blob/main/packages/api_chai/docs/README.md) 💎 ApiChai provides a simple integration with net-http around a lightweight layer for reporting and graceful error handling. diff --git a/packages/aether_observatory/.rubocop.yml b/packages/aether_observatory/.rubocop.yml new file mode 100644 index 00000000..66efc447 --- /dev/null +++ b/packages/aether_observatory/.rubocop.yml @@ -0,0 +1,9 @@ +require: + - rubocop-powerhome + +AllCops: + TargetRubyVersion: 3.0 + +Metrics/MethodLength: + Exclude: + - spec/**/*_spec.rb diff --git a/packages/aether_observatory/Appraisals b/packages/aether_observatory/Appraisals new file mode 100644 index 00000000..6a479d98 --- /dev/null +++ b/packages/aether_observatory/Appraisals @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +appraise "rails-6-0" do + gem "activemodel", "~> 6.0.6" + gem "activesupport", "~> 6.0.6" +end + +appraise "rails-6-1" do + gem "activemodel", "~> 6.1.7" + gem "activesupport", "~> 6.1.7" +end + +appraise "rails-7-0" do + gem "activemodel", "~> 7.0.8" + gem "activesupport", "~> 7.0.8" +end + +appraise "rails-7-1" do + gem "activemodel", "~> 7.1.3" + gem "activesupport", "~> 7.1.3" +end 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..bc7dc833 --- /dev/null +++ b/packages/aether_observatory/aether_observatory.gemspec @@ -0,0 +1,44 @@ +# 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.1" + spec.add_dependency "activesupport", ">= 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 "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/gemfiles/rails_6_0.gemfile b/packages/aether_observatory/gemfiles/rails_6_0.gemfile new file mode 100644 index 00000000..2eb7b89b --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_6_0.gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activemodel", "~> 6.0.6" +gem "activesupport", "~> 6.0.6" +gem "rubocop-powerhome", path: "../../rubocop-powerhome" + +gemspec path: "../" diff --git a/packages/aether_observatory/gemfiles/rails_6_0.gemfile.lock b/packages/aether_observatory/gemfiles/rails_6_0.gemfile.lock new file mode 100644 index 00000000..200e63b4 --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_6_0.gemfile.lock @@ -0,0 +1,150 @@ +PATH + remote: ../../rubocop-powerhome + specs: + rubocop-powerhome (0.5.3) + rubocop (= 1.66.1) + rubocop-performance + rubocop-rails + rubocop-rake + rubocop-rspec + +PATH + remote: .. + specs: + aether_observatory (1.0.0) + activemodel (>= 6.0.6.1) + activesupport (>= 6.0.6.1) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (6.0.6.1) + activesupport (= 6.0.6.1) + activesupport (6.0.6.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) + appraisal (2.5.0) + bundler + rake + thor (>= 0.14.0) + ast (2.4.2) + byebug (11.1.3) + coderay (1.1.3) + concurrent-ruby (1.3.4) + csv (3.3.1) + diff-lcs (1.5.1) + docile (1.1.5) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + json (2.9.0) + language_server-protocol (3.17.0.3) + license_finder (7.2.1) + bundler + csv (~> 3.2) + rubyzip (>= 1, < 3) + thor (~> 1.2) + tomlrb (>= 1.3, < 2.1) + with_env (= 1.1.0) + xml-simple (~> 1.1.9) + method_source (1.1.0) + minitest (5.25.4) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.10.1) + byebug (~> 11.0) + pry (>= 0.13, < 0.15) + racc (1.8.1) + rack (2.2.10) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.3) + rexml (3.4.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.37.0) + parser (>= 3.3.1.0) + rubocop-performance (1.23.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.27.0) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (3.3.0) + rubocop (~> 1.61) + ruby-progressbar (1.13.0) + rubyzip (2.3.2) + simplecov (0.15.1) + docile (~> 1.1.0) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + thor (1.3.2) + thread_safe (0.3.6) + tomlrb (2.0.3) + tzinfo (1.2.11) + thread_safe (~> 0.1) + unicode-display_width (2.6.0) + with_env (1.1.0) + xml-simple (1.1.9) + rexml + yard (0.9.21) + zeitwerk (2.6.18) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + activemodel (~> 6.0.6) + activesupport (~> 6.0.6) + aether_observatory! + appraisal (~> 2.5.0) + bundler (~> 2.1) + license_finder (~> 7.0) + pry (>= 0.14) + pry-byebug (= 3.10.1) + rake (~> 13.0) + rspec (~> 3.0) + rubocop-powerhome! + simplecov (= 0.15.1) + yard (= 0.9.21) + +BUNDLED WITH + 2.4.22 diff --git a/packages/aether_observatory/gemfiles/rails_6_1.gemfile b/packages/aether_observatory/gemfiles/rails_6_1.gemfile new file mode 100644 index 00000000..8b3f0211 --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_6_1.gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activemodel", "~> 6.1.7" +gem "activesupport", "~> 6.1.7" +gem "rubocop-powerhome", path: "../../rubocop-powerhome" + +gemspec path: "../" diff --git a/packages/aether_observatory/gemfiles/rails_6_1.gemfile.lock b/packages/aether_observatory/gemfiles/rails_6_1.gemfile.lock new file mode 100644 index 00000000..9acf1ccc --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_6_1.gemfile.lock @@ -0,0 +1,149 @@ +PATH + remote: ../../rubocop-powerhome + specs: + rubocop-powerhome (0.5.3) + rubocop (= 1.66.1) + rubocop-performance + rubocop-rails + rubocop-rake + rubocop-rspec + +PATH + remote: .. + specs: + aether_observatory (1.0.0) + activemodel (>= 6.0.6.1) + activesupport (>= 6.0.6.1) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (6.1.7.10) + activesupport (= 6.1.7.10) + activesupport (6.1.7.10) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + appraisal (2.5.0) + bundler + rake + thor (>= 0.14.0) + ast (2.4.2) + byebug (11.1.3) + coderay (1.1.3) + concurrent-ruby (1.3.4) + csv (3.3.1) + diff-lcs (1.5.1) + docile (1.1.5) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + json (2.9.0) + language_server-protocol (3.17.0.3) + license_finder (7.2.1) + bundler + csv (~> 3.2) + rubyzip (>= 1, < 3) + thor (~> 1.2) + tomlrb (>= 1.3, < 2.1) + with_env (= 1.1.0) + xml-simple (~> 1.1.9) + method_source (1.1.0) + minitest (5.25.4) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.10.1) + byebug (~> 11.0) + pry (>= 0.13, < 0.15) + racc (1.8.1) + rack (2.2.10) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.3) + rexml (3.4.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.37.0) + parser (>= 3.3.1.0) + rubocop-performance (1.23.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.27.0) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (3.3.0) + rubocop (~> 1.61) + ruby-progressbar (1.13.0) + rubyzip (2.3.2) + simplecov (0.15.1) + docile (~> 1.1.0) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + thor (1.3.2) + tomlrb (2.0.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.6.0) + with_env (1.1.0) + xml-simple (1.1.9) + rexml + yard (0.9.21) + zeitwerk (2.6.18) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + activemodel (~> 6.1.7) + activesupport (~> 6.1.7) + aether_observatory! + appraisal (~> 2.5.0) + bundler (~> 2.1) + license_finder (~> 7.0) + pry (>= 0.14) + pry-byebug (= 3.10.1) + rake (~> 13.0) + rspec (~> 3.0) + rubocop-powerhome! + simplecov (= 0.15.1) + yard (= 0.9.21) + +BUNDLED WITH + 2.4.22 diff --git a/packages/aether_observatory/gemfiles/rails_7_0.gemfile b/packages/aether_observatory/gemfiles/rails_7_0.gemfile new file mode 100644 index 00000000..82e7ddf4 --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_7_0.gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activemodel", "~> 7.0.8" +gem "activesupport", "~> 7.0.8" +gem "rubocop-powerhome", path: "../../rubocop-powerhome" + +gemspec path: "../" diff --git a/packages/aether_observatory/gemfiles/rails_7_0.gemfile.lock b/packages/aether_observatory/gemfiles/rails_7_0.gemfile.lock new file mode 100644 index 00000000..22ce1050 --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_7_0.gemfile.lock @@ -0,0 +1,147 @@ +PATH + remote: ../../rubocop-powerhome + specs: + rubocop-powerhome (0.5.3) + rubocop (= 1.66.1) + rubocop-performance + rubocop-rails + rubocop-rake + rubocop-rspec + +PATH + remote: .. + specs: + aether_observatory (1.0.0) + activemodel (>= 6.0.6.1) + activesupport (>= 6.0.6.1) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.0.8.7) + activesupport (= 7.0.8.7) + activesupport (7.0.8.7) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + appraisal (2.5.0) + bundler + rake + thor (>= 0.14.0) + ast (2.4.2) + byebug (11.1.3) + coderay (1.1.3) + concurrent-ruby (1.3.4) + csv (3.3.1) + diff-lcs (1.5.1) + docile (1.1.5) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + json (2.9.0) + language_server-protocol (3.17.0.3) + license_finder (7.2.1) + bundler + csv (~> 3.2) + rubyzip (>= 1, < 3) + thor (~> 1.2) + tomlrb (>= 1.3, < 2.1) + with_env (= 1.1.0) + xml-simple (~> 1.1.9) + method_source (1.1.0) + minitest (5.25.4) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.10.1) + byebug (~> 11.0) + pry (>= 0.13, < 0.15) + racc (1.8.1) + rack (2.2.10) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.3) + rexml (3.4.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.37.0) + parser (>= 3.3.1.0) + rubocop-performance (1.23.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.27.0) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (3.3.0) + rubocop (~> 1.61) + ruby-progressbar (1.13.0) + rubyzip (2.3.2) + simplecov (0.15.1) + docile (~> 1.1.0) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + thor (1.3.2) + tomlrb (2.0.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.6.0) + with_env (1.1.0) + xml-simple (1.1.9) + rexml + yard (0.9.21) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + activemodel (~> 7.0.8) + activesupport (~> 7.0.8) + aether_observatory! + appraisal (~> 2.5.0) + bundler (~> 2.1) + license_finder (~> 7.0) + pry (>= 0.14) + pry-byebug (= 3.10.1) + rake (~> 13.0) + rspec (~> 3.0) + rubocop-powerhome! + simplecov (= 0.15.1) + yard (= 0.9.21) + +BUNDLED WITH + 2.4.22 diff --git a/packages/aether_observatory/gemfiles/rails_7_1.gemfile b/packages/aether_observatory/gemfiles/rails_7_1.gemfile new file mode 100644 index 00000000..ea9837df --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_7_1.gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activemodel", "~> 7.1.3" +gem "activesupport", "~> 7.1.3" +gem "rubocop-powerhome", path: "../../rubocop-powerhome" + +gemspec path: "../" diff --git a/packages/aether_observatory/gemfiles/rails_7_1.gemfile.lock b/packages/aether_observatory/gemfiles/rails_7_1.gemfile.lock new file mode 100644 index 00000000..7d219590 --- /dev/null +++ b/packages/aether_observatory/gemfiles/rails_7_1.gemfile.lock @@ -0,0 +1,163 @@ +PATH + remote: ../../rubocop-powerhome + specs: + rubocop-powerhome (0.5.3) + rubocop (= 1.66.1) + rubocop-performance + rubocop-rails + rubocop-rake + rubocop-rspec + +PATH + remote: .. + specs: + aether_observatory (1.0.0) + activemodel (>= 6.0.6.1) + activesupport (>= 6.0.6.1) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.1.5.1) + activesupport (= 7.1.5.1) + activesupport (7.1.5.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) + tzinfo (~> 2.0) + appraisal (2.5.0) + bundler + rake + thor (>= 0.14.0) + ast (2.4.2) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.8) + byebug (11.1.3) + coderay (1.1.3) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) + csv (3.3.1) + diff-lcs (1.5.1) + docile (1.1.5) + drb (2.2.1) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + json (2.9.0) + language_server-protocol (3.17.0.3) + license_finder (7.2.1) + bundler + csv (~> 3.2) + rubyzip (>= 1, < 3) + thor (~> 1.2) + tomlrb (>= 1.3, < 2.1) + with_env (= 1.1.0) + xml-simple (~> 1.1.9) + logger (1.6.3) + method_source (1.1.0) + minitest (5.25.4) + mutex_m (0.3.0) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.10.1) + byebug (~> 11.0) + pry (>= 0.13, < 0.15) + racc (1.8.1) + rack (3.1.8) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.3) + rexml (3.4.0) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.37.0) + parser (>= 3.3.1.0) + rubocop-performance (1.23.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.27.0) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (3.3.0) + rubocop (~> 1.61) + ruby-progressbar (1.13.0) + rubyzip (2.3.2) + securerandom (0.3.2) + simplecov (0.15.1) + docile (~> 1.1.0) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + thor (1.3.2) + tomlrb (2.0.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.6.0) + with_env (1.1.0) + xml-simple (1.1.9) + rexml + yard (0.9.21) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + activemodel (~> 7.1.3) + activesupport (~> 7.1.3) + aether_observatory! + appraisal (~> 2.5.0) + bundler (~> 2.1) + license_finder (~> 7.0) + pry (>= 0.14) + pry-byebug (= 3.10.1) + rake (~> 13.0) + rspec (~> 3.0) + rubocop-powerhome! + simplecov (= 0.15.1) + yard (= 0.9.21) + +BUNDLED WITH + 2.4.22 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..8f2085e3 --- /dev/null +++ b/packages/aether_observatory/lib/aether_observatory/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module AetherObservatory + VERSION = "1.0.0" +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..c68888fc --- /dev/null +++ b/packages/aether_observatory/spec/aether_observatory/event_base_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "spec_helper" + +module AetherObservatory + RSpec.describe EventBase do + 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") + + # Teardown + observer.stop + 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") + + # Teardown + observer_zero.stop + observer_both.stop + 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") + + # Teardown + observer_zero.stop + observer_both.stop + 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") + + # Teardown + observer.stop + 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 + 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..d1d9a587 --- /dev/null +++ b/packages/aether_observatory/spec/aether_observatory/observer_base_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "spec_helper" + +module AetherObservatory + RSpec.describe ObserverBase do + 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") + + # Teardown + observer.stop + 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") + + # Teardown + observer.stop + 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) + + # Teardown + observer.stop + 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") + + # Teardown + observer.stop + 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 + 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 diff --git a/portal.yml b/portal.yml index bc2cc0e3..f60d0bf8 100644 --- a/portal.yml +++ b/portal.yml @@ -1,6 +1,23 @@ --- apiVersion: backstage.io/v1alpha1 kind: Component +metadata: + name: aether_observatory + title: Aether Observatory + description: >- + Aether Observatory provides a event subscription system, based on + ActiveSupport::Notification. + annotations: + backstage.io/techdocs-ref: dir:packages/aether_observatory +spec: + type: library + owner: unbreakable + lifecycle: production + subcomponentOf: power-tools + system: power-application-framework +--- +apiVersion: backstage.io/v1alpha1 +kind: Component metadata: name: power-tools title: PowerTools