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