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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ VERSION = "0.2.3"
5
+ 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