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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Sixty
6
+ # Span lifecycle and context propagation.
7
+ #
8
+ # The important property, and it is the same one the Node agent has: EVERY
9
+ # call is measured, but only a few are *transmitted* as traces. Spans are
10
+ # cheap objects that feed a local aggregator; full span trees are serialized
11
+ # only for sampled traces, errors and latency outliers. That is what keeps
12
+ # both agent overhead and ingest cost proportional to cardinality rather than
13
+ # to traffic.
14
+ #
15
+ # Attribution rules, which are the whole reason this file exists:
16
+ # self time — duration minus the sum of direct children. "Did MY code get
17
+ # slower, or did something I called get slower?"
18
+ # db calls — every descendant db span, credited to every ancestor. A method
19
+ # going from 3 to 47 queries is an N+1 being born, and it has to
20
+ # be visible on the method, not only on the query.
21
+ # rows — the same transitive credit. This is the 30 -> 30,000 signal.
22
+ #
23
+ # ── How "the current span" is tracked ─────────────────────────────────────
24
+ #
25
+ # `Thread.current[]` is fiber-local storage, which is the closest thing Ruby
26
+ # has to Node's AsyncLocalStorage. Under Puma, Unicorn and Sidekiq — a thread
27
+ # or a process per unit of work — it is exactly right. Under a fiber
28
+ # scheduler (Falcon, async gem) each fiber starts with an empty context
29
+ # instead of inheriting its parent's, so a query issued inside a spawned fiber
30
+ # is recorded with correct timings but no parent. The counts stay right; the
31
+ # edge is lost. That trade is stated here rather than discovered later, and it
32
+ # is the same shape of limitation core's synchronous context has in browsers.
33
+ module Tracer
34
+ KIND_HTTP = 'http'
35
+ KIND_FUNCTION = 'function'
36
+ KIND_DB = 'db'
37
+
38
+ # A single pathological request — the N+1 this product exists to catch — can
39
+ # emit tens of thousands of spans. Aggregates must still count every one of
40
+ # them, but the retained tree is capped so one bad request cannot exhaust
41
+ # memory.
42
+ MAX_SPANS_PER_TRACE = 500
43
+
44
+ KEY = :sixty_span
45
+
46
+ # A plain class rather than a keyword-initialized Struct, and that is a
47
+ # measured decision: this object is allocated on every instrumented call in
48
+ # the application, and building it from a keyword hash costs several
49
+ # microseconds per span — enough to show up in a benchmark of a method that
50
+ # does nothing. Assigning ivars directly is the cheapest thing Ruby offers.
51
+ class Span
52
+ attr_accessor :id, :trace_id, :parent_id, :parent, :kind, :name, :attrs,
53
+ :start, :start_wall, :duration, :child_duration, :db_calls,
54
+ :db_rows, :error, :depth, :children, :root, :span_count,
55
+ :truncated, :recording
56
+
57
+ def initialize(kind, name, attrs, parent)
58
+ @kind = kind
59
+ @name = name
60
+ @attrs = attrs
61
+ @parent = parent
62
+ @duration = nil
63
+ @child_duration = 0.0
64
+ @db_calls = 0
65
+ @db_rows = 0
66
+ @error = nil
67
+ @children = []
68
+ @span_count = 0
69
+ @truncated = false
70
+ @recording = false
71
+ end
72
+ end
73
+
74
+ class << self
75
+ attr_accessor :sink
76
+
77
+ def current
78
+ Thread.current[KEY]
79
+ end
80
+
81
+ def current=(span)
82
+ Thread.current[KEY] = span
83
+ end
84
+
85
+ def start_span(kind:, name:, attrs: nil)
86
+ parent = current
87
+ span = Span.new(kind, name, attrs || {}, parent)
88
+ span.id = next_id
89
+ span.start = monotonic_ms
90
+
91
+ if parent
92
+ span.trace_id = parent.trace_id
93
+ span.parent_id = parent.id
94
+ span.depth = parent.depth + 1
95
+ span.root = parent.root
96
+ else
97
+ # Only a root pays for entropy and for a wall clock. Child spans are
98
+ # never looked up by time or id outside the tree they belong to, and
99
+ # this runs on every instrumented call in the application.
100
+ span.trace_id = SecureRandom.hex(16)
101
+ span.parent_id = nil
102
+ span.depth = 0
103
+ span.root = span
104
+ span.start_wall = (Time.now.to_f * 1000).round
105
+ end
106
+
107
+ # Children are always collected: whether a trace is worth keeping is
108
+ # only knowable once it finishes (did it raise? was it slow?), and a
109
+ # tree cannot be rebuilt retroactively.
110
+ root = span.root
111
+ root.span_count += 1
112
+ root.truncated = true if root.span_count > MAX_SPANS_PER_TRACE
113
+ span
114
+ end
115
+
116
+ def end_span(span, error = nil, duration: nil)
117
+ return span if span.duration # already ended; guard double-finish
118
+
119
+ span.duration = duration || (monotonic_ms - span.start)
120
+ if error
121
+ span.error = {
122
+ # Class name and message only. A backtrace can contain file paths
123
+ # and interpolated values; we take the message but never the args.
124
+ type: error.class.name.to_s[0, 200],
125
+ message: error.message.to_s[0, 500]
126
+ }
127
+ end
128
+
129
+ parent = span.parent
130
+ if parent
131
+ parent.child_duration += span.duration
132
+ parent.children << span unless span.root.truncated
133
+ end
134
+
135
+ # Credit db work to every ancestor, not only the immediate parent.
136
+ if span.kind == KIND_DB
137
+ rows = span.attrs[:rows].is_a?(Numeric) ? span.attrs[:rows] : 0
138
+ ancestor = span.parent
139
+ while ancestor
140
+ ancestor.db_calls += 1
141
+ ancestor.db_rows += rows
142
+ ancestor = ancestor.parent
143
+ end
144
+ end
145
+
146
+ span
147
+ end
148
+
149
+ def self_time(span)
150
+ [0.0, span.duration.to_f - span.child_duration.to_f].max
151
+ end
152
+
153
+ # Run a block with `span` as the active context, ending and emitting it
154
+ # however the block leaves — returned value, raised error, or `throw`.
155
+ def in_span(span)
156
+ previous = current
157
+ self.current = span
158
+ begin
159
+ result = yield
160
+ rescue Exception => e # rubocop:disable Lint/RescueException
161
+ # Exception, not StandardError: a Timeout::Error or an Interrupt still
162
+ # ended this span, and losing the measurement is the smaller problem
163
+ # than leaving the context pointing at a span that never closed.
164
+ self.current = previous
165
+ end_span(span, e)
166
+ emit(span)
167
+ raise
168
+ end
169
+ self.current = previous
170
+ end_span(span)
171
+ emit(span)
172
+ result
173
+ end
174
+
175
+ # Record a span that has already happened.
176
+ #
177
+ # ActiveSupport::Notifications hands an event to its subscriber *after*
178
+ # the work finished, with its own start and finish times — so there is
179
+ # nothing to run a block around. The span is assembled with those times
180
+ # and parented to whatever is current, which is correct because the
181
+ # subscriber runs synchronously on the same thread as the query it
182
+ # describes.
183
+ def record(kind:, name:, duration_ms:, attrs: {}, start_wall: nil, error: nil)
184
+ span = start_span(kind: kind, name: name, attrs: attrs)
185
+ span.start_wall = start_wall if start_wall
186
+ end_span(span, error, duration: duration_ms)
187
+ emit(span)
188
+ span
189
+ end
190
+
191
+ # Handing a span to the sink can never fail the code being measured.
192
+ #
193
+ # This runs inside somebody's request path — between their query returning
194
+ # and their controller resuming — so a raise here does not lose a
195
+ # measurement, it fails their request. `Sixty.on_span_end` rescues and
196
+ # warns, which is where an agent bug should be noticed; this is the floor
197
+ # underneath that, for a sink installed by anything else. Losing one span
198
+ # is recoverable and invisible. Breaking the host application is neither.
199
+ def emit(span)
200
+ handler = @sink
201
+ handler&.call(span)
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ def monotonic_ms
207
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
208
+ end
209
+
210
+ private
211
+
212
+ # A counter, not entropy. A span id only has to be unique inside the trace
213
+ # it belongs to — the trace id carries the randomness — and this is called
214
+ # on every instrumented call, where a call into the CSPRNG would be the
215
+ # single most expensive thing about tracing a method that does nothing.
216
+ def next_id
217
+ @seq = (@seq || 0) + 1
218
+ @seq.to_s(36)
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sixty
4
+ VERSION = '0.1.0'
5
+ end
data/lib/sixty.rb ADDED
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sixty/version'
4
+ require_relative 'sixty/config'
5
+ require_relative 'sixty/sketch'
6
+ require_relative 'sixty/sql'
7
+ require_relative 'sixty/stack'
8
+ require_relative 'sixty/plans'
9
+ require_relative 'sixty/tracer'
10
+ require_relative 'sixty/aggregator'
11
+ require_relative 'sixty/exporter'
12
+ require_relative 'sixty/shape'
13
+ require_relative 'sixty/instrumented'
14
+
15
+ # sixty — zero-configuration performance drift detection, for Ruby.
16
+ #
17
+ # require 'sixty'
18
+ # Sixty.init # reads SIXTY_API_KEY, SIXTY_SERVICE, SIXTY_RELEASE from ENV
19
+ #
20
+ # In a Rails application the railtie calls this for you and installs the Rack
21
+ # middleware, the ActiveRecord subscriber and the controller instrumentation, so
22
+ # the only line an application needs is the gem in its Gemfile.
23
+ #
24
+ # What it reports is deliberately not latency alone. It is *shape*: how many
25
+ # rows a query returned, how many queries a method issued, how much of a
26
+ # request was spent in your own code rather than below it. Those are the numbers
27
+ # that barely move on a warm development database and take production down a
28
+ # week later.
29
+ module Sixty
30
+ class << self
31
+ attr_reader :config, :aggregator, :exporter
32
+
33
+ def init(options = {})
34
+ return @state if @state
35
+
36
+ config = Config.new(options)
37
+ @config = config
38
+
39
+ unless config.active?
40
+ config.on_warn.call(
41
+ 'sixty: no API key found (set SIXTY_API_KEY or pass api_key:). Agent is inactive.'
42
+ )
43
+ return nil
44
+ end
45
+
46
+ Stack.root = rails_root || Dir.pwd
47
+
48
+ @aggregator = Aggregator.new(on_warn: config.on_warn)
49
+ @exporter = Exporter.new(
50
+ endpoint: config.endpoint,
51
+ api_key: config.api_key,
52
+ service: config.service,
53
+ environment: config.environment,
54
+ release: config.release,
55
+ repo_url: config.repo_url,
56
+ on_warn: config.on_warn
57
+ )
58
+
59
+ Tracer.sink = method(:on_span_end)
60
+ @enabled = true
61
+ @flush_hooks = []
62
+ start_flusher
63
+
64
+ install_database_instrumentation(config)
65
+ # Retried on every flush, because a driver is a constant that may not
66
+ # exist yet. `Sixty.init` in a Sinatra app can easily run before
67
+ # `require 'mysql2'`, and an agent that decided once at boot would report
68
+ # no queries at all for the rest of the process — silently, which is the
69
+ # failure this project refuses. Every installer is idempotent, so the
70
+ # retry costs a few `defined?` checks a minute.
71
+ before_flush { install_database_instrumentation(config) }
72
+
73
+ # Rack and ActionController install themselves through the railtie when
74
+ # Rails is present. A plain Rack or Sinatra app calls
75
+ # Sixty::Instrument::Rack directly, which is why neither is here.
76
+ #
77
+ # The last window is worth one short attempt and no more. A process being
78
+ # asked to stop is often a deploy waiting on it, and an agent that can add
79
+ # ten seconds to every shutdown because a collector is unreachable is an
80
+ # agent that will be removed — correctly.
81
+ at_exit { flush(timeout: 2) }
82
+
83
+ @state = { config: config, aggregator: @aggregator, exporter: @exporter }
84
+ if config.debug
85
+ config.on_warn.call(
86
+ "sixty: active — service=#{config.service} env=#{config.environment} " \
87
+ "release=#{config.release.empty? ? '(none)' : config.release} " \
88
+ "endpoint=#{config.endpoint}"
89
+ )
90
+ end
91
+ @state
92
+ end
93
+
94
+ def enabled?
95
+ @enabled == true
96
+ end
97
+
98
+ # Measure a block as one operation.
99
+ #
100
+ # The escape hatch for code the automatic instrumentation cannot see, and
101
+ # the building block `Sixty::Instrumented` uses. `name` is an identity that
102
+ # will be compared across releases, so it must not contain anything that
103
+ # varies per call — an id in a name mints an operation per id and blows the
104
+ # cardinality cap.
105
+ def trace(name, kind: Tracer::KIND_FUNCTION, attrs: nil)
106
+ return yield unless enabled?
107
+
108
+ span = Tracer.start_span(kind: kind, name: name, attrs: attrs)
109
+ Tracer.in_span(span) { yield }
110
+ end
111
+
112
+ # Record a value observed inside the current operation — e.g. the size of a
113
+ # result the caller cares about. Attaches to the active span.
114
+ def annotate(key, value)
115
+ span = Tracer.current
116
+ span.attrs[key] = value if span
117
+ end
118
+
119
+ # Upgrade the current request's span to a framework-supplied route pattern.
120
+ # A real route always beats the path heuristic: without it every
121
+ # /users/42 is its own operation.
122
+ def set_route(route)
123
+ span = Tracer.current
124
+ return unless span && route.is_a?(String) && !route.empty?
125
+
126
+ root = span.root
127
+ return unless root && root.kind == Tracer::KIND_HTTP
128
+
129
+ root.name = "#{root.attrs[:method]} #{route}"
130
+ end
131
+
132
+ # Called on every completed span. Everything downstream of here is the
133
+ # agent's own work, so it is wrapped: an agent bug must never surface as an
134
+ # application error.
135
+ def on_span_end(span)
136
+ ensure_flusher
137
+ aggregator.record(span)
138
+ keep_exemplar(span)
139
+ rescue StandardError => e
140
+ config.on_warn.call("sixty: internal error recording span: #{e.message}")
141
+ end
142
+
143
+ # Flush now, from whatever thread asks. Used by the interval thread, by
144
+ # at_exit, and by tests that cannot wait fifteen seconds.
145
+ def flush(timeout: Exporter::TIMEOUT_SECONDS)
146
+ return unless enabled?
147
+
148
+ run_flush_hooks
149
+ exporter.flush(aggregator.drain, timeout: timeout)
150
+ rescue StandardError => e
151
+ config&.on_warn&.call("sixty: flush failed: #{e.message}")
152
+ end
153
+
154
+ # Work that should happen on the agent's thread, just before a flush.
155
+ #
156
+ # The ActiveRecord instrumentation registers the EXPLAIN pass here: it needs
157
+ # a database connection and must never run on the request path, and this is
158
+ # the one thread in the process that satisfies both.
159
+ def before_flush(&block)
160
+ (@flush_hooks ||= []) << block
161
+ end
162
+
163
+ # Test seam. Forgets everything and stops the flush thread.
164
+ def reset!
165
+ @flusher&.kill
166
+ @flusher = nil
167
+ @state = nil
168
+ @enabled = false
169
+ @flush_hooks = []
170
+ Tracer.sink = nil
171
+ Tracer.current = nil
172
+ Stack.reset!
173
+ Plans.reset!
174
+ end
175
+
176
+ private
177
+
178
+ # Which database clients to measure, and — the part that matters — which
179
+ # ones not to.
180
+ #
181
+ # ── Why ActiveRecord excludes the raw drivers ─────────────────────────────
182
+ #
183
+ # `sql.active_record` and a patched `PG::Connection` see the *same query*:
184
+ # the adapter runs it through the driver. Installing both would count every
185
+ # query in a Rails app twice, and a doubled `db_calls` is not a visible
186
+ # error — it is a plausible-looking number that makes every fanout finding
187
+ # wrong by a factor of two.
188
+ #
189
+ # So the notification subscriber wins wherever ActiveRecord exists, because
190
+ # it is public API, knows the dialect, and reports the row count the adapter
191
+ # already has. The driver patches are for everything else — Sinatra, Sequel,
192
+ # ROM, a worker, a script — and they are chosen by whether the ORM is in the
193
+ # process at all, not by whether it happens to be loaded yet, so the answer
194
+ # cannot change halfway through boot.
195
+ #
196
+ # `config.instrument` overrides all of it: an application that really does
197
+ # issue queries outside ActiveRecord can ask for both and accept the
198
+ # double count on the queries that go through the adapter.
199
+ def install_database_instrumentation(config)
200
+ require_relative 'sixty/instrument/active_record'
201
+ require_relative 'sixty/instrument/pg'
202
+ require_relative 'sixty/instrument/mysql'
203
+ require_relative 'sixty/instrument/mongo'
204
+
205
+ wanted = config.instrument
206
+ active_record = defined?(::ActiveRecord)
207
+
208
+ if wanted.nil? ? active_record : wanted.include?(:active_record)
209
+ # In a Rails app the railtie installs this on `active_record` load
210
+ # instead, because referencing ActiveRecord::Base here would eagerly
211
+ # load the ORM — and open a connection — in processes that never needed
212
+ # one. This covers Sinatra and friends, where nothing else will.
213
+ Instrument::ActiveRecord.install(config) if defined?(::ActiveRecord::Base)
214
+ end
215
+
216
+ raw_drivers = wanted.nil? ? !active_record : true
217
+ if raw_drivers
218
+ Instrument::Pg.install(config) if wanted.nil? || wanted.include?(:pg)
219
+ Instrument::Mysql.install(config) if wanted.nil? || wanted.include?(:mysql)
220
+ end
221
+
222
+ # Mongo is never a double count: no ORM in this list speaks to it, and
223
+ # Mongoid drives the very same driver the subscription is attached to.
224
+ Instrument::Mongo.install(config) if wanted.nil? || wanted.include?(:mongo)
225
+ rescue StandardError => e
226
+ config.on_warn.call("sixty: could not install database instrumentation: #{e.message}")
227
+ end
228
+
229
+ def rails_root
230
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
231
+
232
+ ::Rails.root.to_s
233
+ end
234
+
235
+ # Keep the full span tree when it is worth keeping. Aggregates already cover
236
+ # "what happened"; exemplars exist to answer "show me one".
237
+ def keep_exemplar(span)
238
+ return if span.parent # only complete traces
239
+
240
+ is_error = !span.error.nil?
241
+ is_slow = span.duration.to_f > config.slow_trace_ms
242
+ return unless span.recording || is_error || is_slow
243
+
244
+ exporter.queue_exemplar(
245
+ traceId: span.trace_id,
246
+ startedAt: span.start_wall,
247
+ durationMs: span.duration,
248
+ isError: is_error,
249
+ reason: is_error ? 'error' : (is_slow ? 'slow' : 'sampled'),
250
+ rootKey: Aggregator.span_key(span),
251
+ spans: flatten(span)
252
+ )
253
+ end
254
+
255
+ def flatten(span, out = [], parent_id = nil)
256
+ out << {
257
+ id: span.id,
258
+ parentId: parent_id,
259
+ kind: span.kind,
260
+ name: span.name,
261
+ durationMs: round(span.duration),
262
+ selfMs: round(Tracer.self_time(span)),
263
+ dbCalls: span.db_calls,
264
+ dbRows: span.db_rows,
265
+ # camelCased on the way out because the collector, the web app and the
266
+ # MCP server all read the JavaScript agent's spelling — one wire format,
267
+ # whatever language wrote it.
268
+ attrs: exported_attrs(span.attrs),
269
+ error: span.error
270
+ }
271
+ span.children.each { |child| flatten(child, out, span.id) }
272
+ out
273
+ end
274
+
275
+ ATTR_NAMES = {
276
+ normalized_sql: :normalizedSql, rows: :rows, fields: :fields, bytes: :bytes,
277
+ status: :status, method: :method, file: :file, line: :line, host: :host,
278
+ round_trips: :roundTrips,
279
+ direction: :direction, frames: :frames, cached: :cached
280
+ }.freeze
281
+
282
+ def exported_attrs(attrs)
283
+ attrs.each_with_object({}) do |(key, value), out|
284
+ name = ATTR_NAMES[key]
285
+ out[name] = value if name
286
+ end
287
+ end
288
+
289
+ def round(value)
290
+ (value.to_f * 1000).round / 1000.0
291
+ end
292
+
293
+ def start_flusher
294
+ @flush_pid = Process.pid
295
+ @flusher = Thread.new do
296
+ loop do
297
+ sleep config.flush_interval
298
+ flush
299
+ end
300
+ end
301
+ # Not joined at exit, and deliberately so: a background thread that keeps
302
+ # a process alive to finish reporting is an agent that has decided its
303
+ # telemetry matters more than the operator's shutdown. The at_exit flush
304
+ # is what catches the last window.
305
+ @flusher.abort_on_exception = false
306
+ @flusher.name = 'sixty-flush' if @flusher.respond_to?(:name=)
307
+ end
308
+
309
+ # Puma and Unicorn fork worker processes after boot, and a thread does not
310
+ # survive a fork — so a gem initialized in the parent has a dead flusher in
311
+ # every child, which reports nothing and says nothing about it. This is
312
+ # checked on the cheapest thing available (an integer compare) on the span
313
+ # path, because there is no portable hook that fires in every forking server.
314
+ def ensure_flusher
315
+ return if @flush_pid == Process.pid
316
+
317
+ start_flusher
318
+ end
319
+
320
+ def run_flush_hooks
321
+ (@flush_hooks || []).each do |hook|
322
+ hook.call
323
+ rescue StandardError => e
324
+ config.on_warn.call("sixty: flush hook failed: #{e.message}")
325
+ end
326
+ end
327
+ end
328
+ end
329
+
330
+ require_relative 'sixty/railtie' if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sixty
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - sixty
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: |
42
+ Measures what your application normally does — per method, per query — and
43
+ reports the shape of it: rows returned, queries issued, time spent in your
44
+ own code. The collector compares one release against the next and surfaces
45
+ what a deploy changed.
46
+ email:
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - README.md
52
+ - lib/sixty.rb
53
+ - lib/sixty/aggregator.rb
54
+ - lib/sixty/config.rb
55
+ - lib/sixty/exporter.rb
56
+ - lib/sixty/instrument/action_controller.rb
57
+ - lib/sixty/instrument/active_record.rb
58
+ - lib/sixty/instrument/mongo.rb
59
+ - lib/sixty/instrument/mysql.rb
60
+ - lib/sixty/instrument/pg.rb
61
+ - lib/sixty/instrument/rack.rb
62
+ - lib/sixty/instrumented.rb
63
+ - lib/sixty/plans.rb
64
+ - lib/sixty/railtie.rb
65
+ - lib/sixty/shape.rb
66
+ - lib/sixty/sketch.rb
67
+ - lib/sixty/sql.rb
68
+ - lib/sixty/stack.rb
69
+ - lib/sixty/tracer.rb
70
+ - lib/sixty/version.rb
71
+ homepage: https://github.com/andana-to/drift
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/andana-to/drift
76
+ source_code_uri: https://github.com/andana-to/drift/tree/main/packages/ruby
77
+ rubygems_mfa_required: 'true'
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 2.7.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.5.9
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Zero-config performance drift detection for Ruby and Rails services
97
+ test_files: []