sixty 0.1.0
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
- data/README.md +326 -0
- data/lib/sixty/aggregator.rb +257 -0
- data/lib/sixty/config.rb +155 -0
- data/lib/sixty/exporter.rb +144 -0
- data/lib/sixty/instrument/action_controller.rb +44 -0
- data/lib/sixty/instrument/active_record.rb +139 -0
- data/lib/sixty/instrument/mongo.rb +482 -0
- data/lib/sixty/instrument/mysql.rb +187 -0
- data/lib/sixty/instrument/pg.rb +197 -0
- data/lib/sixty/instrument/rack.rb +158 -0
- data/lib/sixty/instrumented.rb +204 -0
- data/lib/sixty/plans.rb +258 -0
- data/lib/sixty/railtie.rb +49 -0
- data/lib/sixty/shape.rb +91 -0
- data/lib/sixty/sketch.rb +211 -0
- data/lib/sixty/sql.rb +441 -0
- data/lib/sixty/stack.rb +95 -0
- data/lib/sixty/tracer.rb +222 -0
- data/lib/sixty/version.rb +5 -0
- data/lib/sixty.rb +330 -0
- metadata +97 -0
data/lib/sixty/config.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sixty
|
|
4
|
+
# Configuration, under both names.
|
|
5
|
+
#
|
|
6
|
+
# The product is called sixty, so its variables are `SIXTY_*`. They used to be
|
|
7
|
+
# `DRIFT_*`, and that name is not ours to retire: it is set in other people's
|
|
8
|
+
# deployments — a Dockerfile, a Fly secret, a CI job someone configured once
|
|
9
|
+
# and has not thought about since. Renaming without reading the old name would
|
|
10
|
+
# not produce an error anybody could act on. It would produce an agent that
|
|
11
|
+
# starts cleanly, finds no key, and reports nothing, in exactly the silence
|
|
12
|
+
# this product exists to eliminate.
|
|
13
|
+
#
|
|
14
|
+
# Nothing here warns when the old name is used. A warning would fire on every
|
|
15
|
+
# boot of a correctly configured service that simply predates the rename,
|
|
16
|
+
# which teaches people to filter our log lines out.
|
|
17
|
+
class Config
|
|
18
|
+
ATTRIBUTES = %i[
|
|
19
|
+
api_key endpoint service environment release repo_url
|
|
20
|
+
flush_interval sample_rate slow_trace_ms capture_plans
|
|
21
|
+
instrument ignore_paths debug on_warn logger
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
attr_accessor(*ATTRIBUTES)
|
|
25
|
+
|
|
26
|
+
# Most PaaS providers set one of these without the user doing anything, and
|
|
27
|
+
# release attribution is what makes deploy-anchored detection possible — so
|
|
28
|
+
# the agent tries hard to find one before giving up. Without a release, two
|
|
29
|
+
# deploys are one undifferentiated stream and nothing can be compared
|
|
30
|
+
# against anything.
|
|
31
|
+
RELEASE_VARS = %w[
|
|
32
|
+
HEROKU_SLUG_COMMIT RENDER_GIT_COMMIT RAILWAY_GIT_COMMIT_SHA
|
|
33
|
+
GITHUB_SHA FLY_MACHINE_VERSION SOURCE_VERSION VERCEL_GIT_COMMIT_SHA
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
def initialize(options = {})
|
|
37
|
+
@api_key = options[:api_key] || env('API_KEY')
|
|
38
|
+
@endpoint = options[:endpoint] || env('ENDPOINT') || 'http://localhost:4319'
|
|
39
|
+
@service = options[:service] || env('SERVICE') || infer_service
|
|
40
|
+
@environment = options[:environment] || env('ENV') || rails_env || 'development'
|
|
41
|
+
@release = options[:release] || env('RELEASE') || detect_release || ''
|
|
42
|
+
@repo_url = options[:repo_url] || env('REPO_URL') || github_repo_url || ''
|
|
43
|
+
# Seconds when passed in code, milliseconds in the environment — because
|
|
44
|
+
# `SIXTY_FLUSH_MS` is the name every other agent in this repository reads,
|
|
45
|
+
# and a Ruby app and a Node app sharing a compose file must not need two
|
|
46
|
+
# spellings of the same knob.
|
|
47
|
+
@flush_interval = options[:flush_interval] || (env_number('FLUSH_MS', 15_000) / 1000.0)
|
|
48
|
+
@sample_rate = options[:sample_rate] || env_number('SAMPLE_RATE', 0.05)
|
|
49
|
+
@slow_trace_ms = options[:slow_trace_ms] || env_number('SLOW_TRACE_MS', 1000)
|
|
50
|
+
@capture_plans = options.fetch(:capture_plans, env('CAPTURE_PLANS') != '0')
|
|
51
|
+
# Which database clients to measure. nil means "decide from what is in the
|
|
52
|
+
# process" — see Sixty.install_database_instrumentation, which is where
|
|
53
|
+
# the ActiveRecord-versus-raw-driver choice is made and explained.
|
|
54
|
+
@instrument = options[:instrument] || env_list('INSTRUMENT')
|
|
55
|
+
# Health checks and asset requests are noise: they are not the
|
|
56
|
+
# application, and left in they dominate the operation list of every
|
|
57
|
+
# service behind a load balancer.
|
|
58
|
+
@ignore_paths = options[:ignore_paths] || [
|
|
59
|
+
%r{\A/assets/}, %r{\A/packs/}, %r{\A/rails/active_storage/},
|
|
60
|
+
%r{\A/health}, %r{\A/up\z}, /favicon\.ico\z/
|
|
61
|
+
]
|
|
62
|
+
@debug = options.fetch(:debug, env('DEBUG') == '1')
|
|
63
|
+
@logger = options[:logger] || rails_logger
|
|
64
|
+
@on_warn = options[:on_warn] || default_warn
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def active?
|
|
68
|
+
!api_key.to_s.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ignore?(path)
|
|
72
|
+
ignore_paths.any? { |pattern| pattern.match?(path) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Emptiness is checked per name rather than after choosing one. `SIXTY_X=`
|
|
78
|
+
# in a compose file is someone who added the variable and has not filled it
|
|
79
|
+
# in yet — if that shadowed a `DRIFT_X` that is actually set, adding the new
|
|
80
|
+
# name would break a working deployment, which is the one outcome this
|
|
81
|
+
# lookup exists to prevent.
|
|
82
|
+
def env(name)
|
|
83
|
+
["SIXTY_#{name}", "DRIFT_#{name}"].each do |key|
|
|
84
|
+
value = ENV.fetch(key, nil)
|
|
85
|
+
return value if value && !value.empty?
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def env_number(name, fallback)
|
|
91
|
+
raw = env(name)
|
|
92
|
+
return fallback if raw.nil?
|
|
93
|
+
|
|
94
|
+
value = Float(raw, exception: false)
|
|
95
|
+
value.nil? ? fallback : value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# SIXTY_INSTRUMENT=pg,mongo — for the application that knows better than the
|
|
99
|
+
# default, usually because it uses ActiveRecord *and* a driver directly.
|
|
100
|
+
def env_list(name)
|
|
101
|
+
raw = env(name)
|
|
102
|
+
return nil if raw.nil?
|
|
103
|
+
|
|
104
|
+
raw.split(',').map { |part| part.strip.downcase.to_sym }.reject { |part| part.to_s.empty? }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def detect_release
|
|
108
|
+
RELEASE_VARS.each do |key|
|
|
109
|
+
value = ENV.fetch(key, nil)
|
|
110
|
+
return value if value && !value.empty?
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def github_repo_url
|
|
116
|
+
repo = ENV.fetch('GITHUB_REPOSITORY', nil)
|
|
117
|
+
repo && !repo.empty? ? "https://github.com/#{repo}" : nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def infer_service
|
|
121
|
+
return rails_application_name if rails_application_name
|
|
122
|
+
|
|
123
|
+
File.basename(Dir.pwd)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
'unknown-service'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def rails_application_name
|
|
129
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
130
|
+
|
|
131
|
+
::Rails.application.class.module_parent_name.to_s.gsub(/([a-z])([A-Z])/, '\1-\2').downcase
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def rails_env
|
|
137
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def rails_logger
|
|
141
|
+
defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def default_warn
|
|
145
|
+
log = @logger
|
|
146
|
+
lambda do |message|
|
|
147
|
+
if log
|
|
148
|
+
log.warn(message)
|
|
149
|
+
else
|
|
150
|
+
warn(message)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Sixty
|
|
8
|
+
# Transport. Ships aggregate windows and exemplar traces to the collector.
|
|
9
|
+
#
|
|
10
|
+
# Rules this obeys, because an observability agent that harms the host is
|
|
11
|
+
# worse than no agent at all:
|
|
12
|
+
# - never block the request path (the flush runs on the agent's own thread)
|
|
13
|
+
# - never grow without bound (the exemplar queue is capped and drops oldest)
|
|
14
|
+
# - never keep the process alive (the thread is not joined at exit; the
|
|
15
|
+
# final flush is bounded by its own timeout)
|
|
16
|
+
# - never raise into user code (every failure is swallowed and counted)
|
|
17
|
+
class Exporter
|
|
18
|
+
MAX_QUEUED_EXEMPLARS = 200
|
|
19
|
+
TIMEOUT_SECONDS = 10
|
|
20
|
+
|
|
21
|
+
attr_reader :failures
|
|
22
|
+
|
|
23
|
+
def initialize(endpoint:, api_key:, service:, environment:, release:, repo_url: '',
|
|
24
|
+
path: '/v1/ingest', on_warn: ->(_msg) {})
|
|
25
|
+
@endpoint = endpoint.to_s.sub(%r{/\z}, '')
|
|
26
|
+
# The collector's receive path, appended to the endpoint — and overridable
|
|
27
|
+
# because not every endpoint is the collector. A service reporting through
|
|
28
|
+
# a proxy mounted on its own origin is given the exact URL that proxy
|
|
29
|
+
# lives at, and appending anything to it produces a route the application
|
|
30
|
+
# never registered. That failure is unusually quiet: the request 404s, the
|
|
31
|
+
# agent swallows it like any other transport error, and the only symptom
|
|
32
|
+
# is an absence of data.
|
|
33
|
+
@path = path
|
|
34
|
+
@api_key = api_key
|
|
35
|
+
@resource = {
|
|
36
|
+
service: service,
|
|
37
|
+
environment: environment,
|
|
38
|
+
release: release,
|
|
39
|
+
repoUrl: repo_url.to_s
|
|
40
|
+
}
|
|
41
|
+
@on_warn = on_warn
|
|
42
|
+
@exemplars = []
|
|
43
|
+
@mutex = Mutex.new
|
|
44
|
+
@failures = 0
|
|
45
|
+
@warned_at = 0.0
|
|
46
|
+
@last_attempt = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def queue_exemplar(trace)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@exemplars.shift if @exemplars.length >= MAX_QUEUED_EXEMPLARS
|
|
52
|
+
@exemplars << trace
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param metrics [Hash, nil] a drained aggregator window
|
|
57
|
+
# @param sections [Hash] payload sections this exporter knows nothing about,
|
|
58
|
+
# so a future adapter can add one without teaching the transport about it
|
|
59
|
+
# @param timeout [Numeric] socket timeout; shortened for the exit flush,
|
|
60
|
+
# where the process is trying to stop and a dead collector must not be
|
|
61
|
+
# able to hold it open
|
|
62
|
+
def flush(metrics, sections: {}, timeout: TIMEOUT_SECONDS)
|
|
63
|
+
exemplars = @mutex.synchronize do
|
|
64
|
+
taken = @exemplars
|
|
65
|
+
@exemplars = []
|
|
66
|
+
taken
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
has_sections = sections.any? { |_k, v| !v.nil? }
|
|
70
|
+
return if metrics.nil? && exemplars.empty? && !has_sections
|
|
71
|
+
|
|
72
|
+
# A collector that is down stops being contacted for a while, rather than
|
|
73
|
+
# being dialled on every interval for as long as the outage lasts. The
|
|
74
|
+
# window that would have been sent is dropped here, deliberately: an agent
|
|
75
|
+
# that retained data through an outage would be an agent whose memory
|
|
76
|
+
# footprint is decided by somebody else's uptime.
|
|
77
|
+
return if backing_off?
|
|
78
|
+
|
|
79
|
+
body = JSON.generate(
|
|
80
|
+
{
|
|
81
|
+
resource: @resource,
|
|
82
|
+
metrics: metrics,
|
|
83
|
+
exemplars: exemplars,
|
|
84
|
+
sentAt: (Time.now.to_f * 1000).round
|
|
85
|
+
}.merge(sections.reject { |_k, v| v.nil? })
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
post(body, timeout)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Exponential, capped, and counted from the first failure — so a collector
|
|
94
|
+
# that is briefly unreachable is retried on the next interval, and one that
|
|
95
|
+
# has been down for an hour is dialled every five minutes instead of every
|
|
96
|
+
# fifteen seconds.
|
|
97
|
+
MAX_BACKOFF_SECONDS = 300
|
|
98
|
+
|
|
99
|
+
def backing_off?
|
|
100
|
+
return false if @failures < 2 || @last_attempt.nil?
|
|
101
|
+
|
|
102
|
+
delay = [2**[@failures, 8].min, MAX_BACKOFF_SECONDS].min
|
|
103
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_attempt) < delay
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def post(body, timeout)
|
|
107
|
+
@last_attempt = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
uri = URI.parse("#{@endpoint}#{@path}")
|
|
109
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
110
|
+
http.use_ssl = uri.scheme == 'https'
|
|
111
|
+
http.open_timeout = timeout
|
|
112
|
+
http.read_timeout = timeout
|
|
113
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
114
|
+
|
|
115
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
116
|
+
request['content-type'] = 'application/json'
|
|
117
|
+
# Omitted rather than sent empty when there is no key, so a
|
|
118
|
+
# misconfiguration reads as "no credential" at the collector rather than
|
|
119
|
+
# as an empty one it has to decide what to do with.
|
|
120
|
+
request['authorization'] = "Bearer #{@api_key}" if @api_key && !@api_key.empty?
|
|
121
|
+
request.body = body
|
|
122
|
+
|
|
123
|
+
response = http.request(request)
|
|
124
|
+
if response.code.to_i >= 300
|
|
125
|
+
warn_once("ingest rejected payload: #{response.code} #{response.body.to_s[0, 200]}")
|
|
126
|
+
else
|
|
127
|
+
@failures = 0
|
|
128
|
+
end
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
warn_once("ingest unreachable: #{e.message}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def warn_once(message)
|
|
134
|
+
@failures += 1
|
|
135
|
+
# The first failure is logged, then at most one a minute. An agent that
|
|
136
|
+
# spams stderr through a collector outage is its own incident.
|
|
137
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
return unless @failures == 1 || now - @warned_at > 60
|
|
139
|
+
|
|
140
|
+
@warned_at = now
|
|
141
|
+
@on_warn.call("sixty: #{message}#{@failures > 1 ? " (#{@failures} failures)" : ''}")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tracer'
|
|
4
|
+
|
|
5
|
+
module Sixty
|
|
6
|
+
module Instrument
|
|
7
|
+
# Controller actions, as operations of their own.
|
|
8
|
+
#
|
|
9
|
+
# The Rack span says a request took 900ms. The query spans say the database
|
|
10
|
+
# took 40ms. Without a span in between, the remaining 860ms lands on the
|
|
11
|
+
# request as self time — true, but not actionable: "GET /orders got slower"
|
|
12
|
+
# names an endpoint, not a change. `OrdersController#index` names the method
|
|
13
|
+
# somebody edited.
|
|
14
|
+
#
|
|
15
|
+
# It is also the layer that makes fanout readable. `db_calls` is credited to
|
|
16
|
+
# every ancestor, so an N+1 introduced in a controller shows up here as the
|
|
17
|
+
# call count changing, which is the sentence a person can act on: "this
|
|
18
|
+
# action went from 3 queries to 41".
|
|
19
|
+
#
|
|
20
|
+
# `process_action` is the hook rather than an `around_action` for one
|
|
21
|
+
# reason: an around_action can be skipped, reordered, or short-circuited by
|
|
22
|
+
# another filter — including a `before_action` that renders and halts, which
|
|
23
|
+
# is exactly the request whose measurement matters most.
|
|
24
|
+
module ActionController
|
|
25
|
+
def process_action(*args)
|
|
26
|
+
return super unless Sixty.enabled?
|
|
27
|
+
|
|
28
|
+
Sixty.trace("#{self.class.name}##{action_name}") { super }
|
|
29
|
+
end
|
|
30
|
+
ruby2_keywords :process_action if respond_to?(:ruby2_keywords, true)
|
|
31
|
+
|
|
32
|
+
def self.install
|
|
33
|
+
return false unless defined?(::ActiveSupport)
|
|
34
|
+
|
|
35
|
+
::ActiveSupport.on_load(:action_controller) do
|
|
36
|
+
# `self` here is ActionController::Base or ::API, whichever the
|
|
37
|
+
# application loaded — both, in an app that has each.
|
|
38
|
+
prepend Sixty::Instrument::ActionController
|
|
39
|
+
end
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tracer'
|
|
4
|
+
require_relative '../sql'
|
|
5
|
+
require_relative '../stack'
|
|
6
|
+
require_relative '../plans'
|
|
7
|
+
|
|
8
|
+
module Sixty
|
|
9
|
+
module Instrument
|
|
10
|
+
# ActiveRecord.
|
|
11
|
+
#
|
|
12
|
+
# What is captured beyond timing is the point of the product:
|
|
13
|
+
# rows — how many the statement returned. The 30 -> 30,000 signal, and the
|
|
14
|
+
# one an ORM makes easy to lose: `.where(...)` without a limit is
|
|
15
|
+
# one character away from `.all`, and on a development database
|
|
16
|
+
# both take four milliseconds.
|
|
17
|
+
# calls — every query, credited to every enclosing method and to the
|
|
18
|
+
# request. One query becoming twelve is an N+1 being born.
|
|
19
|
+
#
|
|
20
|
+
# ── Why a notification subscriber and not a patched adapter ───────────────
|
|
21
|
+
#
|
|
22
|
+
# `sql.active_record` is a public, stable interface that every adapter emits
|
|
23
|
+
# and every Rails version has. Patching `exec_query` would work on one
|
|
24
|
+
# adapter and one version and break quietly on the next, and "quietly" is
|
|
25
|
+
# the failure mode this project refuses. The cost is that the event arrives
|
|
26
|
+
# *after* the query — so the span is assembled from the event's own start
|
|
27
|
+
# and finish times rather than measured around it, which is exactly what
|
|
28
|
+
# `Tracer.record` exists for.
|
|
29
|
+
module ActiveRecord
|
|
30
|
+
# Statements Rails issues about itself rather than on the application's
|
|
31
|
+
# behalf. A schema load at boot is not an operation anyone can act on, and
|
|
32
|
+
# a cached query never reached the database at all — recording it would
|
|
33
|
+
# report database work that did not happen.
|
|
34
|
+
IGNORED_NAMES = ['SCHEMA', 'TRANSACTION', 'EXPLAIN'].freeze
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def install(config)
|
|
38
|
+
return false if @installed
|
|
39
|
+
return false unless defined?(::ActiveSupport::Notifications)
|
|
40
|
+
|
|
41
|
+
@config = config
|
|
42
|
+
@subscriber = ::ActiveSupport::Notifications.monotonic_subscribe('sql.active_record') do |_name, started, finished, _id, payload|
|
|
43
|
+
record(payload, (finished - started) * 1000.0)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Sixty.before_flush { capture_plans } if config.capture_plans
|
|
47
|
+
@installed = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def installed?
|
|
51
|
+
@installed == true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Which SQL dialect this application speaks.
|
|
55
|
+
#
|
|
56
|
+
# It decides whether `"alice@example.com"` is an identifier to keep or a
|
|
57
|
+
# string literal to strip, so guessing from the text is not an option —
|
|
58
|
+
# a wrong guess in one direction transmits a customer's data. Asked of
|
|
59
|
+
# the adapter, which knows, and cached because the answer cannot change
|
|
60
|
+
# for the life of a process.
|
|
61
|
+
#
|
|
62
|
+
# `connection_db_config` rather than a live connection: reading this
|
|
63
|
+
# must never be a reason to open one.
|
|
64
|
+
def dialect
|
|
65
|
+
return @dialect if defined?(@dialect) && @dialect
|
|
66
|
+
|
|
67
|
+
@dialect = begin
|
|
68
|
+
name = ::ActiveRecord::Base.connection_db_config.adapter.to_s
|
|
69
|
+
name.match?(/mysql|trilogy|maria/i) ? :mysql : :postgres
|
|
70
|
+
rescue StandardError
|
|
71
|
+
:postgres
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def record(payload, duration_ms)
|
|
78
|
+
return if Thread.current[:sixty_in_agent_query]
|
|
79
|
+
return if payload[:cached]
|
|
80
|
+
return if IGNORED_NAMES.include?(payload[:name].to_s)
|
|
81
|
+
|
|
82
|
+
sql = payload[:sql]
|
|
83
|
+
return unless sql.is_a?(String) && !sql.empty?
|
|
84
|
+
|
|
85
|
+
normalized = Sql.normalize_sql(sql, dialect)
|
|
86
|
+
attrs = { normalized_sql: normalized }
|
|
87
|
+
|
|
88
|
+
# `row_count` is what a modern Rails reports; on anything older the
|
|
89
|
+
# metric is simply absent rather than guessed at. A wrong row count is
|
|
90
|
+
# worse than no row count — it is the number the whole detector leans
|
|
91
|
+
# on.
|
|
92
|
+
attrs[:rows] = payload[:row_count] if payload[:row_count].is_a?(Numeric)
|
|
93
|
+
|
|
94
|
+
# Once per distinct statement, never again: the call site is a
|
|
95
|
+
# property of the query, not of the call.
|
|
96
|
+
frames = Stack.capture(normalized)
|
|
97
|
+
attrs[:frames] = frames if frames
|
|
98
|
+
if frames&.first
|
|
99
|
+
attrs[:file] = frames.first[:file]
|
|
100
|
+
attrs[:line] = frames.first[:line]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Tracer.record(
|
|
104
|
+
kind: Tracer::KIND_DB,
|
|
105
|
+
name: Sql.sql_operation_name(normalized),
|
|
106
|
+
duration_ms: duration_ms,
|
|
107
|
+
attrs: attrs
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
Plans.enqueue(sql, normalized, dialect: dialect) if @config&.capture_plans
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
@config&.on_warn&.call("sixty: error recording query: #{e.message}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Runs on the agent's flush thread, never on a request.
|
|
116
|
+
#
|
|
117
|
+
# `with_connection` checks a connection out of the pool and returns it,
|
|
118
|
+
# so this cannot leak one, and the thread-local flag keeps the EXPLAIN
|
|
119
|
+
# from being recorded as an operation of its own — a query that measures
|
|
120
|
+
# queries would count toward the fanout of nothing and appear in the
|
|
121
|
+
# feed as an operation the application does not contain.
|
|
122
|
+
def capture_plans
|
|
123
|
+
return unless defined?(::ActiveRecord::Base)
|
|
124
|
+
|
|
125
|
+
Thread.current[:sixty_in_agent_query] = true
|
|
126
|
+
::ActiveRecord::Base.connection_pool.with_connection do |connection|
|
|
127
|
+
Plans.capture_pending(lambda { |sql|
|
|
128
|
+
result = connection.exec_query(sql)
|
|
129
|
+
row = result.rows.first
|
|
130
|
+
row && row.first
|
|
131
|
+
})
|
|
132
|
+
end
|
|
133
|
+
ensure
|
|
134
|
+
Thread.current[:sixty_in_agent_query] = false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|