Module: Sixty::Instrument::Mongo

Defined in:
lib/sixty/instrument/mongo.rb

Overview

MongoDB.

The third data model this agent measures and the first that is not SQL. Every signal downstream — documents per call, calls per invocation, payload size, error rate — is defined on span attributes rather than on statements, so the feed, the detector and the MCP server need no change. What has to be rebuilt is everything below those attributes: identity, which has no statement to normalize, and the document count, which in a document database arrives in a different shape for every command.

── Why command monitoring here, and not in the JavaScript agent ──────────

@sixty-sh/node deliberately refuses the driver's command events and patches Collection.prototype instead, because in Node a command event fires from the driver's own plumbing after the caller's async context is gone — every operation would be measured correctly and parented to nothing, and attribution is the whole product.

The Ruby driver is synchronous. started and succeeded are published on the calling thread, inside the caller's own stack, so the current span is still the method that issued the query and the parenting is exact. That makes the official monitoring API strictly better here than monkey-patching would be: it is public, versioned, and covers Mongoid, the driver's own cursor round trips and commands issued through paths a collection patch would never see.

── One cursor is one operation, however many round trips it took ────────

A find returning thirty thousand documents at the default batch size is not one exchange with the server: it is the initial command plus roughly three hundred getMore calls, each a network wait the application sits through. The naive reading of command monitoring records three hundred and one operations, and it is wrong in a way that misdirects the reader — the calling method appears to make three hundred database calls, which is the signature of an N+1 the code does not contain, and the fix a fanout finding suggests ("stop looping") is not the fix this needs ("set batchSize").

So a cursor is held open: the find span stays alive until the cursor is exhausted, counting documents and round trips as its getMores arrive, and is emitted once. db_calls then says one call, rows says thirty thousand, and round_trips — the signal the collector added for exactly this — says three hundred. That also matches what the JavaScript agent reports for the same query, which matters: the same regression in the same database should not look like two different findings depending on which language the service happens to be written in.

── No query plans ────────────────────────────────────────────────────────

explain is the highest-value thing this adapter could add — COLLSCAN versus IXSCAN names a cause rather than a symptom. It is absent because Mongo can only explain a filter that still has its values in it, and this file drops values at the moment it sees them. Capturing a plan would mean retaining somebody's query values in order to compose a command out of them. That trade is refused here exactly as it is refused for MySQL.

Defined Under Namespace

Classes: CommandFailed, Subscriber

Constant Summary collapse

IGNORED_COMMANDS =

Commands the driver issues about itself. A handshake is not an operation anybody can act on, and heartbeats would otherwise be the highest-count "query" in every service.

%w[
  ismaster isMaster hello ping buildInfo getnonce authenticate saslStart
  saslContinue logout endSessions getLog hostInfo listDatabases
  connectionStatus getParameter
].freeze
CURSOR_COMMANDS =

Commands that can hand back a cursor rather than an answer.

%w[find aggregate listIndexes listCollections].freeze
ENVELOPE_KEYS =

Keys the driver adds to every command. They describe the session and the topology rather than the query, and including them would make the identity of every operation move with the driver's version.

cursor, batchSize and singleBatch describe how the results are fetched rather than what was asked for, and they are excluded for a sharper reason than tidiness: setting or removing a batch size is precisely the change the round_trips signal exists to report, and an identity that moved with it would re-identify the operation at the moment of the change. The detector would then have nothing to compare — a new operation with no history beside an old one that stopped reporting — and the finding could never fire.

limit is not in this list, and the difference is the point: a limit changes what you asked for.

%w[
  $db lsid txnNumber $clusterTime $readPreference $audit apiVersion
  apiStrict apiDeprecationErrors signature startTransaction autocommit
  readConcern writeConcern comment cursor batchSize singleBatch
].freeze
MAX_IN_FLIGHT =

A command that never completes would otherwise leave its start context behind forever. The cap is per thread and far above any real number of commands in flight on one connection at one time.

