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/plans.rb
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative 'sql'
|
|
6
|
+
|
|
7
|
+
module Sixty
|
|
8
|
+
# A query plan, reduced to its shape.
|
|
9
|
+
#
|
|
10
|
+
# ── Why this is the signal worth having ───────────────────────────────────
|
|
11
|
+
#
|
|
12
|
+
# Everything else this agent measures is a symptom: rows went up, latency went
|
|
13
|
+
# up, a method makes more queries than it did. A plan change is the *cause* —
|
|
14
|
+
# the moment a query stops using an index is the moment it becomes the
|
|
15
|
+
# incident, and every other number only reflects it afterwards, usually days
|
|
16
|
+
# afterwards, once the table is large enough for the difference to show.
|
|
17
|
+
#
|
|
18
|
+
# It is also the finding a person can act on without understanding any of
|
|
19
|
+
# this. "This got slower" invites a shrug; "this stopped using
|
|
20
|
+
# index_orders_on_user_id" names the fix.
|
|
21
|
+
#
|
|
22
|
+
# ── The rules that make this safe to run in production ────────────────────
|
|
23
|
+
#
|
|
24
|
+
# 1. EXPLAIN, never EXPLAIN ANALYZE. ANALYZE *executes* the statement to
|
|
25
|
+
# measure it — doubling the load of every explained query and, for anything
|
|
26
|
+
# that writes, performing the write a second time.
|
|
27
|
+
# 2. GENERIC_PLAN, so no parameter is ever bound. Postgres 16 added it exactly
|
|
28
|
+
# for this. It is what keeps the privacy boundary intact by construction
|
|
29
|
+
# rather than by a promise to strip values afterwards.
|
|
30
|
+
# 3. Read-only statements only, refused before they reach the database.
|
|
31
|
+
# 4. Off the request path entirely. The Node agent issues its EXPLAIN after
|
|
32
|
+
# the caller's promise has settled; Ruby's notification subscriber runs
|
|
33
|
+
# *inside* the caller's stack with the connection still checked out, so
|
|
34
|
+
# doing the same here would put a second round trip in front of a user.
|
|
35
|
+
# Instead the statement is queued and the flush thread explains it later on
|
|
36
|
+
# a connection of its own. The plan lands one window late, which is nothing
|
|
37
|
+
# — a plan is a property of the statement and the schema, not of the call.
|
|
38
|
+
module Plans
|
|
39
|
+
MAX_PLANS = 500
|
|
40
|
+
MAX_PENDING = 100
|
|
41
|
+
|
|
42
|
+
# Only statements that read. Anything else is refused here rather than
|
|
43
|
+
# relying on GENERIC_PLAN being side-effect free.
|
|
44
|
+
READ_ONLY = /\A\s*(select|with)\b/i.freeze
|
|
45
|
+
# A statement already carrying its own EXPLAIN, or several statements at
|
|
46
|
+
# once, is not something to wrap in another EXPLAIN.
|
|
47
|
+
UNSAFE = /\A\s*explain\b|;\s*\S/i.freeze
|
|
48
|
+
|
|
49
|
+
STRUCTURAL = [
|
|
50
|
+
'Node Type', 'Join Type', 'Strategy', 'Relation Name',
|
|
51
|
+
'Index Name', 'Scan Direction', 'Parent Relationship'
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
MAX_DEPTH = 12
|
|
55
|
+
MAX_NODES = 120
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
def mutex
|
|
59
|
+
@mutex ||= Mutex.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def plans
|
|
63
|
+
@plans ||= {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def pending
|
|
67
|
+
@pending ||= {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get(key)
|
|
71
|
+
mutex.synchronize { plans[key] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def set(key, plan)
|
|
75
|
+
mutex.synchronize do
|
|
76
|
+
# Bounded like every other per-operation cache here. A process that
|
|
77
|
+
# has seen five hundred distinct statements will not learn much from
|
|
78
|
+
# the next one.
|
|
79
|
+
return if plans.size >= MAX_PLANS || plans.key?(key)
|
|
80
|
+
|
|
81
|
+
plans[key] = plan
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remember a statement to explain on the next flush.
|
|
86
|
+
#
|
|
87
|
+
# `attempted` is marked at enqueue rather than at success, so a statement
|
|
88
|
+
# the planner refuses — an older Postgres with no GENERIC_PLAN, an
|
|
89
|
+
# untypable parameter, a temp table that no longer exists — is tried once
|
|
90
|
+
# and then left alone instead of retried on every execution forever.
|
|
91
|
+
#
|
|
92
|
+
# ── Two refusals, both about values ──────────────────────────────────
|
|
93
|
+
#
|
|
94
|
+
# **MySQL gets no plans at all.** It has no equivalent of GENERIC_PLAN: a
|
|
95
|
+
# statement can only be explained with its parameters bound, so capturing
|
|
96
|
+
# a plan would mean *retaining* somebody's query values in order to
|
|
97
|
+
# compose a command out of them. The signal is worth a great deal — an
|
|
98
|
+
# index that stopped being used names a cause rather than a symptom — and
|
|
99
|
+
# it is still not worth holding customer data to get. `@sixty-sh/node`
|
|
100
|
+
# refuses this for the same reason, and MongoDB is refused a third time on
|
|
101
|
+
# the same grounds.
|
|
102
|
+
#
|
|
103
|
+
# **A statement that arrived with literals in it is refused too**, even on
|
|
104
|
+
# Postgres. `where id = $1` carries no data and is safe to hand back;
|
|
105
|
+
# `where id = 42` is what an application with prepared statements turned
|
|
106
|
+
# off produces, and explaining it would mean keeping the values in memory
|
|
107
|
+
# until the next flush. The lexer decides which is which, because the
|
|
108
|
+
# question is exactly "would normalization have removed anything".
|
|
109
|
+
def enqueue(text, key, dialect: :postgres)
|
|
110
|
+
return unless dialect == :postgres
|
|
111
|
+
return unless text.is_a?(String) && READ_ONLY.match?(text) && !UNSAFE.match?(text)
|
|
112
|
+
return unless Sql.value_free?(text)
|
|
113
|
+
|
|
114
|
+
mutex.synchronize do
|
|
115
|
+
return if attempted.key?(key) || pending.size >= MAX_PENDING || plans.size >= MAX_PLANS
|
|
116
|
+
|
|
117
|
+
attempted[key] = true
|
|
118
|
+
pending[key] = text
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Drain the queue, explaining each statement with the caller's runner.
|
|
123
|
+
#
|
|
124
|
+
# @param runner [#call] takes a SQL string, returns the parsed `QUERY PLAN`
|
|
125
|
+
# value (an Array or Hash), or raises. Supplied by the ActiveRecord
|
|
126
|
+
# instrumentation so this file never has to know what a connection is.
|
|
127
|
+
def capture_pending(runner)
|
|
128
|
+
queued = mutex.synchronize do
|
|
129
|
+
taken = pending
|
|
130
|
+
@pending = {}
|
|
131
|
+
taken
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
queued.each do |key, text|
|
|
135
|
+
explained = runner.call("explain (generic_plan, format json) #{text}")
|
|
136
|
+
shaped = shape(explained)
|
|
137
|
+
next unless shaped
|
|
138
|
+
|
|
139
|
+
set(key, shaped.merge(key: plan_key(shaped[:shape])))
|
|
140
|
+
rescue StandardError
|
|
141
|
+
# Swallowed, deliberately and completely. None of the ordinary reasons
|
|
142
|
+
# this fails is the application's problem, and an observability agent
|
|
143
|
+
# that turns a planning quirk into a runtime error has done far more
|
|
144
|
+
# harm than the signal is worth.
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Reduce an EXPLAIN (FORMAT JSON) result to a stable, comparable shape.
|
|
150
|
+
#
|
|
151
|
+
# A plan as Postgres emits it is mostly numbers — startup cost, total
|
|
152
|
+
# cost, row estimate, width, loops — and every one of those moves whenever
|
|
153
|
+
# the statistics move, which is after every autovacuum. A detector
|
|
154
|
+
# comparing plans literally would fire constantly and mean nothing. What
|
|
155
|
+
# matters is structural and changes rarely: this query used to use an
|
|
156
|
+
# index and now scans the table.
|
|
157
|
+
def shape(explain)
|
|
158
|
+
explain = JSON.parse(explain) if explain.is_a?(String)
|
|
159
|
+
root = explain.is_a?(Array) ? explain.dig(0, 'Plan') : explain&.dig('Plan')
|
|
160
|
+
return nil unless root
|
|
161
|
+
|
|
162
|
+
nodes = 0
|
|
163
|
+
scans = []
|
|
164
|
+
|
|
165
|
+
walk = lambda do |node, depth|
|
|
166
|
+
return nil if node.nil? || depth > MAX_DEPTH || nodes >= MAX_NODES
|
|
167
|
+
|
|
168
|
+
nodes += 1
|
|
169
|
+
out = {}
|
|
170
|
+
STRUCTURAL.each { |field| out[field] = node[field] unless node[field].nil? }
|
|
171
|
+
|
|
172
|
+
if node['Node Type'].is_a?(String) && node['Node Type'].include?('Scan')
|
|
173
|
+
relation = node['Relation Name']
|
|
174
|
+
index = node['Index Name']
|
|
175
|
+
if relation
|
|
176
|
+
scans << (index ? "#{node['Node Type']} #{relation} using #{index}" : "#{node['Node Type']} #{relation}")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The presence of a filter is structural; its contents are not.
|
|
181
|
+
out['Filtered'] = true if node['Filter']
|
|
182
|
+
out['IndexCond'] = true if node['Index Cond']
|
|
183
|
+
out['HashCond'] = true if node['Hash Cond']
|
|
184
|
+
|
|
185
|
+
children = Array(node['Plans']).map { |child| walk.call(child, depth + 1) }.compact
|
|
186
|
+
out['Plans'] = children unless children.empty?
|
|
187
|
+
out
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
shaped = walk.call(root, 0)
|
|
191
|
+
return nil unless shaped
|
|
192
|
+
|
|
193
|
+
# Two lists, because they answer different questions. `scans` is every
|
|
194
|
+
# scan node, which is what a person reading a summary wants. `seqScans`
|
|
195
|
+
# is only the sequential ones — the reads with no index — which is what
|
|
196
|
+
# the detector reports as a defect.
|
|
197
|
+
{ shape: shaped, summary: summarize(shaped), scans: scans, seqScans: sequential_scans(shaped) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A stable identity for a shape, so "did the plan change" is one string
|
|
201
|
+
# comparison. Key order is normalised on the way in: Postgres emits fields
|
|
202
|
+
# consistently today, but a shape whose identity depended on that would
|
|
203
|
+
# report a change the first time a minor release reordered them.
|
|
204
|
+
def plan_key(shape)
|
|
205
|
+
JSON.generate(canonical(shape))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def sequential_scans(shape)
|
|
209
|
+
found = []
|
|
210
|
+
walk = lambda do |node|
|
|
211
|
+
found << node['Relation Name'] if node['Node Type'] == 'Seq Scan' && node['Relation Name']
|
|
212
|
+
Array(node['Plans']).each { |child| walk.call(child) }
|
|
213
|
+
end
|
|
214
|
+
walk.call(shape) if shape
|
|
215
|
+
found
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def reset!
|
|
219
|
+
mutex.synchronize do
|
|
220
|
+
@plans = {}
|
|
221
|
+
@pending = {}
|
|
222
|
+
@attempted = {}
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def attempted
|
|
229
|
+
@attempted ||= {}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A one-line description, for a feed card with no room for a tree. It
|
|
233
|
+
# reads outside-in — the top node is what the query *is*, the scans are
|
|
234
|
+
# what it costs — because "Aggregate over Seq Scan on orders" is the
|
|
235
|
+
# sentence a person would say out loud.
|
|
236
|
+
def summarize(shape)
|
|
237
|
+
top = shape['Node Type'] || 'Plan'
|
|
238
|
+
scans = []
|
|
239
|
+
collect = lambda do |node|
|
|
240
|
+
if node['Node Type'].is_a?(String) && node['Node Type'].include?('Scan') && node['Relation Name']
|
|
241
|
+
scans << "#{node['Node Type'].sub(' Scan', '')} on #{node['Relation Name']}"
|
|
242
|
+
end
|
|
243
|
+
Array(node['Plans']).each { |child| collect.call(child) }
|
|
244
|
+
end
|
|
245
|
+
collect.call(shape)
|
|
246
|
+
scans.empty? ? top : "#{top} over #{scans.uniq.join(', ')}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def canonical(node)
|
|
250
|
+
case node
|
|
251
|
+
when Array then node.map { |n| canonical(n) }
|
|
252
|
+
when Hash then node.keys.sort.each_with_object({}) { |k, out| out[k] = canonical(node[k]) }
|
|
253
|
+
else node
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
require_relative 'instrument/rack'
|
|
5
|
+
require_relative 'instrument/active_record'
|
|
6
|
+
require_relative 'instrument/action_controller'
|
|
7
|
+
|
|
8
|
+
module Sixty
|
|
9
|
+
# Rails wiring.
|
|
10
|
+
#
|
|
11
|
+
# The whole install is `gem 'sixty'` plus a key in the environment. Everything
|
|
12
|
+
# below happens on boot, in the order the framework allows it: the middleware
|
|
13
|
+
# goes on the stack, the query subscriber attaches once ActiveRecord is
|
|
14
|
+
# loaded, and controllers are wrapped through `on_load` so this file never
|
|
15
|
+
# forces a constant into existence that the application had not asked for.
|
|
16
|
+
#
|
|
17
|
+
# ── Where the middleware sits, and why it matters ─────────────────────────
|
|
18
|
+
#
|
|
19
|
+
# Second from the top, after `ActionDispatch::RequestId`, so the span covers
|
|
20
|
+
# essentially the whole request — including the time other middleware spends,
|
|
21
|
+
# which is where a surprising amount of a slow request lives (a session store
|
|
22
|
+
# doing a database read, a rate limiter calling Redis). Inserting it lower
|
|
23
|
+
# would make every one of those invisible and quietly attribute their time to
|
|
24
|
+
# the controller.
|
|
25
|
+
class Railtie < ::Rails::Railtie
|
|
26
|
+
config.sixty = ActiveSupport::OrderedOptions.new
|
|
27
|
+
|
|
28
|
+
initializer 'sixty.init', before: :load_config_initializers do |app|
|
|
29
|
+
options = app.config.sixty.to_h
|
|
30
|
+
state = Sixty.init(options)
|
|
31
|
+
|
|
32
|
+
if state
|
|
33
|
+
app.middleware.insert_after(::ActionDispatch::RequestId, Sixty::Instrument::Rack)
|
|
34
|
+
Sixty::Instrument::ActionController.install
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ActiveRecord is subscribed to on `:active_record` load rather than in the
|
|
39
|
+
# initializer above, because in a Rails app that has not touched a model yet
|
|
40
|
+
# ActiveRecord::Base is not loaded — and referencing it to attach a
|
|
41
|
+
# subscriber would eagerly load the ORM (and open a connection) in processes
|
|
42
|
+
# that never needed one.
|
|
43
|
+
initializer 'sixty.active_record' do
|
|
44
|
+
ActiveSupport.on_load(:active_record) do
|
|
45
|
+
Sixty::Instrument::ActiveRecord.install(Sixty.config) if Sixty.enabled?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/sixty/shape.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sixty
|
|
4
|
+
# Argument shape: what a query *is*, with none of what it is about.
|
|
5
|
+
#
|
|
6
|
+
# SECURITY BOUNDARY, and the document-database counterpart to Sixty::Sql.
|
|
7
|
+
#
|
|
8
|
+
# For SQL, identity is the statement with its literals stripped — a string
|
|
9
|
+
# with things taken out of it. That approach has no analogue here. A Mongo
|
|
10
|
+
# filter is a tree in which the values sit *beside* the keys, arbitrarily
|
|
11
|
+
# deep, so there is no text to lex and nothing to remove: the values are
|
|
12
|
+
# structurally interleaved with the only part we are allowed to keep.
|
|
13
|
+
#
|
|
14
|
+
# So this walk never removes anything. It emits keys, and a value has no path
|
|
15
|
+
# to the output at all — not a redaction that could miss a case, but a
|
|
16
|
+
# construction in which the unsafe half is unreachable. If you are tempted to
|
|
17
|
+
# add "just the value, when it is a small integer, for readability": don't.
|
|
18
|
+
# That is the change that turns a structural guarantee back into a filter with
|
|
19
|
+
# exceptions.
|
|
20
|
+
#
|
|
21
|
+
# The other job is bounding cardinality, exactly as SQL normalization does.
|
|
22
|
+
# Identity that moved with the data would make every query a new operation,
|
|
23
|
+
# and an operation with no history has nothing to drift against.
|
|
24
|
+
module Shape
|
|
25
|
+
# Bounds on the walk, so a pathological argument cannot produce an unbounded
|
|
26
|
+
# identity. Both sit far above anything a real query reaches, and the depth
|
|
27
|
+
# cap is also what makes a cyclic structure terminate.
|
|
28
|
+
MAX_DEPTH = 6
|
|
29
|
+
MAX_KEYS = 24
|
|
30
|
+
|
|
31
|
+
# How many stages of an ordered structure — an aggregation pipeline — count.
|
|
32
|
+
MAX_STAGES = 12
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# The shape of one argument tree: `filter{createdAt{$gte},status}`.
|
|
37
|
+
#
|
|
38
|
+
# Keys are sorted, because two call sites writing the same query with the
|
|
39
|
+
# keys in a different order are the same query, and insertion order would
|
|
40
|
+
# make them two operations that never accumulate a shared history.
|
|
41
|
+
#
|
|
42
|
+
# Only a Hash or an Array is structure. Everything else is a *value*,
|
|
43
|
+
# however many attributes it happens to carry — and that is not a nicety:
|
|
44
|
+
# a BSON::ObjectId, a Time, a BSON::Regexp or any application object has
|
|
45
|
+
# internals that are not the query's structure, and walking them would put
|
|
46
|
+
# meaningless keys into the identity of every query that filters by `_id`,
|
|
47
|
+
# which is most of them.
|
|
48
|
+
def shape_of(value, depth = 0)
|
|
49
|
+
return '' if depth > MAX_DEPTH
|
|
50
|
+
|
|
51
|
+
case value
|
|
52
|
+
when Array
|
|
53
|
+
# An array contributes the shape of its first element, never its length.
|
|
54
|
+
# `{_id: {'$in' => [...]}}` with three ids and with three thousand is one
|
|
55
|
+
# operation — the same bargain SQL normalization strikes when it folds
|
|
56
|
+
# `in (?, ?, ?)` down to `in (?)`.
|
|
57
|
+
value.empty? ? '' : shape_of(value.first, depth + 1)
|
|
58
|
+
when Hash
|
|
59
|
+
keys = value.keys.sort_by(&:to_s).first(MAX_KEYS)
|
|
60
|
+
return '' if keys.empty?
|
|
61
|
+
|
|
62
|
+
keys.map do |key|
|
|
63
|
+
inner = shape_of(value[key], depth + 1)
|
|
64
|
+
inner.empty? ? key.to_s : "#{key}{#{inner}}"
|
|
65
|
+
end.join(',')
|
|
66
|
+
else
|
|
67
|
+
''
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The shape of an *ordered* structure, where every element matters.
|
|
72
|
+
#
|
|
73
|
+
# An aggregation pipeline is a list whose entries are deliberately different
|
|
74
|
+
# from one another — `[{$match}, {$group}, {$sort}]` — so it is structure,
|
|
75
|
+
# not data, and taking only the first element the way `shape_of` does would
|
|
76
|
+
# collapse every pipeline in the application to the shape of whatever it
|
|
77
|
+
# happened to start with. `$match → $group` and `$match → $lookup → $unwind`
|
|
78
|
+
# would be one operation, which is the difference between a working query
|
|
79
|
+
# and the N+1 that replaced it.
|
|
80
|
+
def shape_of_sequence(values)
|
|
81
|
+
return '' unless values.is_a?(Array) && !values.empty?
|
|
82
|
+
|
|
83
|
+
stages = values.first(MAX_STAGES).map do |stage|
|
|
84
|
+
inner = shape_of(stage, 1)
|
|
85
|
+
inner.empty? ? '[]' : "[#{inner}]"
|
|
86
|
+
end
|
|
87
|
+
stages << '[…]' if values.length > MAX_STAGES
|
|
88
|
+
stages.join
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/sixty/sketch.rb
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Sixty
|
|
6
|
+
# DDSketch — quantile sketch with *relative* error guarantees.
|
|
7
|
+
#
|
|
8
|
+
# A byte-for-byte port of packages/core/src/sketch.js. The format is the
|
|
9
|
+
# contract between every agent and the collector: the collector decodes these
|
|
10
|
+
# blobs with the JavaScript implementation, merges them across releases, and
|
|
11
|
+
# computes the percentiles a finding is made of. A Ruby sketch that decoded
|
|
12
|
+
# into slightly different buckets would not error anywhere — it would produce
|
|
13
|
+
# a p95 that quietly disagrees with the one a Node service in the same org
|
|
14
|
+
# reports, which is the kind of wrong this project exists to avoid.
|
|
15
|
+
#
|
|
16
|
+
# So the header layout, the varint encoding and the bucket indexing are
|
|
17
|
+
# reproduced exactly rather than reimplemented idiomatically, and
|
|
18
|
+
# test/sketch_test.rb checks the bytes against fixtures generated by the
|
|
19
|
+
# JavaScript writer.
|
|
20
|
+
#
|
|
21
|
+
# Why a sketch at all: latency is heavy-tailed and multimodal. A mean moves
|
|
22
|
+
# too little to detect a real p95 regression, and merges of DDSketches are
|
|
23
|
+
# lossless — which is what lets the collector store one sketch per 5-minute
|
|
24
|
+
# bucket and union arbitrary windows without the numbers drifting.
|
|
25
|
+
class Sketch
|
|
26
|
+
MAGIC = 0xd5
|
|
27
|
+
VERSION = 1
|
|
28
|
+
MIN_VALUE = 1e-9
|
|
29
|
+
# 1 magic + 1 version + 5 doubles + 1 flag.
|
|
30
|
+
HEADER_BYTES = 43
|
|
31
|
+
|
|
32
|
+
attr_reader :alpha, :count, :sum, :zero_count, :buckets
|
|
33
|
+
|
|
34
|
+
# @param alpha [Float] relative accuracy, e.g. 0.01 for 1%
|
|
35
|
+
def initialize(alpha = 0.01)
|
|
36
|
+
@alpha = alpha
|
|
37
|
+
@gamma = (1 + alpha) / (1 - alpha)
|
|
38
|
+
@log_gamma = Math.log(@gamma)
|
|
39
|
+
@buckets = {}
|
|
40
|
+
@zero_count = 0
|
|
41
|
+
@count = 0
|
|
42
|
+
@sum = 0.0
|
|
43
|
+
@min = Float::INFINITY
|
|
44
|
+
@max = -Float::INFINITY
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add(value, count = 1)
|
|
48
|
+
value = value.to_f
|
|
49
|
+
return self if value.nan? || value.infinite? || count <= 0
|
|
50
|
+
|
|
51
|
+
value = 0.0 if value.negative?
|
|
52
|
+
|
|
53
|
+
@count += count
|
|
54
|
+
@sum += value * count
|
|
55
|
+
@min = value if value < @min
|
|
56
|
+
@max = value if value > @max
|
|
57
|
+
|
|
58
|
+
if value < MIN_VALUE
|
|
59
|
+
@zero_count += count
|
|
60
|
+
return self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
i = index(value)
|
|
64
|
+
@buckets[i] = (@buckets[i] || 0) + count
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Lossless when both sketches share alpha. Mutates self.
|
|
69
|
+
def merge(other)
|
|
70
|
+
return self if other.nil? || other.count.zero?
|
|
71
|
+
raise ArgumentError, "cannot merge sketches with different alpha" if (other.alpha - @alpha).abs > 1e-12
|
|
72
|
+
|
|
73
|
+
other.buckets.each { |i, c| @buckets[i] = (@buckets[i] || 0) + c }
|
|
74
|
+
@zero_count += other.zero_count
|
|
75
|
+
@count += other.count
|
|
76
|
+
@sum += other.sum
|
|
77
|
+
@min = other.min if other.min < @min
|
|
78
|
+
@max = other.max if other.max > @max
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def quantile(q)
|
|
83
|
+
return nil if @count.zero?
|
|
84
|
+
return @min if q <= 0
|
|
85
|
+
return @max if q >= 1
|
|
86
|
+
|
|
87
|
+
rank = q * (@count - 1)
|
|
88
|
+
return 0.0 if rank < @zero_count
|
|
89
|
+
|
|
90
|
+
acc = @zero_count
|
|
91
|
+
@buckets.keys.sort.each do |i|
|
|
92
|
+
acc += @buckets[i]
|
|
93
|
+
# Clamped to observed bounds: a bucket's nominal value can fall slightly
|
|
94
|
+
# outside [min, max], and a p99 above any observed value reads as a bug.
|
|
95
|
+
return [[value_at(i), @min].max, @max].min if acc > rank
|
|
96
|
+
end
|
|
97
|
+
@max
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def mean
|
|
101
|
+
@count.zero? ? nil : @sum / @count
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Compact binary form: header + zigzag-varint delta-encoded buckets.
|
|
105
|
+
def to_binary
|
|
106
|
+
indices = @buckets.keys.sort
|
|
107
|
+
body = +''.b
|
|
108
|
+
prev = 0
|
|
109
|
+
indices.each do |i|
|
|
110
|
+
write_varint(body, zigzag(i - prev))
|
|
111
|
+
write_varint(body, @buckets[i].round)
|
|
112
|
+
prev = i
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
header = [MAGIC, VERSION].pack('C2')
|
|
116
|
+
# 'E' is an IEEE-754 double, little-endian — the same layout DataView
|
|
117
|
+
# writes with littleEndian = true.
|
|
118
|
+
header += [@alpha, @zero_count.to_f, @sum.to_f,
|
|
119
|
+
@count.zero? ? 0.0 : @min.to_f,
|
|
120
|
+
@count.zero? ? 0.0 : @max.to_f].pack('E5')
|
|
121
|
+
header += [indices.empty? ? 0 : 1].pack('C')
|
|
122
|
+
header + body
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Base64 of the binary form, which is how the agent ships it over JSON.
|
|
126
|
+
def to_base64
|
|
127
|
+
Base64.strict_encode64(to_binary)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Only the tests decode; the agent is write-only. Kept here anyway because a
|
|
131
|
+
# codec whose two halves live in different languages is a codec nobody can
|
|
132
|
+
# check locally.
|
|
133
|
+
def self.from_binary(bytes)
|
|
134
|
+
bytes = bytes.b
|
|
135
|
+
return new if bytes.bytesize < HEADER_BYTES
|
|
136
|
+
raise ArgumentError, 'not a DDSketch buffer' unless bytes.getbyte(0) == MAGIC
|
|
137
|
+
|
|
138
|
+
version = bytes.getbyte(1)
|
|
139
|
+
raise ArgumentError, "unsupported sketch version #{version}" unless version == VERSION
|
|
140
|
+
|
|
141
|
+
alpha, zero_count, sum, min, max = bytes.byteslice(2, 40).unpack('E5')
|
|
142
|
+
sketch = new(alpha)
|
|
143
|
+
sketch.instance_variable_set(:@zero_count, zero_count)
|
|
144
|
+
sketch.instance_variable_set(:@sum, sum)
|
|
145
|
+
|
|
146
|
+
offset = HEADER_BYTES
|
|
147
|
+
total = zero_count
|
|
148
|
+
prev = 0
|
|
149
|
+
while offset < bytes.bytesize
|
|
150
|
+
delta, offset = read_varint(bytes, offset)
|
|
151
|
+
count, offset = read_varint(bytes, offset)
|
|
152
|
+
i = prev + unzigzag(delta)
|
|
153
|
+
sketch.buckets[i] = (sketch.buckets[i] || 0) + count
|
|
154
|
+
total += count
|
|
155
|
+
prev = i
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
sketch.instance_variable_set(:@count, total)
|
|
159
|
+
sketch.instance_variable_set(:@min, total.positive? ? min : Float::INFINITY)
|
|
160
|
+
sketch.instance_variable_set(:@max, total.positive? ? max : -Float::INFINITY)
|
|
161
|
+
sketch
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
protected
|
|
165
|
+
|
|
166
|
+
attr_reader :min, :max
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def index(value)
|
|
171
|
+
(Math.log(value) / @log_gamma).ceil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def value_at(i)
|
|
175
|
+
(2 * (@gamma**i)) / (@gamma + 1)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def zigzag(n)
|
|
179
|
+
n >= 0 ? n * 2 : -n * 2 - 1
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def write_varint(out, n)
|
|
183
|
+
n = [0, n.round].max
|
|
184
|
+
while n >= 0x80
|
|
185
|
+
out << ((n & 0x7f) | 0x80).chr
|
|
186
|
+
n /= 128
|
|
187
|
+
end
|
|
188
|
+
out << n.chr
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.unzigzag(n)
|
|
192
|
+
n.even? ? n / 2 : -(n + 1) / 2
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.read_varint(bytes, offset)
|
|
196
|
+
result = 0
|
|
197
|
+
shift = 1
|
|
198
|
+
loop do
|
|
199
|
+
b = bytes.getbyte(offset)
|
|
200
|
+
offset += 1
|
|
201
|
+
result += (b & 0x7f) * shift
|
|
202
|
+
break if (b & 0x80).zero?
|
|
203
|
+
|
|
204
|
+
shift *= 128
|
|
205
|
+
end
|
|
206
|
+
[result, offset]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private_class_method :unzigzag, :read_varint
|
|
210
|
+
end
|
|
211
|
+
end
|