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,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