Module: Sixty

Defined in:
lib/sixty.rb,
lib/sixty/sql.rb,
lib/sixty/plans.rb,
lib/sixty/shape.rb,
lib/sixty/stack.rb,
lib/sixty/config.rb,
lib/sixty/sketch.rb,
lib/sixty/tracer.rb,
lib/sixty/railtie.rb,
lib/sixty/version.rb,
lib/sixty/exporter.rb,
lib/sixty/aggregator.rb,
lib/sixty/instrumented.rb,
lib/sixty/instrument/pg.rb,
lib/sixty/instrument/rack.rb,
lib/sixty/instrument/mongo.rb,
lib/sixty/instrument/mysql.rb,
lib/sixty/instrument/active_record.rb,
lib/sixty/instrument/action_controller.rb

Overview

sixty — zero-configuration performance drift detection, for Ruby.

require 'sixty'
Sixty.init   # reads SIXTY_API_KEY, SIXTY_SERVICE, SIXTY_RELEASE from ENV

In a Rails application the railtie calls this for you and installs the Rack middleware, the ActiveRecord subscriber and the controller instrumentation, so the only line an application needs is the gem in its Gemfile.

What it reports is deliberately not latency alone. It is shape: how many rows a query returned, how many queries a method issued, how much of a request was spent in your own code rather than below it. Those are the numbers that barely move on a warm development database and take production down a week later.

Defined Under Namespace

Modules: Instrument, Instrumented, Plans, Shape, Sql, Stack, Tracer Classes: Aggregator, Config, Exporter, Railtie, Sketch

Constant Summary collapse

VERSION =
'0.1.0'

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.aggregatorObject (readonly)

Returns the value of attribute aggregator.



31
32
33
# File 'lib/sixty.rb', line 31

def aggregator
  @aggregator
end

.configObject (readonly)

Returns the value of attribute config.



31
32
33
# File 'lib/sixty.rb', line 31

def config
  @config
end

.exporterObject (readonly)

Returns the value of attribute exporter.



31
32
33
# File 'lib/sixty.rb', line 31

def exporter
  @exporter
end

Class Method Details

.annotate(key, value) ⇒ Object

Record a value observed inside the current operation — e.g. the size of a result the caller cares about. Attaches to the active span.



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

def annotate(key, value)
  span = Tracer.current
  span.attrs[key] = value if span
end

.before_flush(&block) ⇒ Object

Work that should happen on the agent's thread, just before a flush.

The ActiveRecord instrumentation registers the EXPLAIN pass here: it needs a database connection and must never run on the request path, and this is the one thread in the process that satisfies both.



159
160
161
# File 'lib/sixty.rb', line 159

def before_flush(&block)
  (@flush_hooks ||= []) << block
end

.enabled?Boolean

Returns:

  • (Boolean)


94
95
96
# File 'lib/sixty.rb', line 94

def enabled?
  @enabled == true
end

.flush(timeout: Exporter::TIMEOUT_SECONDS) ⇒ Object

Flush now, from whatever thread asks. Used by the interval thread, by at_exit, and by tests that cannot wait fifteen seconds.



145
146
147
148
149
150
151
152
# File 'lib/sixty.rb', line 145

def flush(timeout: Exporter::TIMEOUT_SECONDS)
  return unless enabled?

  run_flush_hooks
  exporter.flush(aggregator.drain, timeout: timeout)
rescue StandardError => e
  config&.on_warn&.call("sixty: flush failed: #{e.message}")
end

.init(options = {}) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/sixty.rb', line 33

