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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7b90bc82ca6cbc6175f936bbe3fae61559f2ed25496038a0606113c6d07d337c
4
+ data.tar.gz: 76a91e28604d7f832e9470cc1232a00eb4a918aeb603ee2d59cbcd8869f6bc58
5
+ SHA512:
6
+ metadata.gz: 1714f467d461bb3dfea330ea8cbebd48b849e5203c859f2d1062a306f620e08b593aa56d4b1f579064eb69f99c433a3d89abf856842182f87d6e306a83baceda
7
+ data.tar.gz: '048abc678da88c8279c84b229918c1130b8cbdbbaa76606b00976e18ef80b41d8dc9574845a96a23418b3ca9f14f5796aa30bf72c88b55eb554a612235a94ee3'
data/README.md ADDED
@@ -0,0 +1,326 @@
1
+ # sixty (Ruby)
2
+
3
+ Zero-configuration performance drift detection for Ruby and Rails services.
4
+
5
+ ```ruby
6
+ # Gemfile
7
+ gem 'sixty'
8
+ ```
9
+
10
+ ```bash
11
+ export SIXTY_API_KEY=sixty_sk_…
12
+ export SIXTY_SERVICE=checkout-api
13
+ export SIXTY_RELEASE=$(git rev-parse --short HEAD)
14
+ ```
15
+
16
+ That is the install. In a Rails application the railtie adds the middleware,
17
+ subscribes to `sql.active_record` and wraps controller actions on boot — there
18
+ is no initializer to write and nothing to call.
19
+
20
+ Outside Rails:
21
+
22
+ ```ruby
23
+ require 'sixty'
24
+ Sixty.init
25
+
26
+ use Sixty::Instrument::Rack # config.ru
27
+ ```
28
+
29
+ ## What it reports
30
+
31
+ Not latency alone. **Shape** — the numbers that barely move on a warm
32
+ development database and take production down a week later:
33
+
34
+ | signal | the question it answers |
35
+ |---|---|
36
+ | rows per call | did this query start returning 30,000 rows instead of 30? |
37
+ | queries per call | did this method start issuing 20 queries instead of 1? |
38
+ | round trips per call | did this read start fetching in three hundred batches? |
39
+ | self time | is *my* code slower, or is something I call slower? |
40
+ | query plan | did this statement stop using its index? |
41
+ | bytes | did the payload go from 4KB to 2MB? |
42
+
43
+ Each is recorded per operation, per release. The collector compares one release
44
+ against the release before it — which is why `SIXTY_RELEASE` matters more than
45
+ any other setting here. Without it there is no "before".
46
+
47
+ ## Which database you use
48
+
49
+ Postgres, MySQL and MongoDB, and nothing to configure for any of them.
50
+
51
+ | client | how it is picked up | rows means | plans |
52
+ |---|---|---|---|
53
+ | ActiveRecord (any adapter) | automatic | `row_count` from the adapter | Postgres only |
54
+ | `pg` | automatic, outside Rails | `ntuples`, or `cmd_tuples` for a write | no |
55
+ | `mysql2` / `trilogy` | automatic, outside Rails | result count, or `affected_rows` | no |
56
+ | `mongo` (and Mongoid) | automatic | documents returned, or affected | no |
57
+
58
+ **ActiveRecord excludes the raw drivers, deliberately.** `sql.active_record` and
59
+ a patched `PG::Connection` see the same query — the adapter runs it through the
60
+ driver — so installing both would count every query in a Rails app twice. A
61
+ doubled `db_calls` is not a visible error; it is a plausible number that makes
62
+ every fanout finding wrong by a factor of two. The subscriber wins wherever
63
+ ActiveRecord is in the process, and the driver patches cover everything else:
64
+ Sinatra, Sequel, ROM, workers, scripts. `SIXTY_INSTRUMENT=active_record,pg`
65
+ overrides that if your app really does issue queries down both paths.
66
+
67
+ Prepared statements are covered on both `pg` and `mysql2`: the text is
68
+ remembered where it is prepared and looked up where it is executed, because
69
+ `statement.execute(id)` carries no SQL and an application that uses them would
70
+ otherwise report no database calls at all.
71
+
72
+ Installation is retried on every flush, because a driver is a constant that may
73
+ not exist yet: `Sixty.init` before `require 'mysql2'` would otherwise report no
74
+ queries for the life of the process, silently.
75
+
76
+ ### What is not covered
77
+
78
+ Stated plainly, because a blind spot nobody wrote down is the failure this
79
+ project exists to prevent:
80
+
81
+ - **`pg`'s asynchronous API** (`send_query` + `get_result`). The synchronous
82
+ family — `exec`, `exec_params`, `exec_prepared`, `async_exec` — is patched;
83
+ pairing a send with a later get is stateful and is not attempted.
84
+ - **Fiber schedulers** (Falcon, the `async` gem). Context is fiber-local, so a
85
+ span opened in one fiber is not visible in a fiber spawned from it: counts and
86
+ durations stay correct, parent/child edges are lost. Thread-per-request
87
+ servers — Puma, Unicorn, Sidekiq — are unaffected.
88
+ - **Query plans outside ActiveRecord**, which needs a connection pool to borrow
89
+ from. See below.
90
+ - **`Sixty.instrument(Klass, :method)`** wraps the methods you name and does not
91
+ follow ones defined later.
92
+
93
+ ### MySQL is read by different rules, not the same ones
94
+
95
+ `"alice@example.com"` is a quoted *identifier* in Postgres — schema, kept, and
96
+ keeping it is what makes `select "userId" from t` readable in the feed — and in
97
+ MySQL's default `sql_mode` the same bytes are a string *literal*, which is
98
+ exactly the PII this agent exists never to transmit. Backticks are the mirror
99
+ image. So the dialect is asked of the adapter rather than guessed from the text,
100
+ and it decides which lexer runs.
101
+
102
+ ### MongoDB has no statement, so identity is the shape
103
+
104
+ There is nothing to strip literals from: a filter is a tree where the values sit
105
+ beside the keys. So the identity is *built* from keys only, and a value has no
106
+ path into it at all:
107
+
108
+ ```
109
+ find orders {filter{user_id},limit,sort{created_at}}
110
+ aggregate orders [$match{status}][$lookup{from}][$unwind]
111
+ insert orders {documents{email,total,user_id}}
112
+ ```
113
+
114
+ `{_id: {$in: [...]}}` with three ids and with three thousand is one operation —
115
+ the same fold `in (?, ?, ?) → in (?)` performs for SQL. A pipeline is the
116
+ opposite case: every stage counts, and in order, because collapsing it would
117
+ hide the `$lookup` that turned one query into an N+1.
118
+
119
+ **A cursor is one operation, however many batches it took.** A `find` returning
120
+ thirty thousand documents at the default batch size is the initial command plus
121
+ roughly three hundred `getMore` round trips, each a network wait. Recording each
122
+ of those as its own operation would tell you your method makes three hundred
123
+ database calls — the signature of an N+1 your code does not contain — and send
124
+ you looking for a loop when the fix is `batch_size`. So the span stays open
125
+ until the cursor is drained: `db_calls` says one call, `rows` says thirty
126
+ thousand, and `round_trips` says three hundred, which is its own finding with
127
+ its own fix.
128
+
129
+ Batch size is deliberately *not* part of the identity, for the same reason:
130
+ setting it is the change the round-trips signal exists to report, and an
131
+ identity that moved with it would leave the detector with nothing to compare.
132
+ `limit` is part of the identity, because a limit changes what you asked for.
133
+
134
+ The instrumentation is the driver's own command-monitoring API rather than a
135
+ monkey patch, which is only possible because the Ruby driver is synchronous:
136
+ events are published on the calling thread, so a query still knows which method
137
+ issued it. (`@sixty-sh/node` cannot do this — in Node those events fire after the
138
+ caller's async context is gone, so it patches the collection instead.)
139
+
140
+ ### Why MySQL and MongoDB get no query plans
141
+
142
+ An index that stopped being used is the highest-value finding this agent could
143
+ produce, and it is still refused for both. Postgres has `EXPLAIN (GENERIC_PLAN)`,
144
+ which plans a statement *without binding a parameter* — there is no step at
145
+ which a value could enter it. MySQL and MongoDB can only explain a query that
146
+ still has its values in it, so capturing a plan there would mean retaining
147
+ somebody's data in order to compose a command out of it. The same refusal, for
148
+ the same reason, in all three agents.
149
+
150
+ Postgres plans are also refused for a statement that arrived carrying literals —
151
+ what an app with prepared statements disabled produces — because this agent
152
+ explains on the flush thread rather than in front of a user, and queueing such a
153
+ statement would mean holding those values until the next flush.
154
+
155
+ ## Your own code
156
+
157
+ Controllers and queries are instrumented automatically. Service objects, query
158
+ objects and jobs — the layer where an N+1 is actually born — are one line:
159
+
160
+ ```ruby
161
+ class OrdersQuery
162
+ include Sixty::Instrumented
163
+
164
+ def for_user(id) = Order.where(user_id: id).limit(30).to_a
165
+ def enrich(orders) = ...
166
+ end
167
+ ```
168
+
169
+ Every public instance method becomes an operation, with its queries and rows
170
+ attributed to it. Private methods are left alone, accessors are skipped, and
171
+ arguments, keyword arguments, blocks, return values, raised exceptions and
172
+ method visibility pass through unchanged — there is a test for each of those,
173
+ because an instrumentation layer that changes program behaviour is unshippable.
174
+
175
+ For a class you do not own:
176
+
177
+ ```ruby
178
+ Sixty.instrument(Stripe::Charge, :create)
179
+ ```
180
+
181
+ And for anything else:
182
+
183
+ ```ruby
184
+ Sixty.trace('nightly-reconciliation') { ... }
185
+ Sixty.annotate(:rows, results.length)
186
+ ```
187
+
188
+ ## What it costs
189
+
190
+ Measured, not asserted:
191
+
192
+ | | overhead |
193
+ |---|---|
194
+ | a traced method (`Sixty::Instrumented`) | **~3.5µs** per call |
195
+ | a query, recorded and rolled up | **~4µs** |
196
+ | `pg` query, end to end against a real server | **+12–16µs** |
197
+ | `mysql2` / `trilogy` query | **+13–14µs** |
198
+ | `mongo` command | **+27–30µs** |
199
+
200
+ The per-call number is asserted by `test/safety_test.rb`, which fails if it
201
+ becomes milliseconds. The driver numbers are paired interleaved A/B samples
202
+ against live servers — every measurement is (one query without the agent, one
203
+ with, back to back), reported as the median difference, with an A/A control run
204
+ to prove the harness has no bias of its own. Against a local query that takes
205
+ 300–500µs, that is a few percent; against a Rails request it was not
206
+ measurable — 731 rps without the agent and 797 with it, i.e. inside the noise.
207
+
208
+ Two of those numbers started five times worse, which is the reason the
209
+ benchmark exists at all: naming an operation (`select:orders`) rebuilt regular
210
+ expressions on every query, and estimating a result's size rendered five rows to
211
+ strings. Both are now computed once per statement and read from libpq
212
+ respectively.
213
+
214
+ Nothing else happens on the request path. Spans go into an in-process rollup
215
+ capped at 2,000 operations; a background thread posts a window every 15 seconds.
216
+ A stack trace is captured once per query, ever, because a call site is a
217
+ property of the statement rather than of the call. `EXPLAIN` runs on the flush
218
+ thread on a connection of its own, never in front of a user, and never
219
+ `EXPLAIN ANALYZE`.
220
+
221
+ ## What happens when the collector is down
222
+
223
+ Nothing, to your application:
224
+
225
+ - the request path never opens a socket to the collector
226
+ - a failed flush is swallowed, logged at most once a minute, and backs off
227
+ exponentially to a five-minute ceiling
228
+ - the undeliverable window is **dropped**, so the agent's memory is bounded by
229
+ your application's shape rather than by somebody else's uptime
230
+ - shutdown waits at most two seconds for a final flush
231
+ - an exception anywhere inside the agent is caught before it reaches your code —
232
+ the Rack middleware is written so that the only unguarded line in it is
233
+ `@app.call(env)`
234
+
235
+ `test/safety_test.rb` asserts all of the above, including that a deliberately
236
+ broken agent still returns 200.
237
+
238
+ ## What never leaves the process
239
+
240
+ Raw SQL does not, and neither does a Mongo filter's contents. Statements are reduced to their literal-free shape before
241
+ anything is recorded (`select * from users where email = ?`), which is also what
242
+ keeps `where id = 1` and `where id = 99` from becoming two operations. Query
243
+ plans come from `EXPLAIN (GENERIC_PLAN)`, which plans a parameterised statement
244
+ without ever binding a parameter, so there is no step at which a value could
245
+ enter one. Errors carry their class and message, never their arguments or
246
+ backtrace.
247
+
248
+ Every one of those claims is asserted against a live server rather than a
249
+ fixture: `test/integration` runs the same checks against Postgres, MySQL and
250
+ MongoDB, including "no value from a real query reaches the payload".
251
+
252
+ ## Configuration
253
+
254
+ Every setting is an environment variable, and `DRIFT_*` still answers everywhere
255
+ `SIXTY_*` does.
256
+
257
+ | variable | default |
258
+ |---|---|
259
+ | `SIXTY_API_KEY` | — (without it the agent stays inactive and says so) |
260
+ | `SIXTY_ENDPOINT` | `http://localhost:4319` |
261
+ | `SIXTY_SERVICE` | the Rails application's name |
262
+ | `SIXTY_ENV` | `Rails.env` |
263
+ | `SIXTY_RELEASE` | a git SHA from the platform's own variables, if it sets one |
264
+ | `SIXTY_FLUSH_MS` | `15000` |
265
+ | `SIXTY_SAMPLE_RATE` | `0.05` (retained exemplar traces, not measurement) |
266
+ | `SIXTY_SLOW_TRACE_MS` | `1000` |
267
+ | `SIXTY_CAPTURE_PLANS` | on; `0` disables `EXPLAIN` |
268
+ | `SIXTY_DEBUG` | `1` prints what the agent decided at boot |
269
+
270
+ In code, through the railtie:
271
+
272
+ ```ruby
273
+ config.sixty.sample_rate = 0.2
274
+ config.sixty.ignore_paths = [%r{\A/internal/}]
275
+ ```
276
+
277
+ ## Compatibility
278
+
279
+ Ruby 2.7+. Rails 6.1+ for the automatic install; Rack alone is enough for the
280
+ manual one. Postgres via `pg`, MySQL via `mysql2` or `trilogy`, MongoDB via the
281
+ `mongo` driver (which is what Mongoid uses). No runtime dependencies — this gem is loaded into other people's
282
+ production processes, and every dependency it took would be a version conflict
283
+ it could cause in an application that has nothing to do with observability.
284
+
285
+ ## Tests
286
+
287
+ ```bash
288
+ bundle install
289
+ rake test
290
+ ```
291
+
292
+ Integration tests skip when a database is not running; `docker compose` in the
293
+ repository root starts Postgres, and MySQL and MongoDB need one container each:
294
+
295
+ ```bash
296
+ docker run -d -p 3307:3306 -e MYSQL_ROOT_PASSWORD=drift -e MYSQL_DATABASE=sixty_test \
297
+ -e MYSQL_USER=drift -e MYSQL_PASSWORD=drift mysql:8
298
+ docker run -d -p 27018:27017 mongo:7
299
+ ```
300
+
301
+ CI starts all three as services and fails if any test reports a skip, so
302
+ "skipped" can never quietly become "never run".
303
+
304
+ The sketch and SQL suites assert against fixtures generated by the JavaScript
305
+ agent (`node test/fixtures/generate.mjs`) rather than against themselves. A
306
+ codec bug that is consistent between a Ruby writer and a Ruby reader passes
307
+ every roundtrip test there is; the collector decodes these bytes with the
308
+ JavaScript implementation, so that is what the expectations come from.
309
+
310
+ A running example lives in [`apps/demo-rails`](../../apps/demo-rails).
311
+
312
+ ## Releasing
313
+
314
+ Bump `Sixty::VERSION` in `lib/sixty/version.rb` and merge to main. CI publishes
315
+ the version to RubyGems if it is not already there, so a merge that does not
316
+ change the version is a no-op rather than a failed build.
317
+
318
+ Authentication is [trusted publishing][tp] rather than an API key: the gemspec
319
+ sets `rubygems_mfa_required`, which is incompatible with an unattended `gem
320
+ push` — deliberately, for a package that loads into other people's production
321
+ processes. GitHub mints a short-lived OIDC token for this repository and this
322
+ workflow instead, so there is no secret to leak or rotate. It is configured once
323
+ per gem at `rubygems.org/gems/sixty/trusted_publishers`, and until it is, the
324
+ publish job fails on the credentials step.
325
+
326
+ [tp]: https://guides.rubygems.org/trusted-publishing/
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sketch'
4
+ require_relative 'tracer'
5
+
6
+ module Sixty
7
+ # In-process rollup.
8
+ #
9
+ # Instead of shipping one record per call, the agent keeps sketches keyed by
10
+ # operation and flushes them on an interval. A service doing 50k req/s across
11
+ # 400 distinct operations sends 400 rows per flush, not 50k per second.
12
+ #
13
+ # Cardinality is capped here as well as on the collector. Once MAX_OPERATIONS
14
+ # distinct operations are seen in a window, further new ones collapse into a
15
+ # single `__overflow__` bucket and a warning is emitted once. Silently
16
+ # dropping them would make the numbers quietly wrong; unbounded growth would
17
+ # take the host down. Overflow is visible and bounded.
18
+ #
19
+ # ── Why this is locked and the JavaScript one is not ──────────────────────
20
+ #
21
+ # Node's agent runs on one thread. A Rails app under Puma runs the request
22
+ # path on many, and every one of them records into this object — so a mutex is
23
+ # not defensive programming here, it is the difference between a correct
24
+ # counter and a torn one. It is held for the duration of a hash update and a
25
+ # sketch insertion, both of which are microseconds; the flush swaps the maps
26
+ # under the same lock and serializes outside it.
27
+ class Aggregator
28
+ MAX_OPERATIONS = 2000
29
+ MAX_EDGES = 5000
30
+
31
+ def initialize(alpha: 0.01, on_warn: ->(_msg) {})
32
+ @alpha = alpha
33
+ @on_warn = on_warn
34
+ @ops = {}
35
+ @edges = {}
36
+ @overflowed = false
37
+ @window_start = now_ms
38
+ @mutex = Mutex.new
39
+ end
40
+
41
+ def record(span)
42
+ @mutex.synchronize { record_locked(span) }
43
+ end
44
+
45
+ # Serialize and reset. Returns nil when there is nothing to send.
46
+ def drain
47
+ ops, edges, window_start = @mutex.synchronize do
48
+ return nil if @ops.empty?
49
+
50
+ taken = [@ops, @edges, @window_start]
51
+ @ops = {}
52
+ @edges = {}
53
+ @overflowed = false
54
+ @window_start = now_ms
55
+ taken
56
+ end
57
+
58
+ {
59
+ windowStart: window_start,
60
+ windowEnd: now_ms,
61
+ operations: ops.map { |key, op| serialize_operation(key, op) },
62
+ edges: edges.map { |key, edge| serialize_edge(key, edge) }
63
+ }
64
+ end
65
+
66
+ # Stable identity for an operation within one agent window.
67
+ #
68
+ # A db operation is identified by its statement, never by its label: the
69
+ # label is presentation and will keep improving, and folding it into the key
70
+ # would re-identify the operation — orphaning its history — every time
71
+ # somebody improves the naming.
72
+ def self.span_key(span)
73
+ if span.kind == Tracer::KIND_DB
74
+ "db\x01#{span.attrs[:normalized_sql] || span.name}"
75
+ else
76
+ "#{span.kind}\x01#{span.name}"
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def record_locked(span)
83
+ key = self.class.span_key(span)
84
+ op = operation_for(key, span)
85
+
86
+ op[:count] += 1
87
+ op[:duration].add(span.duration)
88
+ op[:self].add(Tracer.self_time(span))
89
+
90
+ if span.error
91
+ op[:errors] += 1
92
+ signature = "#{span.error[:type]}: #{span.error[:message]}"[0, 200]
93
+ op[:error_samples][signature] = (op[:error_samples][signature] || 0) + 1
94
+ end
95
+
96
+ # rows: on a db span it is what the query returned; on anything else it is
97
+ # everything its descendants pulled back.
98
+ rows = span.kind == Tracer::KIND_DB ? span.attrs[:rows] : span.db_rows
99
+ if rows.is_a?(Numeric)
100
+ op[:rows].add(rows)
101
+ op[:rows_sum] += rows
102
+ end
103
+
104
+ unless span.kind == Tracer::KIND_DB
105
+ op[:db_calls_sum] += span.db_calls
106
+ op[:db_calls_max] = span.db_calls if span.db_calls > op[:db_calls_max]
107
+ end
108
+
109
+ if span.attrs[:bytes].is_a?(Numeric)
110
+ op[:bytes].add(span.attrs[:bytes])
111
+ op[:bytes_sum] += span.attrs[:bytes]
112
+ end
113
+
114
+ # Network round trips, reported only by a client that can make more than
115
+ # one per operation — today that is MongoDB, where a cursor fetches in
116
+ # batches. A sum rather than a sketch, like db_calls: what matters is
117
+ # round trips *per call*, and a distribution of a number that is 1 almost
118
+ # everywhere would cost a blob per operation to say so.
119
+ #
120
+ # Absent on every other client, which is what keeps this from claiming a
121
+ # Postgres query makes one round trip — it may well not, and this agent
122
+ # cannot see it.
123
+ op[:round_trips_sum] += span.attrs[:round_trips] if span.attrs[:round_trips].is_a?(Numeric)
124
+
125
+ # A plan that arrives after the operation was first recorded still belongs
126
+ # to it, so it is accepted whenever it turns up rather than only on
127
+ # creation. EXPLAIN runs after the span was emitted (see plans.rb), so the
128
+ # first execution of a query never carries one.
129
+ op[:plan] ||= plan_for(span)
130
+
131
+ record_edge(span.parent, span) if span.parent
132
+ end
133
+
134
+ def operation_for(key, span)
135
+ existing = @ops[key]
136
+ return existing if existing
137
+
138
+ if @ops.size >= MAX_OPERATIONS
139
+ unless @overflowed
140
+ @overflowed = true
141
+ @on_warn.call(
142
+ "sixty: operation cardinality cap (#{MAX_OPERATIONS}) reached; further " \
143
+ 'operations are grouped as __overflow__. This usually means a route or ' \
144
+ 'query is not being normalized.'
145
+ )
146
+ end
147
+ key = '__overflow__'
148
+ existing = @ops[key]
149
+ return existing if existing
150
+
151
+ return @ops[key] = blank_operation(kind: Tracer::KIND_FUNCTION, name: '__overflow__')
152
+ end
153
+
154
+ @ops[key] = blank_operation(
155
+ kind: span.kind,
156
+ name: span.name,
157
+ normalized_sql: span.attrs[:normalized_sql],
158
+ source_file: span.attrs[:file],
159
+ source_line: span.attrs[:line],
160
+ # Captured on the first sighting only, so later spans carry nothing.
161
+ frames: span.attrs[:frames],
162
+ plan: plan_for(span)
163
+ )
164
+ end
165
+
166
+ def blank_operation(kind:, name:, normalized_sql: nil, source_file: nil,
167
+ source_line: nil, frames: nil, plan: nil)
168
+ {
169
+ kind: kind,
170
+ name: name,
171
+ normalized_sql: normalized_sql,
172
+ source_file: source_file,
173
+ source_line: source_line,
174
+ frames: frames,
175
+ plan: plan,
176
+ count: 0,
177
+ errors: 0,
178
+ duration: Sketch.new(@alpha),
179
+ self: Sketch.new(@alpha),
180
+ rows: Sketch.new(@alpha),
181
+ rows_sum: 0,
182
+ db_calls_sum: 0,
183
+ db_calls_max: 0,
184
+ bytes: Sketch.new(@alpha),
185
+ bytes_sum: 0,
186
+ round_trips_sum: 0,
187
+ error_samples: {}
188
+ }
189
+ end
190
+
191
+ def record_edge(parent, child)
192
+ key = "#{self.class.span_key(parent)}\x00#{self.class.span_key(child)}"
193
+ edge = @edges[key]
194
+ unless edge
195
+ return if @edges.size >= MAX_EDGES
196
+
197
+ edge = { count: 0, duration: Sketch.new(@alpha), rows_sum: 0 }
198
+ @edges[key] = edge
199
+ end
200
+ edge[:count] += 1
201
+ edge[:duration].add(child.duration)
202
+ edge[:rows_sum] += child.attrs[:rows] if child.attrs[:rows].is_a?(Numeric)
203
+ end
204
+
205
+ def plan_for(span)
206
+ return nil unless span.kind == Tracer::KIND_DB
207
+
208
+ key = span.attrs[:normalized_sql]
209
+ key ? Sixty::Plans.get(key) : nil
210
+ end
211
+
212
+ def serialize_operation(key, op)
213
+ {
214
+ key: key,
215
+ kind: op[:kind],
216
+ name: op[:name],
217
+ normalizedSql: op[:normalized_sql],
218
+ sourceFile: op[:source_file],
219
+ sourceLine: op[:source_line],
220
+ frames: op[:frames],
221
+ plan: op[:plan],
222
+ count: op[:count],
223
+ errors: op[:errors],
224
+ durationSketch: op[:duration].to_base64,
225
+ selfSketch: op[:self].to_base64,
226
+ # Null rather than an empty sketch: a counter has no distribution, and
227
+ # an empty one would be indistinguishable from "measured, all zero".
228
+ rowsSketch: op[:rows].count.positive? ? op[:rows].to_base64 : nil,
229
+ rowsSum: op[:rows_sum],
230
+ dbCallsSum: op[:db_calls_sum],
231
+ dbCallsMax: op[:db_calls_max],
232
+ bytesSketch: op[:bytes].count.positive? ? op[:bytes].to_base64 : nil,
233
+ bytesSum: op[:bytes_sum],
234
+ roundTripsSum: op[:round_trips_sum],
235
+ errorSamples: op[:error_samples]
236
+ .sort_by { |_message, count| -count }
237
+ .first(5)
238
+ .map { |message, count| { message: message, count: count } }
239
+ }
240
+ end
241
+
242
+ def serialize_edge(key, edge)
243
+ parent_key, child_key = key.split("\x00")
244
+ {
245
+ parentKey: parent_key,
246
+ childKey: child_key,
247
+ count: edge[:count],
248
+ durationSketch: edge[:duration].to_base64,
249
+ rowsSum: edge[:rows_sum]
250
+ }
251
+ end
252
+
253
+ def now_ms
254
+ (Time.now.to_f * 1000).round
255
+ end
256
+ end
257
+ end