trailer 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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