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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tracer'
|
|
4
|
+
require_relative '../sql'
|
|
5
|
+
require_relative '../stack'
|
|
6
|
+
|
|
7
|
+
module Sixty
|
|
8
|
+
module Instrument
|
|
9
|
+
# The `pg` gem, patched directly.
|
|
10
|
+
#
|
|
11
|
+
# ── Who this is for ───────────────────────────────────────────────────────
|
|
12
|
+
#
|
|
13
|
+
# A Rails application is covered by `sql.active_record`, which is a public
|
|
14
|
+
# interface every adapter emits and every version has — strictly better than
|
|
15
|
+
# patching, so when ActiveRecord is in the process this is not installed at
|
|
16
|
+
# all. What it covers is everything else: Sinatra, Roda, a Sidekiq worker
|
|
17
|
+
# that talks to Postgres directly, Sequel and ROM (both of which run their
|
|
18
|
+
# queries through this class), and scripts.
|
|
19
|
+
#
|
|
20
|
+
# ── What is captured beyond timing ───────────────────────────────────────
|
|
21
|
+
#
|
|
22
|
+
# rows : `ntuples` for a read, `cmd_tuples` for a write. The 30 → 30,000
|
|
23
|
+
# signal, and the one thing a slow-query log cannot show you.
|
|
24
|
+
# fields : column count, which is how `select *` creeping into a hot path
|
|
25
|
+
# becomes visible.
|
|
26
|
+
# bytes : a sampled estimate of the result's size.
|
|
27
|
+
#
|
|
28
|
+
# Never the SQL text, and never a parameter: only the normalized shape.
|
|
29
|
+
#
|
|
30
|
+
# ── No query plans on this path ──────────────────────────────────────────
|
|
31
|
+
#
|
|
32
|
+
# The ActiveRecord instrumentation captures plans because it can borrow a
|
|
33
|
+
# connection from a pool that is designed to be borrowed from. Here there is
|
|
34
|
+
# no pool — only the caller's own `PG::Connection`, which is not thread-safe
|
|
35
|
+
# and may be mid-query on another thread when the flush thread wakes up. The
|
|
36
|
+
# alternatives are worse: running the EXPLAIN inline puts a second round trip
|
|
37
|
+
# in front of a user, and opening a connection of our own means an
|
|
38
|
+
# observability agent quietly consuming a slot in somebody's connection
|
|
39
|
+
# limit. So plans are a Rails feature for now, and this path reports
|
|
40
|
+
# everything else.
|
|
41
|
+
module Pg
|
|
42
|
+
# `exec`, `query` and `async_exec` are the same method under three names in
|
|
43
|
+
# modern pg, and `super` resolves by name — so a call to `exec` reaches the
|
|
44
|
+
# original `exec` rather than this module's `async_exec`, and nothing is
|
|
45
|
+
# counted twice. `sync_*` are separate implementations and need their own
|
|
46
|
+
# wrapper.
|
|
47
|
+
TEXT_FIRST = %i[exec query async_exec sync_exec exec_params async_exec_params
|
|
48
|
+
sync_exec_params].freeze
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def install(config = nil)
|
|
52
|
+
return false if @installed
|
|
53
|
+
return false unless defined?(::PG::Connection)
|
|
54
|
+
|
|
55
|
+
@config = config
|
|
56
|
+
::PG::Connection.prepend(Patch)
|
|
57
|
+
@installed = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def installed?
|
|
61
|
+
@installed == true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
attr_reader :config
|
|
65
|
+
|
|
66
|
+
# Prepared statements name a query somewhere other than the call site.
|
|
67
|
+
# `exec_prepared('find_user', [id])` has no SQL in it at all, so the text
|
|
68
|
+
# is remembered when the statement is prepared and looked up when it is
|
|
69
|
+
# run. Bounded, because a process that has prepared a thousand distinct
|
|
70
|
+
# statements is not going to learn much from the next one.
|
|
71
|
+
MAX_PREPARED = 500
|
|
72
|
+
|
|
73
|
+
def remember_prepared(name, sql)
|
|
74
|
+
return unless name.is_a?(String) && sql.is_a?(String)
|
|
75
|
+
|
|
76
|
+
@prepared ||= {}
|
|
77
|
+
@prepared.clear if @prepared.size >= MAX_PREPARED
|
|
78
|
+
@prepared[name] = sql
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prepared_sql(name)
|
|
82
|
+
@prepared&.[](name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reset!
|
|
86
|
+
@installed = false
|
|
87
|
+
@prepared = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One span for one statement. Shared by every wrapper below so that the
|
|
91
|
+
# rules — never raise, always emit, capture frames once — live in one
|
|
92
|
+
# place rather than in seven.
|
|
93
|
+
def measure(sql)
|
|
94
|
+
return yield unless Sixty.enabled? && sql.is_a?(String) && !sql.empty?
|
|
95
|
+
|
|
96
|
+
normalized = Sql.normalize_sql(sql)
|
|
97
|
+
started = Tracer.monotonic_ms
|
|
98
|
+
begin
|
|
99
|
+
result = yield
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
record(sql, normalized, Tracer.monotonic_ms - started, nil, e)
|
|
102
|
+
raise
|
|
103
|
+
end
|
|
104
|
+
record(sql, normalized, Tracer.monotonic_ms - started, result, nil)
|
|
105
|
+
result
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def record(sql, normalized, duration_ms, result, error)
|
|
111
|
+
attrs = { normalized_sql: normalized }
|
|
112
|
+
frames = Stack.capture(normalized)
|
|
113
|
+
if frames
|
|
114
|
+
attrs[:frames] = frames
|
|
115
|
+
attrs[:file] = frames.first[:file]
|
|
116
|
+
attrs[:line] = frames.first[:line]
|
|
117
|
+
end
|
|
118
|
+
measure_result(result, attrs) if result
|
|
119
|
+
|
|
120
|
+
Tracer.record(
|
|
121
|
+
kind: Tracer::KIND_DB,
|
|
122
|
+
name: Sql.sql_operation_name(normalized),
|
|
123
|
+
duration_ms: duration_ms,
|
|
124
|
+
attrs: attrs,
|
|
125
|
+
error: error
|
|
126
|
+
)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
# An unmeasured query, not a failed one.
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def measure_result(result, attrs)
|
|
133
|
+
return unless result.respond_to?(:ntuples)
|
|
134
|
+
|
|
135
|
+
returned = result.ntuples
|
|
136
|
+
# A write returns no tuples and reports what it changed instead. Both
|
|
137
|
+
# are "rows this statement was responsible for", which is what the
|
|
138
|
+
# signal means everywhere else in this agent.
|
|
139
|
+
attrs[:rows] = returned.positive? ? returned : result.cmd_tuples.to_i
|
|
140
|
+
attrs[:fields] = result.nfields if result.respond_to?(:nfields)
|
|
141
|
+
attrs[:bytes] = estimate_bytes(result) if returned.positive?
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Sampled and extrapolated: a full measurement of every result would
|
|
147
|
+
# itself be the performance problem this agent exists to find.
|
|
148
|
+
#
|
|
149
|
+
# `getlength` asks libpq for the size of a value in bytes without
|
|
150
|
+
# building anything. The obvious spelling — `result[i].to_s.bytesize` —
|
|
151
|
+
# materialises a Ruby hash per row and renders it to a string, which
|
|
152
|
+
# measured at six microseconds a query on a two-column table and would
|
|
153
|
+
# grow with every column. This is the same number, without the garbage.
|
|
154
|
+
def estimate_bytes(result)
|
|
155
|
+
rows = result.ntuples
|
|
156
|
+
fields = result.nfields
|
|
157
|
+
sample = [rows, 3].min
|
|
158
|
+
return 0 if sample.zero? || fields.zero?
|
|
159
|
+
|
|
160
|
+
total = 0
|
|
161
|
+
sample.times { |i| fields.times { |j| total += result.getlength(i, j) } }
|
|
162
|
+
((total.to_f / sample) * rows).round
|
|
163
|
+
rescue StandardError
|
|
164
|
+
0
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Prepended rather than aliased, so `super` reaches the driver's own
|
|
169
|
+
# implementation and an application that removes the gem's instrumentation
|
|
170
|
+
# gets its untouched methods back.
|
|
171
|
+
module Patch
|
|
172
|
+
Sixty::Instrument::Pg::TEXT_FIRST.each do |method|
|
|
173
|
+
define_method(method) do |*args, &block|
|
|
174
|
+
sql = args.first
|
|
175
|
+
sql = sql[:text] || sql['text'] if sql.is_a?(Hash)
|
|
176
|
+
Sixty::Instrument::Pg.measure(sql) { super(*args, &block) }
|
|
177
|
+
end
|
|
178
|
+
ruby2_keywords method
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def prepare(*args, &block)
|
|
182
|
+
Sixty::Instrument::Pg.remember_prepared(args[0], args[1])
|
|
183
|
+
super
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def exec_prepared(*args, &block)
|
|
187
|
+
sql = Sixty::Instrument::Pg.prepared_sql(args[0])
|
|
188
|
+
# A statement prepared before the agent started has no text here. It is
|
|
189
|
+
# measured under its own name rather than dropped, because a query
|
|
190
|
+
# nobody can see is worse than one labelled by its statement name.
|
|
191
|
+
Sixty::Instrument::Pg.measure(sql || "prepared #{args[0]}") { super }
|
|
192
|
+
end
|
|
193
|
+
ruby2_keywords :exec_prepared
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tracer'
|
|
4
|
+
require_relative '../sql'
|
|
5
|
+
|
|
6
|
+
module Sixty
|
|
7
|
+
module Instrument
|
|
8
|
+
# Inbound HTTP.
|
|
9
|
+
#
|
|
10
|
+
# This is the root span every other span in a request hangs off, and it is
|
|
11
|
+
# what makes "which endpoint got slower" answerable at all. It is Rack
|
|
12
|
+
# middleware rather than a Rails hook so the same class covers Sinatra,
|
|
13
|
+
# Roda, Hanami and a bare Rack app — the Node agent patches `http.Server`
|
|
14
|
+
# for the same reason.
|
|
15
|
+
#
|
|
16
|
+
# ── Naming, and why the route matters so much ─────────────────────────────
|
|
17
|
+
#
|
|
18
|
+
# Middleware runs before routing, so at span start all we have is the path.
|
|
19
|
+
# `/users/42` and `/users/43` are different paths and the same endpoint, and
|
|
20
|
+
# recording them separately would mint an operation per user id — the
|
|
21
|
+
# cardinality cap would be hit within minutes and every one of those
|
|
22
|
+
# operations would have too little traffic to compare against anything.
|
|
23
|
+
#
|
|
24
|
+
# So the path is templated on the way in, and on the way out the real Rails
|
|
25
|
+
# route pattern replaces it if the router recorded one. The template is the
|
|
26
|
+
# fallback; the pattern is the truth.
|
|
27
|
+
class Rack
|
|
28
|
+
def initialize(app, config: nil)
|
|
29
|
+
@app = app
|
|
30
|
+
@config = config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ── The rule this method is written to ────────────────────────────────
|
|
34
|
+
#
|
|
35
|
+
# Nothing the agent does may change what the application returns, and
|
|
36
|
+
# nothing the agent gets wrong may stop it returning at all. So every
|
|
37
|
+
# line that belongs to us is inside a rescue, and the one line that
|
|
38
|
+
# belongs to the application — `@app.call(env)` — is not wrapped in
|
|
39
|
+
# anything that could swallow or alter it. A middleware that 500s a
|
|
40
|
+
# request because a measurement failed has done more damage than every
|
|
41
|
+
# finding it could ever produce.
|
|
42
|
+
def call(env)
|
|
43
|
+
span = begin
|
|
44
|
+
start(env)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return @app.call(env) unless span
|
|
50
|
+
|
|
51
|
+
previous = Tracer.current
|
|
52
|
+
Tracer.current = span
|
|
53
|
+
begin
|
|
54
|
+
status, headers, body = @app.call(env)
|
|
55
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
56
|
+
Tracer.current = previous
|
|
57
|
+
safely { span.attrs[:status] = 500 }
|
|
58
|
+
safely { finish(span, env, e) }
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Tracer.current = previous
|
|
63
|
+
safely do
|
|
64
|
+
span.attrs[:status] = status
|
|
65
|
+
length = headers && (headers['content-length'] || headers['Content-Length'])
|
|
66
|
+
span.attrs[:bytes] = length.to_i if length
|
|
67
|
+
# A 5xx is an error whether or not anything was raised: the exception
|
|
68
|
+
# may have been rescued into a rendered error page three layers down,
|
|
69
|
+
# and from the outside those are the same failure.
|
|
70
|
+
finish(span, env, status.to_i >= 500 ? StandardError.new("HTTP #{status}") : nil)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
[status, headers, body]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Read lazily rather than captured at construction: Rails builds its
|
|
79
|
+
# middleware stack while the initializer that calls `Sixty.init` may not
|
|
80
|
+
# have run yet, and a middleware holding a nil config would silently
|
|
81
|
+
# ignore nothing and sample at zero for the life of the process.
|
|
82
|
+
def config
|
|
83
|
+
@config || Sixty.config
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Tracer::Span, nil] nil when this request is not ours to measure
|
|
87
|
+
def start(env)
|
|
88
|
+
return nil unless Sixty.enabled?
|
|
89
|
+
|
|
90
|
+
path = env['PATH_INFO'].to_s
|
|
91
|
+
return nil if config&.ignore?(path)
|
|
92
|
+
|
|
93
|
+
method = env['REQUEST_METHOD'].to_s
|
|
94
|
+
span = Tracer.start_span(
|
|
95
|
+
kind: Tracer::KIND_HTTP,
|
|
96
|
+
name: "#{method} #{Sql.template_path(path)}",
|
|
97
|
+
attrs: { method: method }
|
|
98
|
+
)
|
|
99
|
+
# Head sampling decides whether an *uneventful* trace is retained.
|
|
100
|
+
# Errors and slow outliers are kept regardless, decided at finish time.
|
|
101
|
+
span.recording = rand < config&.sample_rate.to_f
|
|
102
|
+
span
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def safely
|
|
106
|
+
yield
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Sixty.config&.on_warn&.call("sixty: middleware error: #{e.message}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finish(span, env, error)
|
|
112
|
+
route = route_pattern(env)
|
|
113
|
+
span.name = "#{span.attrs[:method]} #{route}" if route
|
|
114
|
+
Tracer.end_span(span, error)
|
|
115
|
+
Tracer.emit(span)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# What the router matched, in three descending preferences. All are read
|
|
119
|
+
# after the app has run, because none of them exists before routing.
|
|
120
|
+
#
|
|
121
|
+
# 1. `action_dispatch.route_uri_pattern` — the pattern as a string, if
|
|
122
|
+
# anything in the request already asked for it.
|
|
123
|
+
# 2. `action_dispatch.route` — the matched route itself. This is the one
|
|
124
|
+
# that is actually present on a plain request: Rails fills the header
|
|
125
|
+
# above lazily, the first time somebody calls `route_uri_pattern`,
|
|
126
|
+
# and on most requests nobody does.
|
|
127
|
+
# 3. the recognized parameters, which give `orders#index` rather than a
|
|
128
|
+
# path. Less readable, still one operation per endpoint, and it is
|
|
129
|
+
# what a Rails older than 7.1 has.
|
|
130
|
+
def route_pattern(env)
|
|
131
|
+
pattern = env['action_dispatch.route_uri_pattern']
|
|
132
|
+
return normalize_pattern(pattern) if pattern.is_a?(String) && !pattern.empty?
|
|
133
|
+
|
|
134
|
+
route = env['action_dispatch.route']
|
|
135
|
+
if route
|
|
136
|
+
spec = begin
|
|
137
|
+
route.path.spec.to_s
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
return normalize_pattern(spec) if spec.is_a?(String) && !spec.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
params = env['action_dispatch.request.path_parameters']
|
|
145
|
+
return nil unless params.is_a?(Hash) && params[:controller]
|
|
146
|
+
|
|
147
|
+
"#{params[:controller]}##{params[:action]}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# `/users/:id(.:format)` is the same endpoint as `/users/:id`, and the
|
|
151
|
+
# optional format segment appears on every Rails route — carrying it would
|
|
152
|
+
# add noise to every operation name in the feed.
|
|
153
|
+
def normalize_pattern(pattern)
|
|
154
|
+
pattern.sub(/\(\.:format\)\z/, '')
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tracer'
|
|
4
|
+
require_relative 'stack'
|
|
5
|
+
|
|
6
|
+
module Sixty
|
|
7
|
+
# Your own code, measured.
|
|
8
|
+
#
|
|
9
|
+
# class OrdersQuery
|
|
10
|
+
# include Sixty::Instrumented
|
|
11
|
+
#
|
|
12
|
+
# def for_user(id) = Order.where(user_id: id).limit(30).to_a
|
|
13
|
+
# def enrich(orders) = ...
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Every public instance method defined after the include becomes an operation:
|
|
17
|
+
# its own latency, its self time, and — the number that matters — how many
|
|
18
|
+
# queries and how many rows it is responsible for, including everything it
|
|
19
|
+
# calls.
|
|
20
|
+
#
|
|
21
|
+
# ── Why this is opt-in ────────────────────────────────────────────────────
|
|
22
|
+
#
|
|
23
|
+
# The Node agent wraps functions with a build transform, which can afford to
|
|
24
|
+
# instrument everything because it happens once at build time. Ruby has no
|
|
25
|
+
# build step: the equivalent would be walking ObjectSpace at boot and
|
|
26
|
+
# prepending a module to every class in `app/`, which is both slow and a
|
|
27
|
+
# decision nobody asked us to make on their behalf — including on classes
|
|
28
|
+
# whose methods are called in a tight loop, where a span per call is real
|
|
29
|
+
# overhead for no signal.
|
|
30
|
+
#
|
|
31
|
+
# So this is a line the application writes on the classes worth measuring:
|
|
32
|
+
# services, query objects, jobs. Controllers and queries are covered
|
|
33
|
+
# automatically by the railtie, which is enough for the feed to be useful
|
|
34
|
+
# before anybody adds a single include.
|
|
35
|
+
#
|
|
36
|
+
# ── What it costs when the agent is off ───────────────────────────────────
|
|
37
|
+
#
|
|
38
|
+
# One method call. `Sixty.trace` checks a boolean and yields, so an
|
|
39
|
+
# uninitialized agent — every test suite, every rake task, every developer's
|
|
40
|
+
# console — adds a frame and nothing else.
|
|
41
|
+
module Instrumented
|
|
42
|
+
def self.included(base)
|
|
43
|
+
base.extend(ClassMethods)
|
|
44
|
+
base.sixty_install_wrapper!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module ClassMethods
|
|
48
|
+
def sixty_install_wrapper!
|
|
49
|
+
Sixty::Instrumented.wrapper_for(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def method_added(name)
|
|
53
|
+
super
|
|
54
|
+
Sixty::Instrumented.wrap(self, name) unless name.to_s.start_with?('sixty_')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── Why visibility has to be followed, not just read ──────────────────
|
|
58
|
+
#
|
|
59
|
+
# `def helper; end` followed by `private :helper` is the common spelling,
|
|
60
|
+
# and at `method_added` time the method is still public — so it gets
|
|
61
|
+
# wrapped. The wrapper lives in a prepended module, where `private` on the
|
|
62
|
+
# class does not reach it, and the result is a method the author made
|
|
63
|
+
# private that this gem quietly made public again. That is not a
|
|
64
|
+
# measurement problem; it is a change to the application's API.
|
|
65
|
+
#
|
|
66
|
+
# So the declarations are followed. A method turned private loses its
|
|
67
|
+
# wrapper entirely rather than becoming a private wrapper: private methods
|
|
68
|
+
# are not instrumented when they are declared private up front, and the
|
|
69
|
+
# two spellings should not disagree.
|
|
70
|
+
def private(*names)
|
|
71
|
+
result = super
|
|
72
|
+
Sixty::Instrumented.unwrap(self, names.flatten)
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def protected(*names)
|
|
77
|
+
result = super
|
|
78
|
+
Sixty::Instrumented.revisibility(self, :protected, names.flatten)
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def public(*names)
|
|
83
|
+
result = super
|
|
84
|
+
Sixty::Instrumented.revisibility(self, :public, names.flatten)
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Opt a class method in too. Rarely what you want — a class method is
|
|
89
|
+
# usually a constructor or a lookup — but a `Service.call` entry point is
|
|
90
|
+
# common enough to be worth the two lines.
|
|
91
|
+
def sixty_trace_singleton(*names)
|
|
92
|
+
names.each { |name| Sixty::Instrumented.wrap_singleton(self, name) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
# The wrapping lives in a module prepended to the class rather than in an
|
|
98
|
+
# alias chain: `super` then reaches the original definition, subclasses
|
|
99
|
+
# and `prepend`ed concerns keep working, and removing the include removes
|
|
100
|
+
# the instrumentation completely.
|
|
101
|
+
def wrapper_for(klass)
|
|
102
|
+
if klass.instance_variable_defined?(:@sixty_wrapper)
|
|
103
|
+
klass.instance_variable_get(:@sixty_wrapper)
|
|
104
|
+
else
|
|
105
|
+
wrapper = Module.new
|
|
106
|
+
klass.instance_variable_set(:@sixty_wrapper, wrapper)
|
|
107
|
+
klass.prepend(wrapper)
|
|
108
|
+
wrapper
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Wrap one instance method of `klass`.
|
|
113
|
+
def wrap(klass, name)
|
|
114
|
+
return unless klass.public_method_defined?(name) || klass.protected_method_defined?(name)
|
|
115
|
+
# An accessor is not an operation. `attr_reader :total` reports a span
|
|
116
|
+
# per read of an instance variable — pure overhead, and it buries the
|
|
117
|
+
# methods that do something in a list of the ones that do not.
|
|
118
|
+
return if name.to_s.end_with?('=')
|
|
119
|
+
|
|
120
|
+
original = klass.instance_method(name)
|
|
121
|
+
return if original.source_location.nil?
|
|
122
|
+
|
|
123
|
+
operation = "#{klass.name || 'anonymous'}##{name}"
|
|
124
|
+
attrs = location_attrs(original)
|
|
125
|
+
wrapper = wrapper_for(klass)
|
|
126
|
+
return if wrapper.method_defined?(name)
|
|
127
|
+
|
|
128
|
+
wrapper.send(:define_method, name) do |*args, &block|
|
|
129
|
+
Sixty.trace(operation, attrs: attrs.dup) { super(*args, &block) }
|
|
130
|
+
end
|
|
131
|
+
# Keyword arguments survive the splat only if the wrapper is marked:
|
|
132
|
+
# without this, `save(validate: false)` arrives at the original method
|
|
133
|
+
# as a positional Hash on Ruby 3, which is a behaviour change an
|
|
134
|
+
# observability gem has no business introducing.
|
|
135
|
+
wrapper.send(:ruby2_keywords, name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Drop the wrapper for methods that turned out to be private.
|
|
139
|
+
def unwrap(klass, names)
|
|
140
|
+
wrapper = existing_wrapper(klass)
|
|
141
|
+
return unless wrapper
|
|
142
|
+
|
|
143
|
+
names.each do |name|
|
|
144
|
+
wrapper.send(:remove_method, name) if wrapper.method_defined?(name)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def revisibility(klass, visibility, names)
|
|
149
|
+
wrapper = existing_wrapper(klass)
|
|
150
|
+
return unless wrapper
|
|
151
|
+
|
|
152
|
+
names.each do |name|
|
|
153
|
+
wrapper.send(visibility, name) if wrapper.method_defined?(name)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def wrap_singleton(klass, name)
|
|
158
|
+
return unless klass.singleton_class.method_defined?(name)
|
|
159
|
+
|
|
160
|
+
original = klass.singleton_class.instance_method(name)
|
|
161
|
+
operation = "#{klass.name || 'anonymous'}.#{name}"
|
|
162
|
+
attrs = location_attrs(original)
|
|
163
|
+
wrapper = Module.new do
|
|
164
|
+
define_method(name) do |*args, &block|
|
|
165
|
+
Sixty.trace(operation, attrs: attrs.dup) { super(*args, &block) }
|
|
166
|
+
end
|
|
167
|
+
ruby2_keywords name
|
|
168
|
+
end
|
|
169
|
+
klass.singleton_class.prepend(wrapper)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def existing_wrapper(klass)
|
|
175
|
+
klass.instance_variable_defined?(:@sixty_wrapper) &&
|
|
176
|
+
klass.instance_variable_get(:@sixty_wrapper)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The file and line the method is written at, captured once at definition
|
|
180
|
+
# time. This is what turns a finding into a link into the repository, and
|
|
181
|
+
# it is free here — `source_location` is metadata Ruby already holds.
|
|
182
|
+
def location_attrs(method)
|
|
183
|
+
file, line = method.source_location
|
|
184
|
+
return {} unless file
|
|
185
|
+
|
|
186
|
+
{ file: Stack.relativise(file), line: line }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class << self
|
|
192
|
+
# Instrument methods on a class you do not own — a gem's client, a model
|
|
193
|
+
# generated elsewhere, anything you cannot add an `include` to.
|
|
194
|
+
#
|
|
195
|
+
# Sixty.instrument(Stripe::Charge, :create)
|
|
196
|
+
# Only the named methods are wrapped, and no `method_added` hook is
|
|
197
|
+
# installed: a class you do not own may define methods long after this call
|
|
198
|
+
# — lazily, or through a gem's own metaprogramming — and silently
|
|
199
|
+
# instrumenting those is not something a one-line call should decide.
|
|
200
|
+
def instrument(klass, *names)
|
|
201
|
+
names.each { |name| Instrumented.wrap(klass, name) }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|