metrixwire 0.2.3
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 +93 -0
- data/lib/metrixwire/config.rb +67 -0
- data/lib/metrixwire/context.rb +35 -0
- data/lib/metrixwire/instrument/active_support.rb +154 -0
- data/lib/metrixwire/instrument/helpers.rb +71 -0
- data/lib/metrixwire/instrument/mysql2.rb +38 -0
- data/lib/metrixwire/instrument/net_http.rb +79 -0
- data/lib/metrixwire/instrument/pg.rb +68 -0
- data/lib/metrixwire/instrument/rack_builder.rb +43 -0
- data/lib/metrixwire/instrument/redis.rb +66 -0
- data/lib/metrixwire/instrument/source_location.rb +30 -0
- data/lib/metrixwire/instrument/sqlite3.rb +38 -0
- data/lib/metrixwire/rack.rb +89 -0
- data/lib/metrixwire/railtie.rb +38 -0
- data/lib/metrixwire/span.rb +33 -0
- data/lib/metrixwire/trace.rb +62 -0
- data/lib/metrixwire/transport.rb +122 -0
- data/lib/metrixwire/version.rb +5 -0
- data/lib/metrixwire.rb +188 -0
- metadata +69 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetrixWire
|
|
4
|
+
module Instrument
|
|
5
|
+
# Best-effort patch of the `redis` gem → cache custom spans. The redis gem
|
|
6
|
+
# funnels commands through the client's `call`/`call_v`, so we time those and
|
|
7
|
+
# tag them meta.kind='cache' with `hit` known for GET-style reads. Guarded:
|
|
8
|
+
# skips silently if the gem/const isn't present.
|
|
9
|
+
module Redis
|
|
10
|
+
READS = %w[get mget hget hmget hgetall exists].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def install
|
|
15
|
+
target = redis_client_class
|
|
16
|
+
return if target.nil?
|
|
17
|
+
return if target.instance_variable_get(:@__metrixwire_patched)
|
|
18
|
+
|
|
19
|
+
target.prepend(Patch)
|
|
20
|
+
target.instance_variable_set(:@__metrixwire_patched, true)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# redis-rb >= 5 uses RedisClient; older versions ship Redis::Client.
|
|
26
|
+
def redis_client_class
|
|
27
|
+
if defined?(::RedisClient) && ::RedisClient.method_defined?(:call)
|
|
28
|
+
::RedisClient
|
|
29
|
+
elsif defined?(::Redis::Client) && ::Redis::Client.method_defined?(:call)
|
|
30
|
+
::Redis::Client
|
|
31
|
+
end
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module Patch
|
|
37
|
+
def call(command, *rest, &blk)
|
|
38
|
+
return super if Context.current.nil?
|
|
39
|
+
|
|
40
|
+
op = MetrixWire::Instrument::Redis.command_name(command)
|
|
41
|
+
t0 = MetrixWire.monotonic_ms
|
|
42
|
+
result = super
|
|
43
|
+
MetrixWire::Instrument::Redis.record(op, MetrixWire.monotonic_ms - t0, result)
|
|
44
|
+
result
|
|
45
|
+
rescue StandardError
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def command_name(command)
|
|
51
|
+
c = command.is_a?(Array) ? command.first : command
|
|
52
|
+
c.to_s.downcase
|
|
53
|
+
rescue StandardError
|
|
54
|
+
"cmd"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record(op, duration_ms, result)
|
|
58
|
+
meta = { kind: "cache", op: op }
|
|
59
|
+
meta[:hit] = !result.nil? if READS.include?(op)
|
|
60
|
+
Helpers.add_span("custom", "cache #{op}", duration_ms, meta: meta, source_location: nil)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetrixWire
|
|
4
|
+
module Instrument
|
|
5
|
+
# Finds the nearest application caller frame (file.rb:42) for a span, so the
|
|
6
|
+
# dashboard can point at the line that issued a query / HTTP call. Skips the
|
|
7
|
+
# SDK's own frames and common vendor/stdlib paths. Best-effort — returns nil
|
|
8
|
+
# rather than throwing.
|
|
9
|
+
module SourceLocation
|
|
10
|
+
SKIP = %r{/metrixwire/|/gems/|/ruby/\d|/lib/ruby/|<internal:}.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def nearest
|
|
15
|
+
return nil unless MetrixWire.capture_source?
|
|
16
|
+
|
|
17
|
+
caller_locations(2, 40)&.each do |loc|
|
|
18
|
+
path = loc.absolute_path || loc.path
|
|
19
|
+
next if path.nil?
|
|
20
|
+
next if SKIP.match?(path)
|
|
21
|
+
|
|
22
|
+
return "#{File.basename(path)}:#{loc.lineno}"
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetrixWire
|
|
4
|
+
module Instrument
|
|
5
|
+
# Best-effort patch of the `sqlite3` gem (SQLite3::Database#execute).
|
|
6
|
+
# Guarded: skips silently if the gem isn't loaded.
|
|
7
|
+
module Sqlite3
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def install
|
|
11
|
+
return unless defined?(::SQLite3::Database)
|
|
12
|
+
return if ::SQLite3::Database.instance_variable_get(:@__metrixwire_patched)
|
|
13
|
+
|
|
14
|
+
::SQLite3::Database.prepend(Patch)
|
|
15
|
+
::SQLite3::Database.instance_variable_set(:@__metrixwire_patched, true)
|
|
16
|
+
rescue StandardError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module Patch
|
|
21
|
+
def execute(*args, &blk)
|
|
22
|
+
sql = args.first
|
|
23
|
+
return super(*args, &blk) if Context.current.nil? || !sql.is_a?(String)
|
|
24
|
+
|
|
25
|
+
t0 = MetrixWire.monotonic_ms
|
|
26
|
+
result = super(*args, &blk)
|
|
27
|
+
unless MetrixWire::Instrument::Pg.control_statement?(sql)
|
|
28
|
+
Helpers.add_span("db_query", sql, MetrixWire.monotonic_ms - t0,
|
|
29
|
+
meta: Helpers.row_count_meta(result))
|
|
30
|
+
end
|
|
31
|
+
result
|
|
32
|
+
rescue StandardError
|
|
33
|
+
super(*args, &blk)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetrixWire
|
|
4
|
+
# Rack middleware — opens exactly one trace per incoming request, runs the app
|
|
5
|
+
# inside that trace's context, and enqueues the finished trace. Works for
|
|
6
|
+
# Sinatra, bare Rack, Rails (inserted via the Railtie) and anything else Rack.
|
|
7
|
+
#
|
|
8
|
+
# It is intentionally defensive at every step: a failure in instrumentation
|
|
9
|
+
# must never change the response the app returns.
|
|
10
|
+
class Rack
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
return @app.call(env) unless MetrixWire.enabled?
|
|
17
|
+
|
|
18
|
+
method = env["REQUEST_METHOD"] || "GET"
|
|
19
|
+
route = "#{method} #{derive_path(env)}"
|
|
20
|
+
trace = Trace.new(route: route, method: method)
|
|
21
|
+
start_heap = MetrixWire.rss_kb
|
|
22
|
+
|
|
23
|
+
status, headers, body = nil
|
|
24
|
+
Context.with(trace) do
|
|
25
|
+
begin
|
|
26
|
+
status, headers, body = @app.call(env)
|
|
27
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
28
|
+
trace.capture_exception(e)
|
|
29
|
+
finish(trace, 500, nil, start_heap)
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Refine the route from the framework's matched pattern if available
|
|
34
|
+
# (Sinatra exposes sinatra.route; Rails sets it via notifications).
|
|
35
|
+
refine_route(trace, env)
|
|
36
|
+
finish(trace, status, headers, start_heap)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
[status, headers, body]
|
|
40
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
41
|
+
# If we've already opened a trace it was handled above; re-raise so the
|
|
42
|
+
# host app's own error handling runs unchanged.
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def finish(trace, status, headers, start_heap)
|
|
49
|
+
code = status.to_i
|
|
50
|
+
trace.status = "error" if code >= 500
|
|
51
|
+
trace.finalize_duration!
|
|
52
|
+
|
|
53
|
+
mem = MetrixWire.memory_delta_mb(start_heap)
|
|
54
|
+
trace.set_meta(:memoryMb, mem) if mem && mem > 0
|
|
55
|
+
|
|
56
|
+
bytes = response_bytes(headers)
|
|
57
|
+
trace.set_meta(:responseBytes, bytes) if bytes && bytes > 0
|
|
58
|
+
|
|
59
|
+
MetrixWire.enqueue(trace)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
# never let instrumentation break the response
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def derive_path(env)
|
|
65
|
+
path = env["PATH_INFO"]
|
|
66
|
+
path = "/" if path.nil? || path.empty?
|
|
67
|
+
path
|
|
68
|
+
rescue StandardError
|
|
69
|
+
"/"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Prefer a matched route pattern (stable across ids) over the raw path.
|
|
73
|
+
def refine_route(trace, env)
|
|
74
|
+
matched = env["sinatra.route"] # e.g. "GET /users/:id"
|
|
75
|
+
trace.route = matched if matched.is_a?(String) && !matched.empty?
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def response_bytes(headers)
|
|
81
|
+
return nil unless headers
|
|
82
|
+
|
|
83
|
+
len = headers["Content-Length"] || headers["content-length"]
|
|
84
|
+
len ? len.to_i : nil
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetrixWire
|
|
4
|
+
# Rails integration. Loaded automatically when the gem is required inside a
|
|
5
|
+
# Rails app (Rails::Railtie is defined). It:
|
|
6
|
+
# (a) auto-inits the SDK from ENV before initializers run,
|
|
7
|
+
# (b) inserts the Rack middleware so one trace opens per request,
|
|
8
|
+
# (c) wires ActiveSupport::Notifications subscribers (SQL, controller, cache).
|
|
9
|
+
# Fully automatic: add the gem + set METRIXWIRE_KEY — no code changes.
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
# Init as early as possible so subscribers are attached before requests flow.
|
|
12
|
+
config.before_configuration do
|
|
13
|
+
MetrixWire.init unless MetrixWire.initialized?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Insert the Rack middleware at the top of the stack (one trace per request).
|
|
17
|
+
initializer "metrixwire.middleware", before: :load_config_initializers do |app|
|
|
18
|
+
if MetrixWire.enabled?
|
|
19
|
+
begin
|
|
20
|
+
app.config.app_middleware.use MetrixWire::Rack
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Attach the notification subscribers once the app has finished booting.
|
|
28
|
+
config.after_initialize do
|
|
29
|
+
if MetrixWire.enabled?
|
|
30
|
+
begin
|
|
31
|
+
MetrixWire::Instrument::ActiveSupportSubscriber.install
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetrixWire
|
|
4
|
+
# A single unit of work within a trace (db_query | http_call | custom).
|
|
5
|
+
# Users never build these by hand — instrumentation creates them.
|
|
6
|
+
class Span
|
|
7
|
+
attr_reader :type, :description, :started_at, :duration_ms, :source_location, :meta
|
|
8
|
+
|
|
9
|
+
# @param type [String] "db_query" | "http_call" | "custom"
|
|
10
|
+
# @param started_at [String] ISO8601
|
|
11
|
+
# @param meta [Hash, nil] optional signal (e.g. { rowCount: }, { statusCode: })
|
|
12
|
+
def initialize(type:, description:, started_at:, duration_ms:, source_location: nil, meta: nil)
|
|
13
|
+
@type = type
|
|
14
|
+
@description = description
|
|
15
|
+
@started_at = started_at
|
|
16
|
+
@duration_ms = duration_ms
|
|
17
|
+
@source_location = source_location
|
|
18
|
+
@meta = meta
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
out = {
|
|
23
|
+
type: @type,
|
|
24
|
+
description: @description,
|
|
25
|
+
startedAt: @started_at,
|
|
26
|
+
durationMs: @duration_ms
|
|
27
|
+
}
|
|
28
|
+
out[:sourceLocation] = @source_location if @source_location
|
|
29
|
+
out[:meta] = @meta if @meta && !@meta.empty?
|
|
30
|
+
out
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module MetrixWire
|
|
6
|
+
# One request / unit of work. Holds its spans and trace-level meta. Serialized
|
|
7
|
+
# to the exact ingest wire shape. Nothing here is part of the public API.
|
|
8
|
+
class Trace
|
|
9
|
+
attr_accessor :route, :method, :status, :meta
|
|
10
|
+
attr_reader :spans, :started_mono, :started_at
|
|
11
|
+
|
|
12
|
+
# @param started_mono [Float] monotonic clock at open (for duration)
|
|
13
|
+
def initialize(route:, method:, started_at: nil, started_mono: nil)
|
|
14
|
+
@route = route
|
|
15
|
+
@method = method
|
|
16
|
+
@started_mono = started_mono || MetrixWire.monotonic_ms
|
|
17
|
+
@started_at = started_at || MetrixWire.iso8601(Time.now)
|
|
18
|
+
@status = "success"
|
|
19
|
+
@spans = []
|
|
20
|
+
@meta = {}
|
|
21
|
+
@duration_ms = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_span(span)
|
|
25
|
+
@spans << span
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def set_meta(key, value)
|
|
29
|
+
@meta[key] = value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Attach an exception to this trace and flag it as an error. Never throws.
|
|
33
|
+
def capture_exception(err)
|
|
34
|
+
@meta[:exception] = MetrixWire.exception_meta(err)
|
|
35
|
+
@status = "error"
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# instrumentation must never throw
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def duration_ms
|
|
41
|
+
@duration_ms ||= [(MetrixWire.monotonic_ms - @started_mono), 0].max.round
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Freeze the duration at the moment the trace closes.
|
|
45
|
+
def finalize_duration!
|
|
46
|
+
@duration_ms = [(MetrixWire.monotonic_ms - @started_mono), 0].max.round
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
out = {
|
|
51
|
+
route: @route,
|
|
52
|
+
method: @method,
|
|
53
|
+
startedAt: @started_at,
|
|
54
|
+
durationMs: duration_ms,
|
|
55
|
+
status: @status,
|
|
56
|
+
spans: @spans.map(&:to_h)
|
|
57
|
+
}
|
|
58
|
+
out[:meta] = @meta if @meta && !@meta.empty?
|
|
59
|
+
out
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "thread"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module MetrixWire
|
|
9
|
+
# Batches finished traces on an in-memory Queue and flushes them to the ingest
|
|
10
|
+
# endpoint from a background daemon Thread. Every network path is wrapped so a
|
|
11
|
+
# dead or slow MetrixWire API can never crash or block the host app: short
|
|
12
|
+
# timeout, all errors swallowed, sends happen off the request path. A final
|
|
13
|
+
# flush runs at_exit.
|
|
14
|
+
class Transport
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
@queue = Queue.new
|
|
18
|
+
@buffer = []
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@thread = nil
|
|
21
|
+
@stopping = false
|
|
22
|
+
@uri = safe_uri(config.endpoint)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
return unless @config.enabled
|
|
27
|
+
return if @thread&.alive?
|
|
28
|
+
|
|
29
|
+
@thread = Thread.new { run_loop }
|
|
30
|
+
@thread.name = "metrixwire-transport" if @thread.respond_to?(:name=)
|
|
31
|
+
@thread.abort_on_exception = false
|
|
32
|
+
|
|
33
|
+
# Last-chance flush on process exit.
|
|
34
|
+
at_exit { flush_sync }
|
|
35
|
+
rescue StandardError
|
|
36
|
+
# never let startup break the host app
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Enqueue a finished trace. Non-blocking. Flushes immediately once the batch
|
|
40
|
+
# threshold is reached.
|
|
41
|
+
def enqueue(trace)
|
|
42
|
+
return unless @config.enabled
|
|
43
|
+
|
|
44
|
+
@queue << trace
|
|
45
|
+
@signal_flush = true if @queue.size >= @config.max_batch
|
|
46
|
+
rescue StandardError
|
|
47
|
+
# swallow — monitoring must never break the app
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Synchronous flush of everything currently queued. Safe to call anytime.
|
|
51
|
+
def flush_sync
|
|
52
|
+
drain_queue
|
|
53
|
+
batch = nil
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
return if @buffer.empty?
|
|
56
|
+
|
|
57
|
+
batch = @buffer.slice!(0, @buffer.length)
|
|
58
|
+
end
|
|
59
|
+
send_batch(batch) if batch && !batch.empty?
|
|
60
|
+
rescue StandardError
|
|
61
|
+
# swallow
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def run_loop
|
|
67
|
+
interval = @config.flush_interval_ms / 1000.0
|
|
68
|
+
loop do
|
|
69
|
+
begin
|
|
70
|
+
sleep(interval)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
flush_sync
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError
|
|
77
|
+
# thread must never die loudly; if it does, we simply stop flushing
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def drain_queue
|
|
81
|
+
until @queue.empty?
|
|
82
|
+
item = begin
|
|
83
|
+
@queue.pop(true)
|
|
84
|
+
rescue ThreadError
|
|
85
|
+
break
|
|
86
|
+
end
|
|
87
|
+
@mutex.synchronize { @buffer << item }
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def send_batch(batch)
|
|
94
|
+
return if batch.nil? || batch.empty?
|
|
95
|
+
return if @config.api_key.to_s.empty? || @uri.nil?
|
|
96
|
+
|
|
97
|
+
body = JSON.generate(traces: batch.map { |t| t.is_a?(Hash) ? t : t.to_h })
|
|
98
|
+
|
|
99
|
+
timeout = @config.timeout_ms / 1000.0
|
|
100
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
101
|
+
http.use_ssl = (@uri.scheme == "https")
|
|
102
|
+
http.open_timeout = timeout
|
|
103
|
+
http.read_timeout = timeout
|
|
104
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
105
|
+
|
|
106
|
+
req = Net::HTTP::Post.new(@uri.request_uri)
|
|
107
|
+
req["Content-Type"] = "application/json"
|
|
108
|
+
req["x-api-key"] = @config.api_key
|
|
109
|
+
req.body = body
|
|
110
|
+
|
|
111
|
+
http.start { |h| h.request(req) }
|
|
112
|
+
rescue StandardError
|
|
113
|
+
# Swallow everything — instrumentation must never throw into the app.
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def safe_uri(endpoint)
|
|
117
|
+
URI.parse(endpoint)
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/metrixwire.rb
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require_relative "metrixwire/version"
|
|
7
|
+
require_relative "metrixwire/config"
|
|
8
|
+
require_relative "metrixwire/span"
|
|
9
|
+
require_relative "metrixwire/trace"
|
|
10
|
+
require_relative "metrixwire/context"
|
|
11
|
+
require_relative "metrixwire/transport"
|
|
12
|
+
require_relative "metrixwire/rack"
|
|
13
|
+
require_relative "metrixwire/instrument/source_location"
|
|
14
|
+
require_relative "metrixwire/instrument/helpers"
|
|
15
|
+
require_relative "metrixwire/instrument/pg"
|
|
16
|
+
require_relative "metrixwire/instrument/mysql2"
|
|
17
|
+
require_relative "metrixwire/instrument/sqlite3"
|
|
18
|
+
require_relative "metrixwire/instrument/net_http"
|
|
19
|
+
require_relative "metrixwire/instrument/redis"
|
|
20
|
+
require_relative "metrixwire/instrument/rack_builder"
|
|
21
|
+
require_relative "metrixwire/instrument/active_support"
|
|
22
|
+
|
|
23
|
+
# Zero-config APM SDK for Ruby. Call {MetrixWire.init} once (or, in Rails, just
|
|
24
|
+
# add the gem + set METRIXWIRE_KEY and it's fully automatic). Every HTTP request
|
|
25
|
+
# becomes a trace and every DB query / outbound HTTP call / cache op within it
|
|
26
|
+
# becomes a span. There is no manual span API and no middleware to wire up.
|
|
27
|
+
#
|
|
28
|
+
# Non-blocking: traces are batched on a background thread with a short send
|
|
29
|
+
# timeout, and ALL transport errors are swallowed — instrumentation must never
|
|
30
|
+
# throw into or block the host application.
|
|
31
|
+
module MetrixWire
|
|
32
|
+
class << self
|
|
33
|
+
# Initialize the SDK. Args fall back to ENV: METRIXWIRE_KEY,
|
|
34
|
+
# METRIXWIRE_ENDPOINT, METRIXWIRE_ENABLED. Idempotent — a second call is a
|
|
35
|
+
# no-op. Auto-installs every instrumentation whose library is present.
|
|
36
|
+
def init(opts = {})
|
|
37
|
+
return if @config # already initialised
|
|
38
|
+
|
|
39
|
+
@config = Config.new(opts || {})
|
|
40
|
+
@transport = Transport.new(@config)
|
|
41
|
+
|
|
42
|
+
warn("[metrixwire] no apiKey provided — SDK disabled.") if @config.api_key.empty? && !opts_disabled?(opts)
|
|
43
|
+
|
|
44
|
+
if @config.enabled
|
|
45
|
+
@transport.start
|
|
46
|
+
install_all
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
# init itself must never break the host app.
|
|
51
|
+
begin
|
|
52
|
+
warn("[metrixwire] init failed: #{e.message}")
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialized?
|
|
60
|
+
!@config.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def enabled?
|
|
64
|
+
@config ? @config.enabled : false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def capture_source?
|
|
68
|
+
@config ? @config.capture_source : true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def endpoint
|
|
72
|
+
@config ? @config.endpoint : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Enqueue a finished trace for delivery. Internal — used by instrumentation.
|
|
76
|
+
def enqueue(trace)
|
|
77
|
+
@transport&.enqueue(trace)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Manually flush the queue (e.g. before a short-lived process exits).
|
|
81
|
+
def flush
|
|
82
|
+
@transport&.flush_sync
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Escape hatch mirroring Node/PHP: attach an exception to the active trace
|
|
86
|
+
# for frameworks the SDK can't hook automatically. NOT a manual span API.
|
|
87
|
+
def capture_exception(err)
|
|
88
|
+
trace = Context.current
|
|
89
|
+
trace&.capture_exception(err)
|
|
90
|
+
nil
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ── shared utilities used across the SDK ──────────────────────────────────
|
|
96
|
+
|
|
97
|
+
# Monotonic milliseconds — safe for measuring durations (never goes back).
|
|
98
|
+
def monotonic_ms
|
|
99
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ISO8601 UTC with millisecond precision, e.g. "2026-07-14T10:00:00.000Z".
|
|
103
|
+
def iso8601(time)
|
|
104
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
105
|
+
rescue StandardError
|
|
106
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build the ExceptionMeta hash from an arbitrary error. Keeps the top ~8
|
|
110
|
+
# backtrace frames — enough to fingerprint and guide a fix.
|
|
111
|
+
def exception_meta(err)
|
|
112
|
+
return nil if err.nil?
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
type: err.class.name.to_s,
|
|
116
|
+
message: err.respond_to?(:message) ? err.message.to_s : err.to_s,
|
|
117
|
+
stack: (err.respond_to?(:backtrace) && err.backtrace ? err.backtrace.first(8).join("\n") : nil)
|
|
118
|
+
}.compact
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Current process RSS in KB (best-effort, portable-ish). Used to compute a
|
|
124
|
+
# per-request memory delta for the memory_spike detector.
|
|
125
|
+
def rss_kb
|
|
126
|
+
# Linux: read RSS pages straight from /proc (no fork, fast).
|
|
127
|
+
if File.readable?("/proc/self/statm")
|
|
128
|
+
status = File.read("/proc/self/statm")
|
|
129
|
+
pages = status.split[1].to_i
|
|
130
|
+
return pages * (page_size / 1024)
|
|
131
|
+
end
|
|
132
|
+
# macOS / BSD: shell out to ps (cheap, once per request boundary).
|
|
133
|
+
out = `ps -o rss= -p #{Process.pid} 2>/dev/null`.to_s.strip
|
|
134
|
+
out.empty? ? nil : out.to_i
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Memory delta in MB since `start_kb`. Returns 0 when unavailable.
|
|
140
|
+
def memory_delta_mb(start_kb)
|
|
141
|
+
return 0 if start_kb.nil?
|
|
142
|
+
|
|
143
|
+
now = rss_kb
|
|
144
|
+
return 0 if now.nil?
|
|
145
|
+
|
|
146
|
+
((now - start_kb) / 1024.0).round
|
|
147
|
+
rescue StandardError
|
|
148
|
+
0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def page_size
|
|
154
|
+
@page_size ||= (`getconf PAGESIZE 2>/dev/null`.to_s.strip.to_i.nonzero? || 4096)
|
|
155
|
+
rescue StandardError
|
|
156
|
+
4096
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def opts_disabled?(opts)
|
|
160
|
+
opts.is_a?(Hash) && opts.key?(:enabled) && !opts[:enabled]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Auto-detect and patch everything present. Each guarded internally so a
|
|
164
|
+
# missing library / const is skipped silently.
|
|
165
|
+
def install_all
|
|
166
|
+
Instrument::NetHttp.install
|
|
167
|
+
Instrument::Pg.install
|
|
168
|
+
Instrument::Mysql2.install
|
|
169
|
+
Instrument::Sqlite3.install
|
|
170
|
+
Instrument::Redis.install
|
|
171
|
+
# Non-Rails Rack apps (Sinatra / bare Rack) get the middleware auto-inserted.
|
|
172
|
+
Instrument::RackBuilder.install
|
|
173
|
+
# Rails path (if we were init'd manually inside Rails without the Railtie).
|
|
174
|
+
Instrument::ActiveSupportSubscriber.install if defined?(::ActiveSupport::Notifications)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Load the Railtie only inside a Rails app so Rails is fully automatic.
|
|
182
|
+
if defined?(::Rails::Railtie)
|
|
183
|
+
begin
|
|
184
|
+
require_relative "metrixwire/railtie"
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
end
|