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.
@@ -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