64
MAX_OPEN_CURSORS =

And a cursor that is opened and never drained would leave a span open. Past this many, the oldest is emitted with what it has — an incomplete measurement rather than an unbounded one.

128
MAX_CURSOR_AGE_SECONDS =

The same protection in time rather than in count. The Ruby driver kills an abandoned cursor from a finalizer, so breaking out of a loop over a cursor releases nothing until the garbage collector gets to it — which may be never in a process that is not under memory pressure. A cursor nobody has read from in this long is reported with what it has.

60
KEY =
:sixty_mongo_in_flight

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.configObject (readonly)

Returns the value of attribute config.



115
116
117
# File 'lib/sixty/instrument/mongo.rb', line 115

def config
  @config
end

.subscriberObject (readonly)

Returns the value of attribute subscriber.



115
116
117
# File 'lib/sixty/instrument/mongo.rb', line 115

def subscriber
  @subscriber
end

Class Method Details

.batch_length(event) ⇒ Object



313
314
315
316
317
318
319
# File 'lib/sixty/instrument/mongo.rb', line 313

def batch_length(event)
  cursor = reply_of(event)&.[]('cursor')
  return nil unless cursor.is_a?(Hash)

  batch = cursor['firstBatch'] || cursor['nextBatch']
  batch.is_a?(Array) ? batch.length : nil
end

.bytes_from(event) ⇒ Object

Sampled, because serializing a whole reply to measure it would cost more than the query. Precision does not matter: this exists to catch a payload going from 4KB to 2MB.



357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/sixty/instrument/mongo.rb', line 357

def bytes_from(event)
  cursor = reply_of(event)&.[]('cursor')
  batch = cursor.is_a?(Hash) ? (cursor['firstBatch'] || cursor['nextBatch']) : nil
  return nil unless batch.is_a?(Array) && !batch.empty?

  # Three documents rather than five: serializing a BSON document to
  # measure it is the most expensive thing this file does per query, and
  # the number only has to be right to an order of magnitude.
  sample = batch.first(3)
  sampled = sample.sum { |doc| doc.to_bson.length }
  ((sampled.to_f / sample.length) * batch.length).round
rescue StandardError
  nil
end

.close_entry(entry, error) ⇒ Object



221
222
223
224
225
226
227
228
229
230
# File 'lib/sixty/instrument/mongo.rb', line 221

def close_entry(entry, error)
  span = entry[:span]
  span.attrs[:rows] = entry[:rows]
  span.attrs[:round_trips] = entry[:round_trips]
  span.attrs[:bytes] = entry[:bytes] if entry[:bytes].positive?
  Tracer.end_span(span, error)
  Tracer.emit(span)
rescue StandardError
  nil
end

.collection_for(command_name, command, event) ⇒ Object

What the command ran against. For most commands the collection is the value of the command key itself ({find: 'orders', ...}); getMore names a cursor there and carries the collection separately.



272
273
274
275
276
277
278
# File 'lib/sixty/instrument/mongo.rb', line 272

def collection_for(command_name, command, event)
  value = command_name == 'getMore' ? command['collection'] : command[command_name]
  return value if value.is_a?(String) && !value.empty?

  database = event.database_name.to_s
  database.empty? ? 'collection' : database
end

.continue_cursor(cursor_id, event, error) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/sixty/instrument/mongo.rb', line 184

def continue_cursor(cursor_id, event, error)
  entry = cursor_mutex.synchronize { cursors[cursor_id] }
  return false unless entry

  entry[:round_trips] += 1
  entry[:rows] += batch_length(event) || 0
  entry[:bytes] += bytes_from(event) || 0
  entry[:touched_at] = Tracer.monotonic_ms

  if error || exhausted?(event)
    cursor_mutex.synchronize { cursors.delete(cursor_id) }
    close_entry(entry, error)
  end
  true
end

