trailer 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailer/concern'
4
+ require 'trailer/configuration'
5
+ require 'trailer/middleware/rack'
6
+ require 'trailer/middleware/sidekiq'
7
+ require 'trailer/railtie' if defined?(Rails::Railtie)
8
+ require 'trailer/recorder'
9
+ require 'trailer/version'
10
+
11
+ module Trailer
12
+ class Error < StandardError; end
13
+
14
+ class << self
15
+ attr_accessor :config
16
+
17
+ # Accepts a block for configuring things.
18
+ def configure
19
+ self.config ||= Configuration.new
20
+ yield(config) if block_given?
21
+
22
+ # Instantiate a new recorder after configuration.
23
+ @storage = config.storage.new if enabled?
24
+ end
25
+
26
+ # Returns true if tracing is enabled, false otherwise.
27
+ def enabled?
28
+ config&.enabled == true
29
+ end
30
+
31
+ # Returns a new recorder instance.
32
+ def new
33
+ return unless enabled?
34
+
35
+ raise Trailer::Error, 'Trailer.configure must be run before recording' if @storage.nil?
36
+
37
+ Trailer::Recorder.new(@storage)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailer/utility'
4
+
5
+ module Trailer
6
+ module Concern
7
+ # Traces the given block, with an event name, plus optional resource and tags.
8
+ #
9
+ # @param event [String] - Describes the generic kind of operation being done (eg. 'web_request', or 'parse_request').
10
+ # @param resource [ApplicationRecord, String] - *Ideally just pass an ActiveRecord instance here.*
11
+ # The resource being operated on, or its name. Usually domain-specific, such as a model
12
+ # instance, query, etc (eg. current_user, 'Article#submit', 'http://example.com/articles').
13
+ # @param tags Hash - Extra tags which should be tracked (eg. { method: 'GET' }).
14
+ def trace_event(event, resource = nil, **tags, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
15
+ return yield block unless Trailer.enabled?
16
+
17
+ event = Trailer::Utility.resource_name(event) unless event.is_a?(String)
18
+
19
+ unless resource.nil?
20
+ resource_name = resource if resource.is_a?(String)
21
+ resource_name ||= Trailer::Utility.resource_name(resource)
22
+
23
+ # If column_names() is available, we are probably looking at an ActiveRecord instance.
24
+ if resource.class.respond_to?(:column_names)
25
+ resource.class.column_names.each do |field|
26
+ tags[field] ||= resource.public_send(field) if field.match?(Trailer.config.auto_tag_fields)
27
+ end
28
+ elsif resource.respond_to?(:to_h)
29
+ # This handles other types of data, such as GraphQL input objects.
30
+ resource.to_h.stringify_keys.each do |key, value|
31
+ tags[key] ||= value if key.to_s.match?(Trailer.config.auto_tag_fields) || Trailer.config.tag_fields.include?(key)
32
+ end
33
+ end
34
+
35
+ # Tag fields that have been explicitly included.
36
+ Trailer.config.tag_fields.each do |field|
37
+ tags[field] ||= resource.public_send(field) if resource.respond_to?(field)
38
+ end
39
+
40
+ tags["#{resource_name}_id"] ||= resource.id if resource.respond_to?(:id)
41
+ end
42
+
43
+ # Record the ID of the current user, if configured.
44
+ if Trailer.config.current_user_method && respond_to?(Trailer.config.current_user_method, true)
45
+ user = send(Trailer.config.current_user_method)
46
+ tags["#{Trailer.config.current_user_method}_id"] = user.id if user&.respond_to?(:id)
47
+ end
48
+
49
+ # Record how long the operation takes, in milliseconds.
50
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ result = yield block
52
+ tags[:duration] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1_000).ceil
53
+
54
+ # Put the keys in alphabetical order, with the event and resource first.
55
+ sorted = { event: event, resource: resource_name }.merge(tags.sort_by { |key, _val| key.to_s }.to_h)
56
+ RequestStore.store[:trailer].write(sorted)
57
+
58
+ result
59
+ end
60
+
61
+ # Traces the given block with optional resource and tags. It will generate an event name based on the
62
+ # calling class, and pass the information on to trace_event().
63
+ #
64
+ # @param resource [ApplicationRecord, String] - *Ideally just pass an ActiveRecord instance here.*
65
+ # The resource being operated on, or its name. Usually domain-specific, such as a model
66
+ # instance, query, etc (eg. current_user, 'Article#submit', 'http://example.com/articles').
67
+ # @param tags Hash - Extra tags which should be tracked (eg. { method: 'GET' }).
68
+ def trace_class(resource = nil, **tags, &block)
69
+ trace_event(self.class.name, resource, **tags) do
70
+ yield block
71
+ end
72
+ end
73
+
74
+ # Traces the given block with optional resource and tags. It will generate an event name based on the
75
+ # calling method and class, and pass the information on to trace_event().
76
+ #
77
+ # @param resource [ApplicationRecord, String] - *Ideally just pass an ActiveRecord instance here.*
78
+ # The resource being operated on, or its name. Usually domain-specific, such as a model
79
+ # instance, query, etc (eg. current_user, 'Article#submit', 'http://example.com/articles').
80
+ # @param tags Hash - Extra tags which should be tracked (eg. { method: 'GET' }).
81
+ def trace_method(resource = nil, **tags, &block)
82
+ calling_klass = self.class.name
83
+ calling_method = caller(1..1).first[/`.*'/][1..-2]
84
+ trace_event("#{calling_klass}##{calling_method}", resource, **tags) do
85
+ yield block
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailer/storage/cloud_watch'
4
+
5
+ module Trailer
6
+ class Configuration
7
+ attr_accessor :application_name,
8
+ :auto_tag_fields,
9
+ :aws_access_key_id,
10
+ :aws_region,
11
+ :aws_secret_access_key,
12
+ :current_user_method,
13
+ :enabled,
14
+ :environment,
15
+ :storage,
16
+ :host_name,
17
+ :service_name,
18
+ :tag_fields
19
+
20
+ # Constructor.
21
+ def initialize
22
+ # The global application or company name.
23
+ @application_name = ENV['TRAILER_APPLICATION_NAME']
24
+ # When tracing ActiveRecord instances, we can tag our trace with fields matching this regex.
25
+ @auto_tag_fields = /(_id|_at)$/.freeze
26
+ # AWS access key with CloudWatch write permission.
27
+ @aws_access_key_id = ENV['AWS_ACCESS_KEY_ID']
28
+ # The AWS region to log to.
29
+ @aws_region = ENV.fetch('AWS_REGION', 'us-east-1')
30
+ # The AWS secret.
31
+ @aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
32
+ # Allows tracing to be explicitly disabled.
33
+ @enabled = true
34
+ # The environment that the application is running (eg. 'production', 'test').
35
+ @environment = ENV['TRAILER_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV']
36
+ # Optional - the name of the individual host or server within the service.
37
+ @host_name = ENV['TRAILER_HOST_NAME']
38
+ # The name of the service within the application.
39
+ @service_name = ENV['TRAILER_SERVICE_NAME']
40
+ # The storage backend class to use.
41
+ @storage = Trailer::Storage::CloudWatch
42
+ # Optional - When tracing ActiveRecord instances, we can tag our trace with these fields explicitly.
43
+ @tag_fields = %w[name]
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trailer
4
+ module Middleware
5
+ class Rack
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ if Trailer.enabled?
12
+ RequestStore.store[:trailer] ||= Trailer.new
13
+ RequestStore.store[:trailer].start
14
+ end
15
+ @app.call(env)
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ RequestStore.store[:trailer].add_exception(e) if Trailer.enabled?
18
+ raise e
19
+ ensure
20
+ RequestStore.store[:trailer].finish if Trailer.enabled?
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trailer
4
+ module Middleware
5
+ class Sidekiq
6
+ def call(_worker, _job, _queue)
7
+ if Trailer.enabled?
8
+ RequestStore.store[:trailer] ||= Trailer.new
9
+ RequestStore.store[:trailer].start
10
+ end
11
+ yield
12
+ rescue Exception => e # rubocop:disable Lint/RescueException
13
+ RequestStore.store[:trailer].add_exception(e) if Trailer.enabled?
14
+ raise e
15
+ ensure
16
+ RequestStore.store[:trailer].finish if Trailer.enabled?
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trailer
4
+ class Railtie < ::Rails::Railtie
5
+ initializer 'trailer.insert_middleware' do |app|
6
+ # Rack middleware.
7
+ app.config.middleware.insert_after RequestStore::Middleware, Trailer::Middleware::Rack if defined?(RequestStore::Middleware)
8
+
9
+ # Sidekiq middleware.
10
+ if defined?(::Sidekiq)
11
+ ::Sidekiq.configure_server do |config|
12
+ config.server_middleware do |chain|
13
+ chain.add Trailer::Middleware::Sidekiq
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trailer
4
+ class Recorder
5
+ # Constructor.
6
+ #
7
+ # @param storage [Object] A storage instance. See https://github.com/Shuttlerock/trailer#storage
8
+ def initialize(storage)
9
+ @storage = storage
10
+ end
11
+
12
+ # Records the exception class and message on the current trace.
13
+ #
14
+ # @param err [Exception] The exception to record.
15
+ def add_exception(err)
16
+ write({ exception: err.class.name, message: err.message, trace: err.backtrace[0..9] })
17
+ end
18
+
19
+ # Finish tracing, and flush storage.
20
+ def finish
21
+ storage.async.flush
22
+ @trace_id = nil
23
+ end
24
+
25
+ # Create a new trace ID to link log entries.
26
+ def start
27
+ raise Trailer::Error, 'finish() must be called before a new trace can be started' unless @trace_id.nil?
28
+
29
+ # See https://github.com/aws/aws-xray-sdk-ruby/blob/1869ca5/lib/aws-xray-sdk/model/segment.rb#L26-L30
30
+ @trace_id = %(1-#{Time.now.to_i.to_s(16)}-#{SecureRandom.hex(12)})
31
+ end
32
+
33
+ # Write the given hash to storage.
34
+ #
35
+ # @param data [Hash] A key-value hash of trace data to write to storage.
36
+ def write(data)
37
+ raise Trailer::Error, 'start() must be called before write()' if @trace_id.nil?
38
+ raise Trailer::Error, 'data must be an instance of Hash' unless data.is_a?(Hash)
39
+ raise Trailer::Error, 'could not convert data to JSON' unless data.respond_to?(:to_json)
40
+
41
+ # Include some standard tags.
42
+ data[:environment] ||= Trailer.config.environment
43
+ data[:host_name] ||= Trailer.config.host_name
44
+ data[:service_name] ||= Trailer.config.service_name
45
+
46
+ storage.async.write(data.compact.merge(trace_id: trace_id))
47
+ end
48
+
49
+ private
50
+
51
+ attr_accessor :storage, :trace_id
52
+ end
53
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-cloudwatchlogs'
4
+ require 'concurrent'
5
+
6
+ module Trailer
7
+ module Storage
8
+ class CloudWatch
9
+ include Concurrent::Async
10
+
11
+ # Constructor.
12
+ def initialize
13
+ self.messages = []
14
+ self.client = Aws::CloudWatchLogs::Client.new(region: Trailer.config.aws_region, credentials: credentials)
15
+ ensure_log_group
16
+ ensure_log_stream
17
+ end
18
+
19
+ # Queues the given hash for writing to CloudWatch.
20
+ #
21
+ # @param data [Hash] A key-value hash of trace data to write to storage.
22
+ def write(data)
23
+ messages << {
24
+ timestamp: (Time.now.utc.to_f.round(3) * 1000).to_i,
25
+ message: data&.to_json,
26
+ }.compact
27
+ end
28
+
29
+ # Sends all of the queued messages to CloudWatch, and resets the messages queue.
30
+ #
31
+ # See https://stackoverflow.com/a/36901509
32
+ def flush
33
+ return if messages.empty?
34
+
35
+ events = {
36
+ log_group_name: Trailer.config.application_name,
37
+ log_stream_name: Trailer.config.application_name,
38
+ log_events: messages,
39
+ sequence_token: sequence_token,
40
+ }
41
+
42
+ response = client.put_log_events(events)
43
+ self.sequence_token = response&.next_sequence_token
44
+ self.messages = []
45
+ rescue Aws::CloudWatchLogs::Errors::InvalidSequenceTokenException
46
+ # Only one client at a time can write to the log. If another client has written before we get a chance,
47
+ # the sequence token is invalidated, and we need to get a new one.
48
+ self.sequence_token = log_stream[:upload_sequence_token]
49
+ retry
50
+ end
51
+
52
+ private
53
+
54
+ attr_accessor :client, :messages, :sequence_token
55
+
56
+ # Returns an AWS credentials instance for writing to CloudWatch.
57
+ def credentials
58
+ Aws::Credentials.new(Trailer.config.aws_access_key_id, Trailer.config.aws_secret_access_key)
59
+ end
60
+
61
+ # Creates the log group, if it doesn't already exist. Ideally we would paginate here in case
62
+ # the account has a lot of log groups with the same prefix, but it seems unlikely to happen.
63
+ def ensure_log_group
64
+ existing = client.describe_log_groups.log_groups.find do |group|
65
+ group.log_group_name == Trailer.config.application_name
66
+ end
67
+
68
+ client.create_log_group(log_group_name: Trailer.config.application_name) unless existing
69
+ rescue Aws::CloudWatchLogs::Errors::ResourceAlreadyExistsException
70
+ # No need to do anything - probably caused by lack of pagination.
71
+ end
72
+
73
+ # Create the log stream, if it doesn't already exist.
74
+ # Ideally we would paginate here in case the account has a lot of log streams.
75
+ def ensure_log_stream
76
+ if (existing = log_stream)
77
+ self.sequence_token = existing.upload_sequence_token
78
+ else
79
+ client.create_log_stream(
80
+ log_group_name: Trailer.config.application_name,
81
+ log_stream_name: Trailer.config.application_name,
82
+ )
83
+ end
84
+ rescue Aws::CloudWatchLogs::Errors::ResourceAlreadyExistsException
85
+ # No need to do anything - probably caused by lack of pagination.
86
+ end
87
+
88
+ # Returns the current log stream, if one exists.
89
+ def log_stream
90
+ client.describe_log_streams(
91
+ log_group_name: Trailer.config.application_name,
92
+ log_stream_name_prefix: Trailer.config.application_name,
93
+ ).log_streams.find do |stream|
94
+ stream.log_stream_name == Trailer.config.application_name
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trailer
4
+ class Utility
5
+ class << self
6
+ # Copied from ActiveSupport::Inflector to avoid introducing an extra dependency.
7
+ #
8
+ # @param path [String] The path to demodulize.
9
+ #
10
+ # @see https://apidock.com/rails/ActiveSupport/Inflector/demodulize
11
+ #
12
+ # Removes the module part from the expression in the string.
13
+ #
14
+ # demodulize('ActiveSupport::Inflector::Inflections') # => "Inflections"
15
+ # demodulize('Inflections') # => "Inflections"
16
+ # demodulize('::Inflections') # => "Inflections"
17
+ # demodulize('') # => ""
18
+ def demodulize(path)
19
+ path = path.to_s
20
+ if (i = path.rindex('::'))
21
+ path[(i + 2)..]
22
+ else
23
+ path
24
+ end
25
+ end
26
+
27
+ # Copied from ActiveSupport::Inflector to avoid introducing an extra dependency.
28
+ #
29
+ # @param camel_cased_word [String] The word to underscore.
30
+ #
31
+ # @see https://apidock.com/rails/v5.2.3/ActiveSupport/Inflector/underscore
32
+ #
33
+ # Makes an underscored, lowercase form from the expression in the string.
34
+ #
35
+ # Changes '::' to '/' to convert namespaces to paths.
36
+ #
37
+ # underscore('ActiveModel') # => "active_model"
38
+ # underscore('ActiveModel::Errors') # => "active_model/errors"
39
+ def underscore(camel_cased_word)
40
+ return camel_cased_word unless /[A-Z-]|::/.match?(camel_cased_word)
41
+
42
+ word = camel_cased_word.to_s.gsub('::', '/')
43
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
44
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
45
+ word.tr!('-', '_')
46
+ word.downcase!
47
+ word
48
+ end
49
+
50
+ # Creates a name for the given resource instance, suitable for recording in the trace.
51
+ #
52
+ # @param resource [Object] The resource instance to derive a name for.
53
+ def resource_name(resource)
54
+ return if resource.nil?
55
+
56
+ underscore(demodulize(resource.class.name))
57
+ end
58
+ end
59
+ end
60
+ end