def init(options = {})
  return @state if @state

  config = Config.new(options)
  @config = config

  unless config.active?
    config.on_warn.call(
      'sixty: no API key found (set SIXTY_API_KEY or pass api_key:). Agent is inactive.'
    )
    return nil
  end

  Stack.root = rails_root || Dir.pwd

  @aggregator = Aggregator.new(on_warn: config.on_warn)
  @exporter = Exporter.new(
    endpoint: config.endpoint,
    api_key: config.api_key,
    service: config.service,
    environment: config.environment,
    release: config.release,
    repo_url: config.repo_url,
    on_warn: config.on_warn
  )

  Tracer.sink = method(:on_span_end)
  @enabled = true
  @flush_hooks = []
  start_flusher

  install_database_instrumentation(config)
  # Retried on every flush, because a driver is a constant that may not
  # exist yet. `Sixty.init` in a Sinatra app can easily run before
  # `require 'mysql2'`, and an agent that decided once at boot would report
  # no queries at all for the rest of the process — silently, which is the
  # failure this project refuses. Every installer is idempotent, so the
  # retry costs a few `defined?` checks a minute.
  before_flush { install_database_instrumentation(config) }

  # Rack and ActionController install themselves through the railtie when
  # Rails is present. A plain Rack or Sinatra app calls
  # Sixty::Instrument::Rack directly, which is why neither is here.
  #
  # The last window is worth one short attempt and no more. A process being
  # asked to stop is often a deploy waiting on it, and an agent that can add
  # ten seconds to every shutdown because a collector is unreachable is an
  # agent that will be removed — correctly.
  at_exit { flush(timeout: 2) }

  @state = { config: config, aggregator: @aggregator, exporter: @exporter }
  if config.debug
    config.on_warn.call(
      "sixty: active — service=#{config.service} env=#{config.environment} " \
      "release=#{config.release.empty? ? '(none)' : config.release} " \
      "endpoint=#{config.endpoint}"
    )
  end
  @state
end

.instrument(klass, *names) ⇒ Object

Instrument methods on a class you do not own — a gem's client, a model generated elsewhere, anything you cannot add an include to.

Sixty.instrument(Stripe::Charge, :create)

Only the named methods are wrapped, and no method_added hook is installed: a class you do not own may define methods long after this call — lazily, or through a gem's own metaprogramming — and silently instrumenting those is not something a one-line call should decide.



200
201
202
# File 'lib/sixty/instrumented.rb', line 200

def instrument(klass, *names)
  names.each { |name| Instrumented.wrap(klass, name) }
end

.on_span_end(span) ⇒ Object

Called on every completed span. Everything downstream of here is the agent's own work, so it is wrapped: an agent bug must never surface as an application error.



135
136
137
138
139
140
141
# File 'lib/sixty.rb', line 135

def on_span_end(span)
  ensure_flusher
  aggregator.record(span)
  keep_exemplar(span)
rescue StandardError => e
  config.on_warn.call("sixty: internal error recording span: #{e.message}")
end

.reset!Object

Test seam. Forgets everything and stops the flush thread.



164
165
166
167
168
169
170
171
172
173
174
# File 'lib/sixty.rb', line 164

def reset!
  @flusher&.kill
  @flusher = nil
  @state = nil
  @enabled = false
  @flush_hooks = []
  Tracer.sink = nil
  Tracer.current = nil
  Stack.reset!
  Plans.reset!
end

.set_route(route) ⇒ Object

Upgrade the current request's span to a framework-supplied route pattern. A real route always beats the path heuristic: without it every /users/42 is its own operation.



122
123
124
125
126
127
128
129
130
# File 'lib/sixty.rb', line 122

def set_route(route)
  span = Tracer.current
  return unless span && route.is_a?(String) && !route.empty?

  root = span.root
  return unless root && root.kind == Tracer::KIND_HTTP

  root.name = "#{root.attrs[:method]} #{route}"
end

.trace(name, kind: Tracer::KIND_FUNCTION, attrs: nil) ⇒ Object

Measure a block as one operation.

The escape hatch for code the automatic instrumentation cannot see, and the building block Sixty::Instrumented uses. name is an identity that will be compared across releases, so it must not contain anything that varies per call — an id in a name mints an operation per id and blows the cardinality cap.



105
106
107
108
109
110
# File 'lib/sixty.rb', line 105

def trace(name, kind: Tracer::KIND_FUNCTION, attrs: nil)
  return yield unless enabled?

  span = Tracer.start_span(kind: kind, name: name, attrs: attrs)
  Tracer.in_span(span) { yield }
end