.cursor_id_from_reply(event) ⇒ Object



321
322
323
324
# File 'lib/sixty/instrument/mongo.rb', line 321

def cursor_id_from_reply(event)
  cursor = reply_of(event)&.[]('cursor')
  cursor.is_a?(Hash) ? cursor_id_of(cursor['id']) : nil
end

.cursor_id_of(value) ⇒ Object

BSON::Int64 and Integer both appear here depending on driver version and platform, and they are not eql? — so a map keyed by the raw value would miss on lookup and every cursor would look like a new one.



334
335
336
337
338
339
340
# File 'lib/sixty/instrument/mongo.rb', line 334

def cursor_id_of(value)
  return nil if value.nil?

  value.respond_to?(:value) ? value.value.to_i : value.to_i
rescue StandardError
  nil
end

.cursor_mutexObject



151
152
153
# File 'lib/sixty/instrument/mongo.rb', line 151

def cursor_mutex
  @cursor_mutex ||= Mutex.new
end

.cursorsObject

Cursors whose spans are still open, keyed by the server's cursor id.

Process-wide rather than thread-local: a cursor may be handed to another thread to drain, and a span that could only be closed by the thread that opened it would leak in exactly that case.



147
148
149
# File 'lib/sixty/instrument/mongo.rb', line 147

def cursors
  @cursors ||= {}
end

.describe(event) ⇒ Object

Everything about a command that has to be read before it runs.



235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/sixty/instrument/mongo.rb', line 235

def describe(event)
  command = event.command || {}
  command_name = event.command_name.to_s
  collection = collection_for(command_name, command, event)
  identity = identity_for(command_name, collection, command)

  {
    command_name: command_name,
    name: "#{command_name}:#{collection}",
    identity: identity,
    frames: Stack.capture(identity),
    cursor_id: command_name == 'getMore' ? cursor_id_of(command['getMore']) : nil
  }
end

.exhausted?(event) ⇒ Boolean

Returns:

  • (Boolean)


326
327
328
329
# File 'lib/sixty/instrument/mongo.rb', line 326

def exhausted?(event)
  id = cursor_id_from_reply(event)
  id.nil? || id.zero?
end

.identity_for(command_name, collection, command) ⇒ Object

The identity of the operation: the command, what it ran against, and the structure of its arguments. See Sixty::Shape — no value in the command has a path into this string.



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/sixty/instrument/mongo.rb', line 253

def identity_for(command_name, collection, command)
  parts = []
  # A pipeline is ordered structure, so every stage counts and in order.
  pipeline = command['pipeline'] || command[:pipeline]
  parts << Shape.shape_of_sequence(pipeline) if pipeline.is_a?(Array)

  rest = command.reject do |key, _value|
    name = key.to_s
    ENVELOPE_KEYS.include?(name) || name == command_name || name == 'pipeline'
  end
  shape = Shape.shape_of(rest)
  parts << "{#{shape}}" unless shape.empty?

  "#{command_name} #{collection} #{parts.join(' ')}".strip
end

.install(config = nil) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/sixty/instrument/mongo.rb', line 117

def install(config = nil)
  return false if @installed
  return false unless defined?(::Mongo::Monitoring::Global)

  @config = config
  @subscriber = Subscriber.new
  # Global, so clients created later are covered. Clients that already
  # exist — Mongoid builds its own during boot, which may be before
  # this runs — are subscribed to individually below.
  ::Mongo::Monitoring::Global.subscribe(::Mongo::Monitoring::COMMAND, @subscriber)
  subscribe_existing_clients
  Sixty.before_flush { sweep }
  @installed = true
end

.installed?Boolean

Returns:

  • (Boolean)


132
133
134
# File 'lib/sixty/instrument/mongo.rb', line 132

def installed?
  @installed == true
end

.kill_cursors(ids) ⇒ Object

An abandoned cursor: the application stopped reading and the driver told the server so. The measurement ends where the reading did.



