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,482 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tracer'
|
|
4
|
+
require_relative '../shape'
|
|
5
|
+
require_relative '../stack'
|
|
6
|
+
|
|
7
|
+
module Sixty
|
|
8
|
+
module Instrument
|
|
9
|
+
# MongoDB.
|
|
10
|
+
#
|
|
11
|
+
# The third data model this agent measures and the first that is not SQL.
|
|
12
|
+
# Every signal downstream — documents per call, calls per invocation,
|
|
13
|
+
# payload size, error rate — is defined on span attributes rather than on
|
|
14
|
+
# statements, so the feed, the detector and the MCP server need no change.
|
|
15
|
+
# What has to be rebuilt is everything below those attributes: identity,
|
|
16
|
+
# which has no statement to normalize, and the document count, which in a
|
|
17
|
+
# document database arrives in a different shape for every command.
|
|
18
|
+
#
|
|
19
|
+
# ── Why command monitoring here, and not in the JavaScript agent ──────────
|
|
20
|
+
#
|
|
21
|
+
# `@sixty-sh/node` deliberately refuses the driver's command events and
|
|
22
|
+
# patches `Collection.prototype` instead, because in Node a command event
|
|
23
|
+
# fires from the driver's own plumbing *after* the caller's async context is
|
|
24
|
+
# gone — every operation would be measured correctly and parented to
|
|
25
|
+
# nothing, and attribution is the whole product.
|
|
26
|
+
#
|
|
27
|
+
# The Ruby driver is synchronous. `started` and `succeeded` are published on
|
|
28
|
+
# the calling thread, inside the caller's own stack, so the current span is
|
|
29
|
+
# still the method that issued the query and the parenting is exact. That
|
|
30
|
+
# makes the official monitoring API strictly better here than monkey-patching
|
|
31
|
+
# would be: it is public, versioned, and covers Mongoid, the driver's own
|
|
32
|
+
# cursor round trips and commands issued through paths a collection patch
|
|
33
|
+
# would never see.
|
|
34
|
+
#
|
|
35
|
+
# ── One cursor is one operation, however many round trips it took ────────
|
|
36
|
+
#
|
|
37
|
+
# A `find` returning thirty thousand documents at the default batch size is
|
|
38
|
+
# not one exchange with the server: it is the initial command plus roughly
|
|
39
|
+
# three hundred `getMore` calls, each a network wait the application sits
|
|
40
|
+
# through. The naive reading of command monitoring records three hundred and
|
|
41
|
+
# one operations, and it is wrong in a way that misdirects the reader — the
|
|
42
|
+
# calling method appears to make three hundred database calls, which is the
|
|
43
|
+
# signature of an N+1 the code does not contain, and the fix a fanout finding
|
|
44
|
+
# suggests ("stop looping") is not the fix this needs ("set batchSize").
|
|
45
|
+
#
|
|
46
|
+
# So a cursor is held open: the `find` span stays alive until the cursor is
|
|
47
|
+
# exhausted, counting documents and round trips as its `getMore`s arrive, and
|
|
48
|
+
# is emitted once. `db_calls` then says one call, `rows` says thirty
|
|
49
|
+
# thousand, and `round_trips` — the signal the collector added for exactly
|
|
50
|
+
# this — says three hundred. That also matches what the JavaScript agent
|
|
51
|
+
# reports for the same query, which matters: the same regression in the same
|
|
52
|
+
# database should not look like two different findings depending on which
|
|
53
|
+
# language the service happens to be written in.
|
|
54
|
+
#
|
|
55
|
+
# ── No query plans ────────────────────────────────────────────────────────
|
|
56
|
+
#
|
|
57
|
+
# `explain` is the highest-value thing this adapter could add — COLLSCAN
|
|
58
|
+
# versus IXSCAN names a cause rather than a symptom. It is absent because
|
|
59
|
+
# Mongo can only explain a filter that still has its values in it, and this
|
|
60
|
+
# file drops values at the moment it sees them. Capturing a plan would mean
|
|
61
|
+
# *retaining* somebody's query values in order to compose a command out of
|
|
62
|
+
# them. That trade is refused here exactly as it is refused for MySQL.
|
|
63
|
+
module Mongo
|
|
64
|
+
# Commands the driver issues about itself. A handshake is not an operation
|
|
65
|
+
# anybody can act on, and heartbeats would otherwise be the highest-count
|
|
66
|
+
# "query" in every service.
|
|
67
|
+
IGNORED_COMMANDS = %w[
|
|
68
|
+
ismaster isMaster hello ping buildInfo getnonce authenticate saslStart
|
|
69
|
+
saslContinue logout endSessions getLog hostInfo listDatabases
|
|
70
|
+
connectionStatus getParameter
|
|
71
|
+
].freeze
|
|
72
|
+
|
|
73
|
+
# Commands that can hand back a cursor rather than an answer.
|
|
74
|
+
CURSOR_COMMANDS = %w[find aggregate listIndexes listCollections].freeze
|
|
75
|
+
|
|
76
|
+
# Keys the driver adds to every command. They describe the session and the
|
|
77
|
+
# topology rather than the query, and including them would make the
|
|
78
|
+
# identity of every operation move with the driver's version.
|
|
79
|
+
#
|
|
80
|
+
# `cursor`, `batchSize` and `singleBatch` describe how the results are
|
|
81
|
+
# *fetched* rather than what was asked for, and they are excluded for a
|
|
82
|
+
# sharper reason than tidiness: setting or removing a batch size is
|
|
83
|
+
# precisely the change the `round_trips` signal exists to report, and an
|
|
84
|
+
# identity that moved with it would re-identify the operation at the
|
|
85
|
+
# moment of the change. The detector would then have nothing to compare —
|
|
86
|
+
# a new operation with no history beside an old one that stopped
|
|
87
|
+
# reporting — and the finding could never fire.
|
|
88
|
+
#
|
|
89
|
+
# `limit` is not in this list, and the difference is the point: a limit
|
|
90
|
+
# changes what you asked for.
|
|
91
|
+
ENVELOPE_KEYS = %w[
|
|
92
|
+
$db lsid txnNumber $clusterTime $readPreference $audit apiVersion
|
|
93
|
+
apiStrict apiDeprecationErrors signature startTransaction autocommit
|
|
94
|
+
readConcern writeConcern comment cursor batchSize singleBatch
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
# A command that never completes would otherwise leave its start context
|
|
98
|
+
# behind forever. The cap is per thread and far above any real number of
|
|
99
|
+
# commands in flight on one connection at one time.
|
|
100
|
+
MAX_IN_FLIGHT = 64
|
|
101
|
+
# And a cursor that is opened and never drained would leave a span open.
|
|
102
|
+
# Past this many, the oldest is emitted with what it has — an incomplete
|
|
103
|
+
# measurement rather than an unbounded one.
|
|
104
|
+
MAX_OPEN_CURSORS = 128
|
|
105
|
+
# The same protection in time rather than in count. The Ruby driver kills
|
|
106
|
+
# an abandoned cursor from a finalizer, so `break`ing out of a loop over a
|
|
107
|
+
# cursor releases nothing until the garbage collector gets to it — which
|
|
108
|
+
# may be never in a process that is not under memory pressure. A cursor
|
|
109
|
+
# nobody has read from in this long is reported with what it has.
|
|
110
|
+
MAX_CURSOR_AGE_SECONDS = 60
|
|
111
|
+
|
|
112
|
+
KEY = :sixty_mongo_in_flight
|
|
113
|
+
|
|
114
|
+
class << self
|
|
115
|
+
attr_reader :config, :subscriber
|
|
116
|
+
|
|
117
|
+
def install(config = nil)
|
|
118
|
+
return false if @installed
|
|
119
|
+
return false unless defined?(::Mongo::Monitoring::Global)
|
|
120
|
+
|
|
121
|
+
@config = config
|
|
122
|
+
@subscriber = Subscriber.new
|
|
123
|
+
# Global, so clients created later are covered. Clients that already
|
|
124
|
+
# exist — Mongoid builds its own during boot, which may be before
|
|
125
|
+
# this runs — are subscribed to individually below.
|
|
126
|
+
::Mongo::Monitoring::Global.subscribe(::Mongo::Monitoring::COMMAND, @subscriber)
|
|
127
|
+
subscribe_existing_clients
|
|
128
|
+
Sixty.before_flush { sweep }
|
|
129
|
+
@installed = true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def installed?
|
|
133
|
+
@installed == true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Exported for tests.
|
|
137
|
+
def reset!
|
|
138
|
+
@installed = false
|
|
139
|
+
@cursors = nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Cursors whose spans are still open, keyed by the server's cursor id.
|
|
143
|
+
#
|
|
144
|
+
# Process-wide rather than thread-local: a cursor may be handed to
|
|
145
|
+
# another thread to drain, and a span that could only be closed by the
|
|
146
|
+
# thread that opened it would leak in exactly that case.
|
|
147
|
+
def cursors
|
|
148
|
+
@cursors ||= {}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def cursor_mutex
|
|
152
|
+
@cursor_mutex ||= Mutex.new
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- state
|
|
156
|
+
|
|
157
|
+
def open_cursor(cursor_id, state, event)
|
|
158
|
+
span = Tracer.start_span(
|
|
159
|
+
kind: Tracer::KIND_DB,
|
|
160
|
+
name: state[:name],
|
|
161
|
+
attrs: { normalized_sql: state[:identity] }
|
|
162
|
+
)
|
|
163
|
+
span.attrs[:frames] = state[:frames] if state[:frames]
|
|
164
|
+
# Back-dated by the command's own duration, so the span covers the
|
|
165
|
+
# round trip that opened the cursor as well as the ones that follow.
|
|
166
|
+
span.start -= event.duration.to_f * 1000.0
|
|
167
|
+
|
|
168
|
+
entry = {
|
|
169
|
+
span: span,
|
|
170
|
+
rows: batch_length(event) || 0,
|
|
171
|
+
round_trips: 1,
|
|
172
|
+
bytes: bytes_from(event) || 0,
|
|
173
|
+
touched_at: Tracer.monotonic_ms
|
|
174
|
+
}
|
|
175
|
+
evicted = nil
|
|
176
|
+
cursor_mutex.synchronize do
|
|
177
|
+
cursors[cursor_id] = entry
|
|
178
|
+
evicted = cursors.shift if cursors.size > MAX_OPEN_CURSORS
|
|
179
|
+
end
|
|
180
|
+
close_entry(evicted[1], nil) if evicted
|
|
181
|
+
entry
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def continue_cursor(cursor_id, event, error)
|
|
185
|
+
entry = cursor_mutex.synchronize { cursors[cursor_id] }
|
|
186
|
+
return false unless entry
|
|
187
|
+
|
|
188
|
+
entry[:round_trips] += 1
|
|
189
|
+
entry[:rows] += batch_length(event) || 0
|
|
190
|
+
entry[:bytes] += bytes_from(event) || 0
|
|
191
|
+
entry[:touched_at] = Tracer.monotonic_ms
|
|
192
|
+
|
|
193
|
+
if error || exhausted?(event)
|
|
194
|
+
cursor_mutex.synchronize { cursors.delete(cursor_id) }
|
|
195
|
+
close_entry(entry, error)
|
|
196
|
+
end
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Cursors nobody is reading any more, reported rather than held. Runs on
|
|
201
|
+
# the agent's flush thread, like every other piece of housekeeping here.
|
|
202
|
+
def sweep(max_age: MAX_CURSOR_AGE_SECONDS)
|
|
203
|
+
deadline = Tracer.monotonic_ms - (max_age * 1000.0)
|
|
204
|
+
stale = cursor_mutex.synchronize do
|
|
205
|
+
cursors.select { |_id, entry| entry[:touched_at] < deadline }
|
|
206
|
+
.each_key { |id| cursors.delete(id) }
|
|
207
|
+
end
|
|
208
|
+
stale.each_value { |entry| close_entry(entry, nil) }
|
|
209
|
+
stale.size
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# An abandoned cursor: the application stopped reading and the driver
|
|
213
|
+
# told the server so. The measurement ends where the reading did.
|
|
214
|
+
def kill_cursors(ids)
|
|
215
|
+
ids.each do |id|
|
|
216
|
+
entry = cursor_mutex.synchronize { cursors.delete(id) }
|
|
217
|
+
close_entry(entry, nil) if entry
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def close_entry(entry, error)
|
|
222
|
+
span = entry[:span]
|
|
223
|
+
span.attrs[:rows] = entry[:rows]
|
|
224
|
+
span.attrs[:round_trips] = entry[:round_trips]
|
|
225
|
+
span.attrs[:bytes] = entry[:bytes] if entry[:bytes].positive?
|
|
226
|
+
Tracer.end_span(span, error)
|
|
227
|
+
Tracer.emit(span)
|
|
228
|
+
rescue StandardError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------ describing
|
|
233
|
+
|
|
234
|
+
# Everything about a command that has to be read before it runs.
|
|
235
|
+
def describe(event)
|
|
236
|
+
command = event.command || {}
|
|
237
|
+
command_name = event.command_name.to_s
|
|
238
|
+
collection = collection_for(command_name, command, event)
|
|
239
|
+
identity = identity_for(command_name, collection, command)
|
|
240
|
+
|
|
241
|
+
{
|
|
242
|
+
command_name: command_name,
|
|
243
|
+
name: "#{command_name}:#{collection}",
|
|
244
|
+
identity: identity,
|
|
245
|
+
frames: Stack.capture(identity),
|
|
246
|
+
cursor_id: command_name == 'getMore' ? cursor_id_of(command['getMore']) : nil
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The identity of the operation: the command, what it ran against, and
|
|
251
|
+
# the *structure* of its arguments. See Sixty::Shape — no value in the
|
|
252
|
+
# command has a path into this string.
|
|
253
|
+
def identity_for(command_name, collection, command)
|
|
254
|
+
parts = []
|
|
255
|
+
# A pipeline is ordered structure, so every stage counts and in order.
|
|
256
|
+
pipeline = command['pipeline'] || command[:pipeline]
|
|
257
|
+
parts << Shape.shape_of_sequence(pipeline) if pipeline.is_a?(Array)
|
|
258
|
+
|
|
259
|
+
rest = command.reject do |key, _value|
|
|
260
|
+
name = key.to_s
|
|
261
|
+
ENVELOPE_KEYS.include?(name) || name == command_name || name == 'pipeline'
|
|
262
|
+
end
|
|
263
|
+
shape = Shape.shape_of(rest)
|
|
264
|
+
parts << "{#{shape}}" unless shape.empty?
|
|
265
|
+
|
|
266
|
+
"#{command_name} #{collection} #{parts.join(' ')}".strip
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# What the command ran against. For most commands the collection is the
|
|
270
|
+
# value of the command key itself (`{find: 'orders', ...}`); `getMore`
|
|
271
|
+
# names a cursor there and carries the collection separately.
|
|
272
|
+
def collection_for(command_name, command, event)
|
|
273
|
+
value = command_name == 'getMore' ? command['collection'] : command[command_name]
|
|
274
|
+
return value if value.is_a?(String) && !value.empty?
|
|
275
|
+
|
|
276
|
+
database = event.database_name.to_s
|
|
277
|
+
database.empty? ? 'collection' : database
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# -------------------------------------------------------------- replies
|
|
281
|
+
|
|
282
|
+
# Documents returned for a read, documents affected for a write — the
|
|
283
|
+
# same meaning `rows` has for every SQL client in this directory. A
|
|
284
|
+
# `rows` drift that meant one thing on Postgres and another on Mongo
|
|
285
|
+
# would be a chart nobody can read.
|
|
286
|
+
def rows_from(event, command_name)
|
|
287
|
+
batch = batch_length(event)
|
|
288
|
+
return batch if batch
|
|
289
|
+
|
|
290
|
+
reply = reply_of(event)
|
|
291
|
+
return nil unless reply
|
|
292
|
+
|
|
293
|
+
case command_name
|
|
294
|
+
when 'count', 'countDocuments'
|
|
295
|
+
# A count returns one number, so one document came back. Reporting
|
|
296
|
+
# the count itself would say that counting a million-document
|
|
297
|
+
# collection returned a million documents, and the first collection
|
|
298
|
+
# to grow would look like the regression this product exists to
|
|
299
|
+
# report.
|
|
300
|
+
1
|
|
301
|
+
when 'distinct'
|
|
302
|
+
values = reply['values']
|
|
303
|
+
values.is_a?(Array) ? values.length : 1
|
|
304
|
+
when 'findAndModify'
|
|
305
|
+
reply['value'].nil? ? 0 : 1
|
|
306
|
+
when 'update'
|
|
307
|
+
number(reply['nModified']) || number(reply['n'])
|
|
308
|
+
else
|
|
309
|
+
number(reply['n'])
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def batch_length(event)
|
|
314
|
+
cursor = reply_of(event)&.[]('cursor')
|
|
315
|
+
return nil unless cursor.is_a?(Hash)
|
|
316
|
+
|
|
317
|
+
batch = cursor['firstBatch'] || cursor['nextBatch']
|
|
318
|
+
batch.is_a?(Array) ? batch.length : nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def cursor_id_from_reply(event)
|
|
322
|
+
cursor = reply_of(event)&.[]('cursor')
|
|
323
|
+
cursor.is_a?(Hash) ? cursor_id_of(cursor['id']) : nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def exhausted?(event)
|
|
327
|
+
id = cursor_id_from_reply(event)
|
|
328
|
+
id.nil? || id.zero?
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# BSON::Int64 and Integer both appear here depending on driver version
|
|
332
|
+
# and platform, and they are not `eql?` — so a map keyed by the raw value
|
|
333
|
+
# would miss on lookup and every cursor would look like a new one.
|
|
334
|
+
def cursor_id_of(value)
|
|
335
|
+
return nil if value.nil?
|
|
336
|
+
|
|
337
|
+
value.respond_to?(:value) ? value.value.to_i : value.to_i
|
|
338
|
+
rescue StandardError
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# A failed command has no reply at all — `CommandFailed` carries a
|
|
343
|
+
# message where `CommandSucceeded` carries the server's answer. Reading
|
|
344
|
+
# it unguarded is how the first version of this file dropped every failed
|
|
345
|
+
# query: the error reached the application correctly and the operation
|
|
346
|
+
# that produced it was never recorded.
|
|
347
|
+
def reply_of(event)
|
|
348
|
+
return nil unless event.respond_to?(:reply)
|
|
349
|
+
|
|
350
|
+
reply = event.reply
|
|
351
|
+
reply.is_a?(Hash) ? reply : nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Sampled, because serializing a whole reply to measure it would cost
|
|
355
|
+
# more than the query. Precision does not matter: this exists to catch a
|
|
356
|
+
# payload going from 4KB to 2MB.
|
|
357
|
+
def bytes_from(event)
|
|
358
|
+
cursor = reply_of(event)&.[]('cursor')
|
|
359
|
+
batch = cursor.is_a?(Hash) ? (cursor['firstBatch'] || cursor['nextBatch']) : nil
|
|
360
|
+
return nil unless batch.is_a?(Array) && !batch.empty?
|
|
361
|
+
|
|
362
|
+
# Three documents rather than five: serializing a BSON document to
|
|
363
|
+
# measure it is the most expensive thing this file does per query, and
|
|
364
|
+
# the number only has to be right to an order of magnitude.
|
|
365
|
+
sample = batch.first(3)
|
|
366
|
+
sampled = sample.sum { |doc| doc.to_bson.length }
|
|
367
|
+
((sampled.to_f / sample.length) * batch.length).round
|
|
368
|
+
rescue StandardError
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def number(value)
|
|
373
|
+
value.is_a?(Numeric) ? value.to_i : nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
private
|
|
377
|
+
|
|
378
|
+
# Mongoid registers its clients before an initializer runs, and a global
|
|
379
|
+
# subscription only reaches clients constructed afterwards. Reaching into
|
|
380
|
+
# `Mongoid.clients` is guarded twice over: the constant may not exist,
|
|
381
|
+
# and a driver that changed the shape of it must not take the boot down.
|
|
382
|
+
def subscribe_existing_clients
|
|
383
|
+
return unless defined?(::Mongoid) && ::Mongoid.respond_to?(:clients)
|
|
384
|
+
|
|
385
|
+
::Mongoid.clients.each_key do |name|
|
|
386
|
+
::Mongoid.client(name).subscribe(::Mongo::Monitoring::COMMAND, @subscriber)
|
|
387
|
+
rescue StandardError
|
|
388
|
+
next
|
|
389
|
+
end
|
|
390
|
+
rescue StandardError
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# The monitoring contract: three methods, none of which may raise into the
|
|
396
|
+
# driver. A subscriber that throws would fail the application's query.
|
|
397
|
+
class Subscriber
|
|
398
|
+
def started(event)
|
|
399
|
+
return unless Sixty.enabled?
|
|
400
|
+
|
|
401
|
+
command_name = event.command_name.to_s
|
|
402
|
+
# Not an operation, but the end of one: the application stopped
|
|
403
|
+
# reading a cursor and the driver is telling the server so.
|
|
404
|
+
if command_name == 'killCursors'
|
|
405
|
+
Mongo.kill_cursors(Array(event.command['cursors']).map { |id| Mongo.cursor_id_of(id) }.compact)
|
|
406
|
+
return
|
|
407
|
+
end
|
|
408
|
+
return if IGNORED_COMMANDS.include?(command_name)
|
|
409
|
+
|
|
410
|
+
in_flight = (Thread.current[KEY] ||= {})
|
|
411
|
+
in_flight.clear if in_flight.size >= MAX_IN_FLIGHT
|
|
412
|
+
in_flight[event.request_id] = Mongo.describe(event)
|
|
413
|
+
rescue StandardError
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def succeeded(event)
|
|
418
|
+
finish(event, nil)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def failed(event)
|
|
422
|
+
# `event.message` is the server's error text. It is used as the error
|
|
423
|
+
# message and never as part of the operation's identity, so a failure
|
|
424
|
+
# that quotes a value cannot mint an operation from it.
|
|
425
|
+
finish(event, CommandFailed.new(event.message.to_s))
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
private
|
|
429
|
+
|
|
430
|
+
def finish(event, error)
|
|
431
|
+
in_flight = Thread.current[KEY]
|
|
432
|
+
state = in_flight&.delete(event.request_id)
|
|
433
|
+
return unless state
|
|
434
|
+
|
|
435
|
+
# A `getMore` belongs to the cursor that opened it, not to itself.
|
|
436
|
+
# Only a cursor this agent never saw opened — one already in flight
|
|
437
|
+
# when the process started measuring — is recorded on its own.
|
|
438
|
+
if state[:command_name] == 'getMore'
|
|
439
|
+
return if Mongo.continue_cursor(state[:cursor_id], event, error)
|
|
440
|
+
elsif error.nil? && CURSOR_COMMANDS.include?(state[:command_name])
|
|
441
|
+
cursor_id = Mongo.cursor_id_from_reply(event)
|
|
442
|
+
if cursor_id && !cursor_id.zero?
|
|
443
|
+
Mongo.open_cursor(cursor_id, state, event)
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
record(state, event, error)
|
|
449
|
+
rescue StandardError
|
|
450
|
+
nil
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# A command whose answer is complete when it returns: a write, a count,
|
|
454
|
+
# or a read that fitted in one batch.
|
|
455
|
+
def record(state, event, error)
|
|
456
|
+
attrs = { normalized_sql: state[:identity], round_trips: 1 }
|
|
457
|
+
attrs[:frames] = state[:frames] if state[:frames]
|
|
458
|
+
rows = Mongo.rows_from(event, state[:command_name])
|
|
459
|
+
attrs[:rows] = rows if rows
|
|
460
|
+
bytes = Mongo.bytes_from(event)
|
|
461
|
+
attrs[:bytes] = bytes if bytes
|
|
462
|
+
|
|
463
|
+
Tracer.record(
|
|
464
|
+
kind: Tracer::KIND_DB,
|
|
465
|
+
name: state[:name],
|
|
466
|
+
# The driver reports seconds; everything in this agent is
|
|
467
|
+
# milliseconds, and a unit disagreement here would put Mongo
|
|
468
|
+
# operations three orders of magnitude below every other query in
|
|
469
|
+
# the same feed.
|
|
470
|
+
duration_ms: event.duration.to_f * 1000.0,
|
|
471
|
+
attrs: attrs,
|
|
472
|
+
error: error
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# The error type recorded for a command the server refused. Named rather
|
|
478
|
+
# than a bare StandardError so the feed says what kind of failure it was.
|
|
479
|
+
class CommandFailed < StandardError; end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
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 MySQL drivers — `mysql2` and `trilogy` — patched directly.
|
|
10
|
+
#
|
|
11
|
+
# Same audience as the `pg` patch: everything that is not ActiveRecord.
|
|
12
|
+
# A Rails app on MySQL is covered by `sql.active_record`, which is public
|
|
13
|
+
# API and does not care which driver is underneath; this covers Sinatra,
|
|
14
|
+
# Sequel, workers and scripts, both of whose drivers reach the server
|
|
15
|
+
# through one method each.
|
|
16
|
+
#
|
|
17
|
+
# ── The dialect is not a detail ──────────────────────────────────────────
|
|
18
|
+
#
|
|
19
|
+
# Everything here normalizes with `:mysql`, because `"alice@example.com"` is
|
|
20
|
+
# a *string literal* in MySQL's default sql_mode and a quoted *identifier*
|
|
21
|
+
# in Postgres. Reading a MySQL statement with the Postgres rules would keep
|
|
22
|
+
# it — the one class of bug this agent must never have.
|
|
23
|
+
#
|
|
24
|
+
# ── Rows, and why the result is not iterated to find them ────────────────
|
|
25
|
+
#
|
|
26
|
+
# `Mysql2::Result` is lazily populated when the client is streaming, and
|
|
27
|
+
# asking a streaming result for its size consumes it. The count is therefore
|
|
28
|
+
# only read where it is free — a buffered result already knows it — and a
|
|
29
|
+
# streaming query reports no row count rather than a wrong one, or a query
|
|
30
|
+
# this agent silently drained on the application's behalf.
|
|
31
|
+
module Mysql
|
|
32
|
+
class << self
|
|
33
|
+
def install(config = nil)
|
|
34
|
+
@config = config
|
|
35
|
+
installed = false
|
|
36
|
+
installed |= install_mysql2
|
|
37
|
+
installed |= install_trilogy
|
|
38
|
+
installed
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def installed?
|
|
42
|
+
@mysql2_installed == true || @trilogy_installed == true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reset!
|
|
46
|
+
@mysql2_installed = false
|
|
47
|
+
@trilogy_installed = false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One span for one statement, shared by both drivers.
|
|
51
|
+
#
|
|
52
|
+
# `count_rows` is decided by the caller rather than here, because only
|
|
53
|
+
# the patch can see the query options that say whether the result will
|
|
54
|
+
# be streamed — and asking a streaming result for its size would consume
|
|
55
|
+
# it, which is a change in the application's behaviour rather than a
|
|
56
|
+
# measurement of it.
|
|
57
|
+
def measure(sql, connection: nil, count_rows: true)
|
|
58
|
+
return yield unless Sixty.enabled? && sql.is_a?(String) && !sql.empty?
|
|
59
|
+
|
|
60
|
+
normalized = Sql.normalize_sql(sql, :mysql)
|
|
61
|
+
started = Tracer.monotonic_ms
|
|
62
|
+
begin
|
|
63
|
+
result = yield
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
record(normalized, Tracer.monotonic_ms - started, nil, nil, false, e)
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
record(normalized, Tracer.monotonic_ms - started, result, connection, count_rows, nil)
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def install_mysql2
|
|
75
|
+
return false if @mysql2_installed
|
|
76
|
+
return false unless defined?(::Mysql2::Client)
|
|
77
|
+
|
|
78
|
+
::Mysql2::Client.prepend(Mysql2Patch)
|
|
79
|
+
::Mysql2::Client.prepend(Mysql2PreparePatch)
|
|
80
|
+
::Mysql2::Statement.prepend(Mysql2StatementPatch) if defined?(::Mysql2::Statement)
|
|
81
|
+
@mysql2_installed = true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def install_trilogy
|
|
85
|
+
return false if @trilogy_installed
|
|
86
|
+
return false unless defined?(::Trilogy)
|
|
87
|
+
|
|
88
|
+
::Trilogy.prepend(TrilogyPatch)
|
|
89
|
+
@trilogy_installed = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def record(normalized, duration_ms, result, connection, count_rows, error)
|
|
93
|
+
attrs = { normalized_sql: normalized }
|
|
94
|
+
frames = Stack.capture(normalized)
|
|
95
|
+
if frames
|
|
96
|
+
attrs[:frames] = frames
|
|
97
|
+
attrs[:file] = frames.first[:file]
|
|
98
|
+
attrs[:line] = frames.first[:line]
|
|
99
|
+
end
|
|
100
|
+
rows = row_count(result, connection, count_rows)
|
|
101
|
+
attrs[:rows] = rows if rows
|
|
102
|
+
attrs[:fields] = result.fields.length if result.respond_to?(:fields) && result.fields
|
|
103
|
+
|
|
104
|
+
Tracer.record(
|
|
105
|
+
kind: Tracer::KIND_DB,
|
|
106
|
+
name: Sql.sql_operation_name(normalized),
|
|
107
|
+
duration_ms: duration_ms,
|
|
108
|
+
attrs: attrs,
|
|
109
|
+
error: error
|
|
110
|
+
)
|
|
111
|
+
# No plans on MySQL. See Sixty::Plans#enqueue: there is no
|
|
112
|
+
# GENERIC_PLAN here, so an EXPLAIN would need the values bound.
|
|
113
|
+
rescue StandardError
|
|
114
|
+
# An unmeasured query, not a failed one.
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def row_count(result, connection, count_rows)
|
|
119
|
+
# A read: the rows the statement returned.
|
|
120
|
+
return result.count if count_rows && result.respond_to?(:count)
|
|
121
|
+
|
|
122
|
+
# A write returns no result set and reports what it changed instead.
|
|
123
|
+
# Both are "rows this statement was responsible for", which is what
|
|
124
|
+
# this signal means for every other client in this directory.
|
|
125
|
+
return result.affected_rows if result.respond_to?(:affected_rows)
|
|
126
|
+
return connection.affected_rows if connection.respond_to?(:affected_rows)
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A prepared statement runs its SQL somewhere other than the call site:
|
|
135
|
+
# `client.prepare(sql)` has the text and `statement.execute(id)` has the
|
|
136
|
+
# values, and only the second one is a query. Without this, an application
|
|
137
|
+
# using prepared statements reports no database calls at all — the silent
|
|
138
|
+
# blind spot this project refuses, since nothing errors and the feed
|
|
139
|
+
# simply shows a service that never touches its database.
|
|
140
|
+
#
|
|
141
|
+
# The text is carried on the statement object itself rather than in a map:
|
|
142
|
+
# a statement is a Ruby object with the same lifetime as the prepared
|
|
143
|
+
# statement it stands for, so there is nothing to bound and nothing to
|
|
144
|
+
# evict.
|
|
145
|
+
module Mysql2PreparePatch
|
|
146
|
+
def prepare(*args, &block)
|
|
147
|
+
statement = super
|
|
148
|
+
statement.instance_variable_set(:@sixty_sql, args.first)
|
|
149
|
+
statement
|
|
150
|
+
rescue StandardError
|
|
151
|
+
# A driver that changed the shape of `prepare` must not take the
|
|
152
|
+
# application's query with it.
|
|
153
|
+
statement
|
|
154
|
+
end
|
|
155
|
+
ruby2_keywords :prepare
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
module Mysql2StatementPatch
|
|
159
|
+
def execute(*args, &block)
|
|
160
|
+
sql = instance_variable_get(:@sixty_sql)
|
|
161
|
+
Sixty::Instrument::Mysql.measure(sql) { super }
|
|
162
|
+
end
|
|
163
|
+
ruby2_keywords :execute
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
module Mysql2Patch
|
|
167
|
+
def query(*args, &block)
|
|
168
|
+
# `stream: true` can come from the call or from the client's defaults,
|
|
169
|
+
# and either one means the rows are not there yet.
|
|
170
|
+
options = args[1].is_a?(Hash) ? args[1] : {}
|
|
171
|
+
streaming = options[:stream] || (query_options.is_a?(Hash) && query_options[:stream])
|
|
172
|
+
Sixty::Instrument::Mysql.measure(
|
|
173
|
+
args.first, connection: self, count_rows: !streaming
|
|
174
|
+
) { super }
|
|
175
|
+
end
|
|
176
|
+
ruby2_keywords :query
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
module TrilogyPatch
|
|
180
|
+
def query(*args, &block)
|
|
181
|
+
Sixty::Instrument::Mysql.measure(args.first, connection: self) { super }
|
|
182
|
+
end
|
|
183
|
+
ruby2_keywords :query
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|