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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/.circleci/config.yml +116 -0
- data/.env.example +6 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +263 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +8 -0
- data/README.md +407 -0
- data/Rakefile +8 -0
- data/bin/console +19 -0
- data/bin/setup +8 -0
- data/certs/daveperrett.pem +25 -0
- data/lib/trailer.rb +40 -0
- data/lib/trailer/concern.rb +89 -0
- data/lib/trailer/configuration.rb +46 -0
- data/lib/trailer/middleware/rack.rb +24 -0
- data/lib/trailer/middleware/sidekiq.rb +20 -0
- data/lib/trailer/railtie.rb +19 -0
- data/lib/trailer/recorder.rb +53 -0
- data/lib/trailer/storage/cloud_watch.rb +99 -0
- data/lib/trailer/utility.rb +60 -0
- data/lib/trailer/version.rb +5 -0
- data/trailer.gemspec +47 -0
- metadata +277 -0
- metadata.gz.sig +0 -0
data/lib/trailer.rb
ADDED
@@ -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
|