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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sixty
4
+ # Configuration, under both names.
5
+ #
6
+ # The product is called sixty, so its variables are `SIXTY_*`. They used to be
7
+ # `DRIFT_*`, and that name is not ours to retire: it is set in other people's
8
+ # deployments — a Dockerfile, a Fly secret, a CI job someone configured once
9
+ # and has not thought about since. Renaming without reading the old name would
10
+ # not produce an error anybody could act on. It would produce an agent that
11
+ # starts cleanly, finds no key, and reports nothing, in exactly the silence
12
+ # this product exists to eliminate.
13
+ #
14
+ # Nothing here warns when the old name is used. A warning would fire on every
15
+ # boot of a correctly configured service that simply predates the rename,
16
+ # which teaches people to filter our log lines out.
17
+ class Config
18
+ ATTRIBUTES = %i[
19
+ api_key endpoint service environment release repo_url
20
+ flush_interval sample_rate slow_trace_ms capture_plans
21
+ instrument ignore_paths debug on_warn logger
22
+ ].freeze
23
+
24
+ attr_accessor(*ATTRIBUTES)
25
+
26
+ # Most PaaS providers set one of these without the user doing anything, and
27
+ # release attribution is what makes deploy-anchored detection possible — so
28
+ # the agent tries hard to find one before giving up. Without a release, two
29
+ # deploys are one undifferentiated stream and nothing can be compared
30
+ # against anything.
31
+ RELEASE_VARS = %w[
32
+ HEROKU_SLUG_COMMIT RENDER_GIT_COMMIT RAILWAY_GIT_COMMIT_SHA
33
+ GITHUB_SHA FLY_MACHINE_VERSION SOURCE_VERSION VERCEL_GIT_COMMIT_SHA
34
+ ].freeze
35
+
36
+ def initialize(options = {})
37
+ @api_key = options[:api_key] || env('API_KEY')
38
+ @endpoint = options[:endpoint] || env('ENDPOINT') || 'http://localhost:4319'
39
+ @service = options[:service] || env('SERVICE') || infer_service
40
+ @environment = options[:environment] || env('ENV') || rails_env || 'development'
41
+ @release = options[:release] || env('RELEASE') || detect_release || ''
42
+ @repo_url = options[:repo_url] || env('REPO_URL') || github_repo_url || ''
43
+ # Seconds when passed in code, milliseconds in the environment — because
44
+ # `SIXTY_FLUSH_MS` is the name every other agent in this repository reads,
45
+ # and a Ruby app and a Node app sharing a compose file must not need two
46
+ # spellings of the same knob.
47
+ @flush_interval = options[:flush_interval] || (env_number('FLUSH_MS', 15_000) / 1000.0)
48
+ @sample_rate = options[:sample_rate] || env_number('SAMPLE_RATE', 0.05)
49
+ @slow_trace_ms = options[:slow_trace_ms] || env_number('SLOW_TRACE_MS', 1000)
50
+ @capture_plans = options.fetch(:capture_plans, env('CAPTURE_PLANS') != '0')
51
+ # Which database clients to measure. nil means "decide from what is in the
52
+ # process" — see Sixty.install_database_instrumentation, which is where
53
+ # the ActiveRecord-versus-raw-driver choice is made and explained.
54
+ @instrument = options[:instrument] || env_list('INSTRUMENT')
55
+ # Health checks and asset requests are noise: they are not the
56
+ # application, and left in they dominate the operation list of every
57
+ # service behind a load balancer.
58
+ @ignore_paths = options[:ignore_paths] || [
59
+ %r{\A/assets/}, %r{\A/packs/}, %r{\A/rails/active_storage/},
60
+ %r{\A/health}, %r{\A/up\z}, /favicon\.ico\z/
61
+ ]
62
+ @debug = options.fetch(:debug, env('DEBUG') == '1')
63
+ @logger = options[:logger] || rails_logger
64
+ @on_warn = options[:on_warn] || default_warn
65
+ end
66
+
67
+ def active?
68
+ !api_key.to_s.empty?
69
+ end
70
+
71
+ def ignore?(path)
72
+ ignore_paths.any? { |pattern| pattern.match?(path) }
73
+ end
74
+
75
+ private
76
+
77
+ # Emptiness is checked per name rather than after choosing one. `SIXTY_X=`
78
+ # in a compose file is someone who added the variable and has not filled it
79
+ # in yet — if that shadowed a `DRIFT_X` that is actually set, adding the new
80
+ # name would break a working deployment, which is the one outcome this
81
+ # lookup exists to prevent.
82
+ def env(name)
83
+ ["SIXTY_#{name}", "DRIFT_#{name}"].each do |key|
84
+ value = ENV.fetch(key, nil)
85
+ return value if value && !value.empty?
86
+ end
87
+ nil
88
+ end
89
+
90
+ def env_number(name, fallback)
91
+ raw = env(name)
92
+ return fallback if raw.nil?
93
+
94
+ value = Float(raw, exception: false)
95
+ value.nil? ? fallback : value
96
+ end
97
+
98
+ # SIXTY_INSTRUMENT=pg,mongo — for the application that knows better than the
99
+ # default, usually because it uses ActiveRecord *and* a driver directly.
100
+ def env_list(name)
101
+ raw = env(name)
102
+ return nil if raw.nil?
103
+
104
+ raw.split(',').map { |part| part.strip.downcase.to_sym }.reject { |part| part.to_s.empty? }
105
+ end
106
+
107
+ def detect_release
108
+ RELEASE_VARS.each do |key|
109
+ value = ENV.fetch(key, nil)
110
+ return value if value && !value.empty?
111
+ end
112
+ nil
113
+ end
114
+
115
+ def github_repo_url
116
+ repo = ENV.fetch('GITHUB_REPOSITORY', nil)
117
+ repo && !repo.empty? ? "https://github.com/#{repo}" : nil
118
+ end
119
+
120
+ def infer_service
121
+ return rails_application_name if rails_application_name
122
+
123
+ File.basename(Dir.pwd)
124
+ rescue StandardError
125
+ 'unknown-service'
126
+ end
127
+
128
+ def rails_application_name
129
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
130
+
131
+ ::Rails.application.class.module_parent_name.to_s.gsub(/([a-z])([A-Z])/, '\1-\2').downcase
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def rails_env
137
+ defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : nil
138
+ end
139
+
140
+ def rails_logger
141
+ defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
142
+ end
143
+
144
+ def default_warn
145
+ log = @logger
146
+ lambda do |message|
147
+ if log
148
+ log.warn(message)
149
+ else
150
+ warn(message)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Sixty
8
+ # Transport. Ships aggregate windows and exemplar traces to the collector.
9
+ #
10
+ # Rules this obeys, because an observability agent that harms the host is
11
+ # worse than no agent at all:
12
+ # - never block the request path (the flush runs on the agent's own thread)
13
+ # - never grow without bound (the exemplar queue is capped and drops oldest)
14
+ # - never keep the process alive (the thread is not joined at exit; the
15
+ # final flush is bounded by its own timeout)
16
+ # - never raise into user code (every failure is swallowed and counted)
17
+ class Exporter
18
+ MAX_QUEUED_EXEMPLARS = 200
19
+ TIMEOUT_SECONDS = 10
20
+
21
+ attr_reader :failures
22
+
23
+ def initialize(endpoint:, api_key:, service:, environment:, release:, repo_url: '',
24
+ path: '/v1/ingest', on_warn: ->(_msg) {})
25
+ @endpoint = endpoint.to_s.sub(%r{/\z}, '')
26
+ # The collector's receive path, appended to the endpoint — and overridable
27
+ # because not every endpoint is the collector. A service reporting through
28
+ # a proxy mounted on its own origin is given the exact URL that proxy
29
+ # lives at, and appending anything to it produces a route the application
30
+ # never registered. That failure is unusually quiet: the request 404s, the
31
+ # agent swallows it like any other transport error, and the only symptom
32
+ # is an absence of data.
33
+ @path = path
34
+ @api_key = api_key
35
+ @resource = {
36
+ service: service,
37
+ environment: environment,
38
+ release: release,
39
+ repoUrl: repo_url.to_s
40
+ }
41
+ @on_warn = on_warn
42
+ @exemplars = []
43
+ @mutex = Mutex.new
44
+ @failures = 0
45
+ @warned_at = 0.0
46
+ @last_attempt = nil
47
+ end
48
+
49
+ def queue_exemplar(trace)
50
+ @mutex.synchronize do
51
+ @exemplars.shift if @exemplars.length >= MAX_QUEUED_EXEMPLARS
52
+ @exemplars << trace
53
+ end
54
+ end
55
+
56
+ # @param metrics [Hash, nil] a drained aggregator window
57
+ # @param sections [Hash] payload sections this exporter knows nothing about,
58
+ # so a future adapter can add one without teaching the transport about it
59
+ # @param timeout [Numeric] socket timeout; shortened for the exit flush,
60
+ # where the process is trying to stop and a dead collector must not be
61
+ # able to hold it open
62
+ def flush(metrics, sections: {}, timeout: TIMEOUT_SECONDS)
63
+ exemplars = @mutex.synchronize do
64
+ taken = @exemplars
65
+ @exemplars = []
66
+ taken
67
+ end
68
+
69
+ has_sections = sections.any? { |_k, v| !v.nil? }
70
+ return if metrics.nil? && exemplars.empty? && !has_sections
71
+
72
+ # A collector that is down stops being contacted for a while, rather than
73
+ # being dialled on every interval for as long as the outage lasts. The
74
+ # window that would have been sent is dropped here, deliberately: an agent
75
+ # that retained data through an outage would be an agent whose memory
76
+ # footprint is decided by somebody else's uptime.
77
+ return if backing_off?
78
+
79
+ body = JSON.generate(
80
+ {
81
+ resource: @resource,
82
+ metrics: metrics,
83
+ exemplars: exemplars,
84
+ sentAt: (Time.now.to_f * 1000).round
85
+ }.merge(sections.reject { |_k, v| v.nil? })
86
+ )
87
+
88
+ post(body, timeout)
89
+ end
90
+
91
+ private
92
+
93
+ # Exponential, capped, and counted from the first failure — so a collector
94
+ # that is briefly unreachable is retried on the next interval, and one that
95
+ # has been down for an hour is dialled every five minutes instead of every
96
+ # fifteen seconds.
97
+ MAX_BACKOFF_SECONDS = 300
98
+
99
+ def backing_off?
100
+ return false if @failures < 2 || @last_attempt.nil?
101
+
102
+ delay = [2**[@failures, 8].min, MAX_BACKOFF_SECONDS].min
103
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_attempt) < delay
104
+ end
105
+
106
+ def post(body, timeout)
107
+ @last_attempt = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ uri = URI.parse("#{@endpoint}#{@path}")
109
+ http = Net::HTTP.new(uri.host, uri.port)
110
+ http.use_ssl = uri.scheme == 'https'
111
+ http.open_timeout = timeout
112
+ http.read_timeout = timeout
113
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
114
+
115
+ request = Net::HTTP::Post.new(uri.request_uri)
116
+ request['content-type'] = 'application/json'
117
+ # Omitted rather than sent empty when there is no key, so a
118
+ # misconfiguration reads as "no credential" at the collector rather than
119
+ # as an empty one it has to decide what to do with.
120
+ request['authorization'] = "Bearer #{@api_key}" if @api_key && !@api_key.empty?
121
+ request.body = body
122
+
123
+ response = http.request(request)
124
+ if response.code.to_i >= 300
125
+ warn_once("ingest rejected payload: #{response.code} #{response.body.to_s[0, 200]}")
126
+ else
127
+ @failures = 0
128
+ end
129
+ rescue StandardError => e
130
+ warn_once("ingest unreachable: #{e.message}")
131
+ end
132
+
133
+ def warn_once(message)
134
+ @failures += 1
135
+ # The first failure is logged, then at most one a minute. An agent that
136
+ # spams stderr through a collector outage is its own incident.
137
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
138
+ return unless @failures == 1 || now - @warned_at > 60
139
+
140
+ @warned_at = now
141
+ @on_warn.call("sixty: #{message}#{@failures > 1 ? " (#{@failures} failures)" : ''}")
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tracer'
4
+
5
+ module Sixty
6
+ module Instrument
7
+ # Controller actions, as operations of their own.
8
+ #
9
+ # The Rack span says a request took 900ms. The query spans say the database
10
+ # took 40ms. Without a span in between, the remaining 860ms lands on the
11
+ # request as self time — true, but not actionable: "GET /orders got slower"
12
+ # names an endpoint, not a change. `OrdersController#index` names the method
13
+ # somebody edited.
14
+ #
15
+ # It is also the layer that makes fanout readable. `db_calls` is credited to
16
+ # every ancestor, so an N+1 introduced in a controller shows up here as the
17
+ # call count changing, which is the sentence a person can act on: "this
18
+ # action went from 3 queries to 41".
19
+ #
20
+ # `process_action` is the hook rather than an `around_action` for one
21
+ # reason: an around_action can be skipped, reordered, or short-circuited by
22
+ # another filter — including a `before_action` that renders and halts, which
23
+ # is exactly the request whose measurement matters most.
24
+ module ActionController
25
+ def process_action(*args)
26
+ return super unless Sixty.enabled?
27
+
28
+ Sixty.trace("#{self.class.name}##{action_name}") { super }
29
+ end
30
+ ruby2_keywords :process_action if respond_to?(:ruby2_keywords, true)
31
+
32
+ def self.install
33
+ return false unless defined?(::ActiveSupport)
34
+
35
+ ::ActiveSupport.on_load(:action_controller) do
36
+ # `self` here is ActionController::Base or ::API, whichever the
37
+ # application loaded — both, in an app that has each.
38
+ prepend Sixty::Instrument::ActionController
39
+ end
40
+ true
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tracer'
4
+ require_relative '../sql'
5
+ require_relative '../stack'
6
+ require_relative '../plans'
7
+
8
+ module Sixty
9
+ module Instrument
10
+ # ActiveRecord.
11
+ #
12
+ # What is captured beyond timing is the point of the product:
13
+ # rows — how many the statement returned. The 30 -> 30,000 signal, and the
14
+ # one an ORM makes easy to lose: `.where(...)` without a limit is
15
+ # one character away from `.all`, and on a development database
16
+ # both take four milliseconds.
17
+ # calls — every query, credited to every enclosing method and to the
18
+ # request. One query becoming twelve is an N+1 being born.
19
+ #
20
+ # ── Why a notification subscriber and not a patched adapter ───────────────
21
+ #
22
+ # `sql.active_record` is a public, stable interface that every adapter emits
23
+ # and every Rails version has. Patching `exec_query` would work on one
24
+ # adapter and one version and break quietly on the next, and "quietly" is
25
+ # the failure mode this project refuses. The cost is that the event arrives
26
+ # *after* the query — so the span is assembled from the event's own start
27
+ # and finish times rather than measured around it, which is exactly what
28
+ # `Tracer.record` exists for.
29
+ module ActiveRecord
30
+ # Statements Rails issues about itself rather than on the application's
31
+ # behalf. A schema load at boot is not an operation anyone can act on, and
32
+ # a cached query never reached the database at all — recording it would
33
+ # report database work that did not happen.
34
+ IGNORED_NAMES = ['SCHEMA', 'TRANSACTION', 'EXPLAIN'].freeze
35
+
36
+ class << self
37
+ def install(config)
38
+ return false if @installed
39
+ return false unless defined?(::ActiveSupport::Notifications)
40
+
41
+ @config = config
42
+ @subscriber = ::ActiveSupport::Notifications.monotonic_subscribe('sql.active_record') do |_name, started, finished, _id, payload|
43
+ record(payload, (finished - started) * 1000.0)
44
+ end
45
+
46
+ Sixty.before_flush { capture_plans } if config.capture_plans
47
+ @installed = true
48
+ end
49
+
50
+ def installed?
51
+ @installed == true
52
+ end
53
+
54
+ # Which SQL dialect this application speaks.
55
+ #
56
+ # It decides whether `"alice@example.com"` is an identifier to keep or a
57
+ # string literal to strip, so guessing from the text is not an option —
58
+ # a wrong guess in one direction transmits a customer's data. Asked of
59
+ # the adapter, which knows, and cached because the answer cannot change
60
+ # for the life of a process.
61
+ #
62
+ # `connection_db_config` rather than a live connection: reading this
63
+ # must never be a reason to open one.
64
+ def dialect
65
+ return @dialect if defined?(@dialect) && @dialect
66
+
67
+ @dialect = begin
68
+ name = ::ActiveRecord::Base.connection_db_config.adapter.to_s
69
+ name.match?(/mysql|trilogy|maria/i) ? :mysql : :postgres
70
+ rescue StandardError
71
+ :postgres
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def record(payload, duration_ms)
78
+ return if Thread.current[:sixty_in_agent_query]
79
+ return if payload[:cached]
80
+ return if IGNORED_NAMES.include?(payload[:name].to_s)
81
+
82
+ sql = payload[:sql]
83
+ return unless sql.is_a?(String) && !sql.empty?
84
+
85
+ normalized = Sql.normalize_sql(sql, dialect)
86
+ attrs = { normalized_sql: normalized }
87
+
88
+ # `row_count` is what a modern Rails reports; on anything older the
89
+ # metric is simply absent rather than guessed at. A wrong row count is
90
+ # worse than no row count — it is the number the whole detector leans
91
+ # on.
92
+ attrs[:rows] = payload[:row_count] if payload[:row_count].is_a?(Numeric)
93
+
94
+ # Once per distinct statement, never again: the call site is a
95
+ # property of the query, not of the call.
96
+ frames = Stack.capture(normalized)
97
+ attrs[:frames] = frames if frames
98
+ if frames&.first
99
+ attrs[:file] = frames.first[:file]
100
+ attrs[:line] = frames.first[:line]
101
+ end
102
+
103
+ Tracer.record(
104
+ kind: Tracer::KIND_DB,
105
+ name: Sql.sql_operation_name(normalized),
106
+ duration_ms: duration_ms,
107
+ attrs: attrs
108
+ )
109
+
110
+ Plans.enqueue(sql, normalized, dialect: dialect) if @config&.capture_plans
111
+ rescue StandardError => e
112
+ @config&.on_warn&.call("sixty: error recording query: #{e.message}")
113
+ end
114
+
115
+ # Runs on the agent's flush thread, never on a request.
116
+ #
117
+ # `with_connection` checks a connection out of the pool and returns it,
118
+ # so this cannot leak one, and the thread-local flag keeps the EXPLAIN
119
+ # from being recorded as an operation of its own — a query that measures
120
+ # queries would count toward the fanout of nothing and appear in the
121
+ # feed as an operation the application does not contain.
122
+ def capture_plans
123
+ return unless defined?(::ActiveRecord::Base)
124
+
125
+ Thread.current[:sixty_in_agent_query] = true
126
+ ::ActiveRecord::Base.connection_pool.with_connection do |connection|
127
+ Plans.capture_pending(lambda { |sql|
128
+ result = connection.exec_query(sql)
129
+ row = result.rows.first
130
+ row && row.first
131
+ })
132
+ end
133
+ ensure
134
+ Thread.current[:sixty_in_agent_query] = false
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end