stenotype 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/cloud/pubsub'
4
+
5
+ module Stenotype
6
+ module Adapters
7
+ #
8
+ # An adapter implementing method {#publish} to send data to Google Cloud PubSub
9
+ #
10
+ class GoogleCloud < Base
11
+ #
12
+ # @param event_data {Hash} The data to be published to Google Cloud
13
+ # @raise {Stenotype::Exceptions::GoogleCloudUnsupportedMode} unless the mode
14
+ # in configured to be :sync or :async
15
+ # @raise {Stenotype::Exceptions::MessageNotPublished} unless message is published
16
+ #
17
+ # rubocop:disable Metrics/MethodLength
18
+ #
19
+ def publish(event_data, **additional_arguments)
20
+ case config.gc_mode
21
+ when :async
22
+ topic.publish_async(event_data, additional_arguments) do |result|
23
+ raise Stenotype::Exceptions::MessageNotPublished unless result.succeeded?
24
+ end
25
+ when :sync
26
+ topic.publish(event_data, additional_arguments)
27
+ else
28
+ raise Stenotype::Exceptions::GoogleCloudUnsupportedMode,
29
+ 'Please use either :sync or :async modes for publishing the events.'
30
+ end
31
+ end
32
+ # rubocop:enable Metrics/MethodLength
33
+
34
+ private
35
+
36
+ # :nocov:
37
+ def client
38
+ @client ||= Google::Cloud::PubSub.new(
39
+ project_id: config.gc_project_id,
40
+ credentials: config.gc_credentials
41
+ )
42
+ end
43
+
44
+ # Use memoization, otherwise a new topic will be created
45
+ # every time. And a new async_publisher will be created.
46
+ # :nocov:
47
+ def topic
48
+ @topic ||= client.topic config.gc_topic
49
+ end
50
+
51
+ def config
52
+ Stenotype.config
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ module Adapters
5
+ #
6
+ # An adapter implementing method {#publish} to send data to STDOUT
7
+ #
8
+ class StdoutAdapter < Base
9
+ #
10
+ # @param event_data {Hash} The data to be published to STDOUT
11
+ #
12
+ def publish(event_data, **additional_arguments)
13
+ client.info(event_data, **additional_arguments)
14
+ end
15
+
16
+ private
17
+
18
+ def client
19
+ @client ||= Logger.new(STDOUT)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stenotype/adapters/base'
4
+ require 'stenotype/adapters/google_cloud'
5
+ require 'stenotype/adapters/stdout_adapter'
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ #
5
+ # A module containing freshly-event gem configuration
6
+ #
7
+ module Configuration
8
+ class << self
9
+ # @return {Array<#publish>} a list of targets responding to method [#publish]
10
+ attr_writer :targets
11
+ # @return {Sting} a string with GC API credential. Refer to GC PubSub documentation
12
+ attr_accessor :gc_credentials
13
+ # @return {String} a name of the project in GC PubSub
14
+ attr_accessor :gc_project_id
15
+ # @return {String} a name of the topic in GC PubSub
16
+ attr_accessor :gc_topic
17
+ # @return [:sync, :async] GC publish mode
18
+ attr_accessor :gc_mode
19
+ # @return {#publish} as object responding to method [#publish]
20
+ attr_accessor :dispatcher
21
+ # @return {#uuid} an object responding to method [#uuid]
22
+ attr_accessor :uuid_generator
23
+
24
+ #
25
+ # Yields control to the caller
26
+ # @yield {Stenotype::Configuration}
27
+ # @return {Stenotype::Configuration}
28
+ #
29
+ def configure
30
+ yield self
31
+ self
32
+ end
33
+
34
+ #
35
+ # @raise {Stenotype::Exceptions::NoTargetsSpecified} in case no targets are configured
36
+ # @return {Array<#publish>} An array of targets implementing method [#publish]
37
+ #
38
+ def targets
39
+ if @targets.nil? || @targets.empty?
40
+ raise Stenotype::Exceptions::NoTargetsSpecified,
41
+ 'Please configure a target(s) for events to be sent to. ' \
42
+ 'See Stenotype::Configuration for reference.'
43
+ else
44
+ @targets
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ module ContextHandlers
5
+ #
6
+ # An abstract base class for implementing contexts handlers
7
+ #
8
+ # @abstract
9
+ # @attr_reader {Object} context A context in which the event was emitted
10
+ # @attr_reader {Hash} options A hash of additional options
11
+ #
12
+ class Base
13
+ attr_reader :context, :options
14
+
15
+ #
16
+ # @param context {Object} A context where the event was emitted
17
+ # @param options {Hash} A hash of additional options
18
+ #
19
+ # @return {#as_json} A context handler implementing [#as_json]
20
+ #
21
+ def initialize(context, options = {})
22
+ @context = context
23
+ @options = options
24
+ end
25
+
26
+ #
27
+ # @abstract
28
+ # @raise {NotImplementedError} subclasses must implement this method
29
+ #
30
+ def as_json(*_args)
31
+ raise NotImplementedError, "#{self} must implement method ##{__method__}"
32
+ end
33
+
34
+ #
35
+ # @attr_writer {Symbol} handler_name The name under which a handler is going to be registered
36
+ #
37
+ class << self
38
+ # Handler name by which it will be registered in {Stenotype::ContextHandlers::Collection}
39
+ attr_writer :handler_name
40
+
41
+ #
42
+ # @return {Symbol} Name of the handler
43
+ # @raise {NotImplementedError} in case handler name is not specified.
44
+ #
45
+ def handler_name
46
+ @handler_name || raise(NotImplementedError,
47
+ "Please, specify the handler_name of #{self}")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ module ContextHandlers
5
+ #
6
+ # A class representing a list of available context handlers
7
+ #
8
+ class Collection < Array
9
+ #
10
+ # @param handler_name {Symbol} a handler to be found in the collection
11
+ # @raise {Stenotype::Exceptions::UnknownHandler} in case a handler is not registered
12
+ # @return {#as_json} A handler which respond to #as_json
13
+ #
14
+ def choose(handler_name:)
15
+ handler = detect { |e| e.handler_name == handler_name }
16
+ handler || raise(Stenotype::Exceptions::UnknownHandler,
17
+ "Handler '#{handler_name}' is not found. " \
18
+ "Please make sure the handler you've specified is " \
19
+ 'registered in the list of known handlers. ' \
20
+ "See #{Stenotype::ContextHandlers} for more information.")
21
+ end
22
+
23
+ #
24
+ # @param handler {#as_json} a new handler to be added to collection
25
+ # @raise {ArgumentError} in case handler does not inherit from {Stenotype::ContextHandlers::Base}
26
+ # @return {Stenotype::ContextHandlers::Collection} a collection object
27
+ #
28
+ def register(handler)
29
+ unless handler < Stenotype::ContextHandlers::Base
30
+ raise ArgumentError,
31
+ "Handler must inherit from #{Stenotype::ContextHandlers::Base}, " \
32
+ "but inherited from #{handler.superclass}"
33
+ end
34
+
35
+ push(handler) unless registered?(handler)
36
+ self
37
+ end
38
+
39
+ #
40
+ # @param handler {#as_json} a handler to be removed from the collection of known handlers
41
+ # @raise {ArgumentError} in case handler does not inherit from {Stenotype::ContextHandlers::Base}
42
+ # @return {Stenotype::ContextHandlers::Collection} a collection object
43
+ #
44
+ def unregister(handler)
45
+ unless handler < Stenotype::ContextHandlers::Base
46
+ raise ArgumentError,
47
+ "Handler must inherit from #{Stenotype::ContextHandlers::Base}, " \
48
+ "but inherited from #{handler.superclass}"
49
+ end
50
+
51
+ delete(handler) if registered?(handler)
52
+ self
53
+ end
54
+
55
+ #
56
+ # @param handler {#as_json} a handler to be checked for presence in a collection
57
+ # @return [true, false]
58
+ #
59
+ def registered?(handler)
60
+ include?(handler)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ module ContextHandlers
5
+ #
6
+ # Plain Ruby class handler to support fetching data from a class
7
+ #
8
+ class Klass < Stenotype::ContextHandlers::Base
9
+ self.handler_name = :klass
10
+
11
+ #
12
+ # @todo r.kapitonov Figure out the params
13
+ # @return {Hash} a JSON representation of a Ruby class
14
+ #
15
+ def as_json(*_args)
16
+ {}
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ module ContextHandlers
5
+ #
6
+ # A namespace containing extension of rails components
7
+ # for fetching specific data from those components.
8
+ # For example fetching request from a controller context,
9
+ # or fetching ActiveJob attributes from an ActiveJob instance
10
+ #
11
+ module Rails
12
+ #
13
+ # ActiveJob handler to support fetching data out of an ActiveJob instance
14
+ #
15
+ class ActiveJob < Stenotype::ContextHandlers::Base
16
+ self.handler_name = :active_job
17
+
18
+ #
19
+ # @todo How to deal with _args? It won't necessarily respond to #as_json
20
+ # @return {Hash} a JSON representation of job's data
21
+ #
22
+ def as_json(*_args)
23
+ {
24
+ job_id: job_id,
25
+ enqueued_at: Time.now,
26
+ queue_name: queue_name,
27
+ class: context.class.name
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def job_id
34
+ context.job_id
35
+ end
36
+
37
+ def queue_name
38
+ context.queue_name
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ module ContextHandlers
5
+ module Rails
6
+ #
7
+ # ActionController handler to support fetching data out of a rails controller instance
8
+ #
9
+ class Controller < Stenotype::ContextHandlers::Base
10
+ self.handler_name = :controller
11
+
12
+ #
13
+ # @return {Hash} a JSON representation of controller's data
14
+ #
15
+ def as_json(*_args)
16
+ {
17
+ class: request.controller_class.name,
18
+ method: request.method,
19
+ url: request.url,
20
+ referer: request.referer,
21
+ params: request.params.except('controller', 'action'),
22
+ ip: request.remote_ip
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def request
29
+ context.request
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stenotype/context_handlers/base'
4
+ require 'stenotype/context_handlers/rails/controller'
5
+ require 'stenotype/context_handlers/rails/active_job'
6
+ require 'stenotype/context_handlers/klass'
7
+ require 'stenotype/context_handlers/collection'
8
+
9
+ module Stenotype
10
+ #
11
+ # A namespace to contain various context
12
+ # handlers implementations
13
+ #
14
+ module ContextHandlers
15
+ class << self
16
+ attr_writer :known
17
+ #
18
+ # @return {Array<#publish>} A list of handlers implementing [#publish]
19
+ #
20
+ def known
21
+ @known ||= Stenotype::ContextHandlers::Collection.new
22
+ end
23
+
24
+ #
25
+ # @param handler {#publish} A handler with implemented method [#publish]
26
+ #
27
+ def register(handler)
28
+ known.register(handler)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ #
5
+ # {Stenotype::Dispatcher} is responsible for gluing together
6
+ # publishing targets and data gathering.
7
+ #
8
+ class Dispatcher
9
+ #
10
+ # Publishes an event to the list of configured targets.
11
+ #
12
+ # @example
13
+ #
14
+ # event = Stenotype::Event.new(data, options, eval_context)
15
+ # Stenotype::Dispatcher.new.publish(event)
16
+ #
17
+ # @param event {Stenotype::Event} An instance of event to be published.
18
+ # @param serializer {#serialize} A class responsible for serializing the event
19
+ # @return {Stenotype::Dispatcher} for the sake of chaining
20
+ #
21
+ def publish(event, serializer: Stenotype::EventSerializer)
22
+ event_data = serializer.new(event).serialize
23
+
24
+ targets.each do |t|
25
+ t.publish(event_data)
26
+ end
27
+
28
+ self
29
+ end
30
+
31
+ private
32
+
33
+ def targets
34
+ Stenotype.config.targets
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ #
5
+ # {Stenotype::Event} represents a triggered event
6
+ #
7
+ class Event
8
+ #
9
+ # Delegates event to instance of {Stenotype::Event}.
10
+ #
11
+ # @example
12
+ #
13
+ # Stenotype::Event.emit!(data, options, eval_context)
14
+ #
15
+ # @param data {Hash} Data to be published to the targets.
16
+ # @param options {Hash} A hash of additional options to be tracked.
17
+ # @param eval_context {Hash} A context having handler defined in {Stenotype::ContextHandlers}.
18
+ # @param dispatcher {#publish} A dispatcher object responding to [#publish]
19
+ # @return {Stenotype::Event} An instance of {Stenotype::Event}
20
+ #
21
+ def self.emit!(data, options: {}, eval_context: {}, dispatcher: Stenotype.config.dispatcher)
22
+ event = new(data, options: options, eval_context: eval_context, dispatcher: dispatcher)
23
+ event.emit!
24
+ event
25
+ end
26
+
27
+ attr_reader :data, :options, :eval_context, :dispatcher
28
+
29
+ #
30
+ # @example
31
+ #
32
+ # Stenotype::Event.emit!(data, options, eval_context)
33
+ #
34
+ # @param {Hash} data Data to be published to the targets.
35
+ # @param {Hash} options A hash of additional options to be tracked.
36
+ # @param {Hash} eval_context A context having handler defined in {Stenotype::ContextHandlers}.
37
+ # @param dispatcher {#publish} A dispatcher object responding to [#publish].
38
+ # @return {Stenotype::Event} An instance of event
39
+ #
40
+ def initialize(data, options: {}, eval_context: {}, dispatcher: Stenotype.config.dispatcher)
41
+ @data = data
42
+ @options = options
43
+ @eval_context = eval_context
44
+ @dispatcher = dispatcher
45
+ end
46
+
47
+ #
48
+ # Emits a {Stenotype::Event}.
49
+ #
50
+ # @example
51
+ #
52
+ # event = Stenotype::Event.new(data, options, eval_context)
53
+ # event.emit!
54
+ #
55
+ def emit!
56
+ dispatcher.publish(self)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ require 'securerandom'
3
+
4
+ module Stenotype
5
+ #
6
+ # A class used to serialize a {Stenotype::Event}
7
+ # upon publishing it to targets
8
+ #
9
+ class EventSerializer
10
+ attr_reader :event, :uuid_generator
11
+
12
+ #
13
+ # @param event {Stenotype::Event}
14
+ # @param uuid_generator {#uuid} an object responding to [#uuid]
15
+ #
16
+ def initialize(event, uuid_generator: Stenotype.config.uuid_generator)
17
+ @event = event
18
+ @uuid_generator = uuid_generator
19
+ end
20
+
21
+ #
22
+ # @return {Hash} A hash representation of the event and its context
23
+ #
24
+ def serialize
25
+ {
26
+ **event_data,
27
+ **event_options,
28
+ **default_options,
29
+ **eval_context_options
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def event_data
36
+ event.data
37
+ end
38
+
39
+ def event_options
40
+ event.options
41
+ end
42
+
43
+ def eval_context
44
+ event.eval_context
45
+ end
46
+
47
+ def eval_context_options
48
+ eval_context.map do |context_name, context|
49
+ handler = Stenotype::ContextHandlers.known.choose(handler_name: context_name)
50
+ handler.new(context).as_json
51
+ end.reduce(:merge!) || {}
52
+ end
53
+
54
+ def default_options
55
+ {
56
+ timestamp: Time.now.utc,
57
+ uuid: uuid_generator.uuid
58
+ }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stenotype
4
+ #
5
+ # A namespace for holding library-level exceptions.
6
+ #
7
+ module Exceptions
8
+ #
9
+ # This exception is being raised in case an unsupported mode
10
+ # for Google Cloud is specified.
11
+ #
12
+ class GoogleCloudUnsupportedMode < StandardError; end
13
+
14
+ #
15
+ # This exception is being raised upon unsuccessful publishing of an event.
16
+ #
17
+ class MessageNotPublished < StandardError; end
18
+
19
+ #
20
+ # This exception is being raised in case no targets are
21
+ # specified {Stenotype::Configuration}.
22
+ #
23
+ class NoTargetsSpecified < StandardError; end
24
+
25
+ #
26
+ # This exception is being raised upon using a context handler which
27
+ # has never been registered in known handlers in {Stenotype::ContextHandlers::Collection}.
28
+ #
29
+ class UnknownHandler < StandardError; end
30
+ end
31
+ end