sixty 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +326 -0
- data/lib/sixty/aggregator.rb +257 -0
- data/lib/sixty/config.rb +155 -0
- data/lib/sixty/exporter.rb +144 -0
- data/lib/sixty/instrument/action_controller.rb +44 -0
- data/lib/sixty/instrument/active_record.rb +139 -0
- data/lib/sixty/instrument/mongo.rb +482 -0
- data/lib/sixty/instrument/mysql.rb +187 -0
- data/lib/sixty/instrument/pg.rb +197 -0
- data/lib/sixty/instrument/rack.rb +158 -0
- data/lib/sixty/instrumented.rb +204 -0
- data/lib/sixty/plans.rb +258 -0
- data/lib/sixty/railtie.rb +49 -0
- data/lib/sixty/shape.rb +91 -0
- data/lib/sixty/sketch.rb +211 -0
- data/lib/sixty/sql.rb +441 -0
- data/lib/sixty/stack.rb +95 -0
- data/lib/sixty/tracer.rb +222 -0
- data/lib/sixty/version.rb +5 -0
- data/lib/sixty.rb +330 -0
- metadata +97 -0
data/lib/sixty/tracer.rb
ADDED
|
@@ -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
|
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: []
|