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.
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
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.
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
+
+## 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
+```
+
+
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