214
215
216
217
218
219
# File 'lib/sixty/instrument/mongo.rb', line 214

def kill_cursors(ids)
  ids.each do |id|
    entry = cursor_mutex.synchronize { cursors.delete(id) }
    close_entry(entry, nil) if entry
  end
end

.number(value) ⇒ Object



372
373
374
# File 'lib/sixty/instrument/mongo.rb', line 372

def number(value)
  value.is_a?(Numeric) ? value.to_i : nil
end

.open_cursor(cursor_id, state, event) ⇒ Object

---------------------------------------------------------------- state



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/sixty/instrument/mongo.rb', line 157

def open_cursor(cursor_id, state, event)
  span = Tracer.start_span(
    kind: Tracer::KIND_DB,
    name: state[:name],
    attrs: { normalized_sql: state[:identity] }
  )
  span.attrs[:frames] = state[:frames] if state[:frames]
  # Back-dated by the command's own duration, so the span covers the
  # round trip that opened the cursor as well as the ones that follow.
  span.start -= event.duration.to_f * 1000.0

  entry = {
    span: span,
    rows: batch_length(event) || 0,
    round_trips: 1,
    bytes: bytes_from(event) || 0,
    touched_at: Tracer.monotonic_ms
  }
  evicted = nil
  cursor_mutex.synchronize do
    cursors[cursor_id] = entry
    evicted = cursors.shift if cursors.size > MAX_OPEN_CURSORS
  end
  close_entry(evicted[1], nil) if evicted
  entry
end

.reply_of(event) ⇒ Object

A failed command has no reply at all — CommandFailed carries a message where CommandSucceeded carries the server's answer. Reading it unguarded is how the first version of this file dropped every failed query: the error reached the application correctly and the operation that produced it was never recorded.



347
348
349
350
351
352
# File 'lib/sixty/instrument/mongo.rb', line 347

def reply_of(event)
  return nil unless event.respond_to?(:reply)

  reply = event.reply
  reply.is_a?(Hash) ? reply : nil
end

.reset!Object

Exported for tests.



137
138
139
140
# File 'lib/sixty/instrument/mongo.rb', line 137

def reset!
  @installed = false
  @cursors = nil
end

.rows_from(event, command_name) ⇒ Object

Documents returned for a read, documents affected for a write — the same meaning rows has for every SQL client in this directory. A rows drift that meant one thing on Postgres and another on Mongo would be a chart nobody can read.



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/sixty/instrument/mongo.rb', line 286

def rows_from(event, command_name)
  batch = batch_length(event)
  return batch if batch

  reply = reply_of(event)
  return nil unless reply

  case command_name
  when 'count', 'countDocuments'
    # A count returns one number, so one document came back. Reporting
    # the count itself would say that counting a million-document
    # collection returned a million documents, and the first collection
    # to grow would look like the regression this product exists to
    # report.
    1
  when 'distinct'
    values = reply['values']
    values.is_a?(Array) ? values.length : 1
  when 'findAndModify'
    reply['value'].nil? ? 0 : 1
  when 'update'
    number(reply['nModified']) || number(reply['n'])
  else
    number(reply['n'])
  end
end

.sweep(max_age: MAX_CURSOR_AGE_SECONDS) ⇒ Object

Cursors nobody is reading any more, reported rather than held. Runs on the agent's flush thread, like every other piece of housekeeping here.



202
203
204
205
206
207
208
209
210
# File 'lib/sixty/instrument/mongo.rb', line 202

def sweep(max_age: MAX_CURSOR_AGE_SECONDS)
  deadline = Tracer.monotonic_ms - (max_age * 1000.0)
  stale = cursor_mutex.synchronize do
    cursors.select { |_id, entry| entry[:touched_at] < deadline }
           .each_key { |id| cursors.delete(id) }
  end
  stale.each_value { |entry| close_entry(entry, nil) }
  stale.size
end