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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 60349ff1d29a910423b140cf510ecb4134a7ebbab5785018b97633d93d7b6a12
4
+ data.tar.gz: a945b1cd55dbba41183f4b1a59b6154fe58d98d9b74ef4614e37094fcafd22d6
5
+ SHA512:
6
+ metadata.gz: 5deefc9b6d766d44d7c1da82bc876cd15bc143c200cd190c057666f773d86f1ccb3c1d249ce523e63c6494dbbe73eec2344cde0f6cbc3b37006d2c8ae48b9c1a
7
+ data.tar.gz: f75038a4a6e3f00d13f81979b52dd73c18dc301c3e971c0c6de2d965e4068c467f91452e47cddcf7157ce2769a7c632857491c164f69f26d3246662fa9391e71
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # metrixwire
2
+
3
+ Zero-config APM SDK for **Ruby** — Rails, Sinatra & bare Rack. In Rails it's fully automatic: add the gem and set `METRIXWIRE_KEY`. Elsewhere, call `MetrixWire.init` once. Every request, database query, outbound HTTP call and cache op is instrumented automatically. There is no manual span API and no middleware to wire up. Non-blocking: if the MetrixWire endpoint is down, your app keeps running normally.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem 'metrixwire', require: 'metrixwire'
10
+ ```
11
+
12
+ ```bash
13
+ bundle install
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ### Rails — fully automatic
19
+
20
+ Add the gem, set the key, done. A Railtie auto-initializes from ENV, inserts the Rack middleware (one trace per request), and subscribes to `ActiveSupport::Notifications` — no code changes:
21
+
22
+ ```bash
23
+ export METRIXWIRE_KEY=mw_...
24
+ ```
25
+
26
+ ### Sinatra & bare Rack — automatic
27
+
28
+ Just require the gem and call `init` once, as early as possible:
29
+
30
+ ```ruby
31
+ require 'metrixwire'
32
+ MetrixWire.init(api_key: ENV['METRIXWIRE_KEY'])
33
+ ```
34
+
35
+ The SDK patches `Rack::Builder#to_app`, so the tracing middleware is inserted for you — you never write `use MetrixWire::Rack`. Every HTTP request becomes a **trace**, and every query / HTTP call / cache op within it becomes a **span**.
36
+
37
+ ## How the automatic tracing works
38
+
39
+ | Framework | Traced automatically | Notes |
40
+ |---|---|---|
41
+ | **Rails** | ✅ | Route refined to `controller#action` via `ActiveSupport::Notifications`. 5xx & exceptions captured. |
42
+ | **Sinatra** | ✅ | Route pattern (`GET /users/:id`) detected from `sinatra.route`. Zero wiring. |
43
+ | **bare Rack** | ✅ | Middleware auto-inserted via `Rack::Builder#to_app`. |
44
+
45
+ The Rails path uses `ActiveSupport::Notifications` (the idiomatic, reliable route): `sql.active_record` for DB spans, `process_action.action_controller` for the route + errors, `cache_*.active_support` for cache spans, and `transaction.active_record` for transaction spans. Outside Rails, DB and HTTP are captured by patching the underlying drivers directly.
46
+
47
+ ## Automatically instrumented libraries
48
+
49
+ | Library | How |
50
+ |---|---|
51
+ | **ActiveRecord** (Rails) | `sql.active_record` notifications (db_query + rowCount) |
52
+ | **pg** | patches `PG::Connection#exec`/`#exec_params`/`#async_exec` (rowCount from the result; transaction spans from `BEGIN…COMMIT`) |
53
+ | **mysql2** | patches `Mysql2::Client#query` |
54
+ | **sqlite3** | patches `SQLite3::Database#execute` |
55
+ | **Net::HTTP** (and libs built on it) | patches `Net::HTTP#request` (http_call span + statusCode) |
56
+ | **redis / redis-client** | cache spans (hit/miss where known) for the slow-cache detector |
57
+
58
+ Each patch is guarded: if the library or constant isn't present, it's skipped silently. The core has **zero runtime gem dependencies** (stdlib only).
59
+
60
+ ## `init` options
61
+
62
+ ```ruby
63
+ MetrixWire.init(
64
+ api_key: 'mw_...', # required (falls back to METRIXWIRE_KEY)
65
+ endpoint: 'http://localhost:3000/ingest', # default (a base URL is accepted; /ingest is appended)
66
+ flush_interval_ms: 5000, # how often batches are sent
67
+ enabled: true, # set to false to disable entirely (or METRIXWIRE_ENABLED=false)
68
+ timeout_ms: 3000, # send timeout (short, non-blocking)
69
+ max_batch: 20, # flush immediately once this many are queued
70
+ capture_source: true # capture the file:line a span originated from
71
+ )
72
+ ```
73
+
74
+ All options fall back to environment variables when omitted: `METRIXWIRE_KEY`, `METRIXWIRE_ENDPOINT`, `METRIXWIRE_ENABLED`. With no API key the SDK runs in disabled mode instead of raising.
75
+
76
+ ## Non-blocking behavior
77
+
78
+ - Traces are batched on a background **daemon thread** and sent off the request path with a short timeout.
79
+ - **All** transport errors are swallowed — instrumentation never throws into or blocks your app.
80
+ - A final flush runs on `at_exit`; you can also call `MetrixWire.flush` before a short-lived process exits.
81
+
82
+ ## Error boundary (escape hatch)
83
+
84
+ For frameworks the SDK can't hook automatically, attach an exception to the active trace — this is **not** a manual span API; requests/queries are still captured automatically:
85
+
86
+ ```ruby
87
+ begin
88
+ do_work
89
+ rescue => e
90
+ MetrixWire.capture_exception(e)
91
+ raise
92
+ end
93
+ ```
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ # Immutable-ish config holder. Built once by {MetrixWire.init} from explicit
5
+ # options with ENV fallbacks. Nothing here throws — bad input degrades to a
6
+ # sensible default so instrumentation never breaks the host app.
7
+ class Config
8
+ DEFAULT_ENDPOINT = "http://localhost:3000/ingest"
9
+ DEFAULT_FLUSH_INTERVAL_MS = 5000
10
+ DEFAULT_TIMEOUT_MS = 3000
11
+ DEFAULT_MAX_BATCH = 20
12
+
13
+ attr_reader :api_key, :endpoint, :flush_interval_ms, :enabled,
14
+ :timeout_ms, :max_batch, :capture_source
15
+
16
+ def initialize(opts = {})
17
+ @api_key = to_s(opts.fetch(:api_key, env("METRIXWIRE_KEY")))
18
+ @endpoint = normalize_endpoint(opts.fetch(:endpoint, env("METRIXWIRE_ENDPOINT")) || DEFAULT_ENDPOINT)
19
+ @flush_interval_ms = to_i(opts[:flush_interval_ms], DEFAULT_FLUSH_INTERVAL_MS)
20
+ @timeout_ms = to_i(opts[:timeout_ms], DEFAULT_TIMEOUT_MS)
21
+ @max_batch = to_i(opts[:max_batch], DEFAULT_MAX_BATCH)
22
+ @capture_source = opts.fetch(:capture_source, true) ? true : false
23
+
24
+ enabled = opts.key?(:enabled) ? opts[:enabled] : env_enabled
25
+ # No key ⇒ disabled mode (never throw). Matches Node/PHP behavior.
26
+ @enabled = (enabled ? true : false) && !@api_key.empty?
27
+ end
28
+
29
+ # Accept a base URL too: append /ingest when missing.
30
+ def normalize_endpoint(url)
31
+ url = to_s(url)
32
+ url = DEFAULT_ENDPOINT if url.empty?
33
+ url = url.sub(%r{/+\z}, "")
34
+ url = "#{url}/ingest" unless url.end_with?("/ingest")
35
+ url
36
+ rescue StandardError
37
+ DEFAULT_ENDPOINT
38
+ end
39
+
40
+ private
41
+
42
+ def env(key)
43
+ v = ENV[key]
44
+ v.nil? || v.empty? ? nil : v
45
+ end
46
+
47
+ def env_enabled
48
+ raw = ENV["METRIXWIRE_ENABLED"]
49
+ return true if raw.nil? || raw.empty?
50
+
51
+ !%w[false 0 no off].include?(raw.strip.downcase)
52
+ end
53
+
54
+ def to_s(v)
55
+ v.nil? ? "" : v.to_s
56
+ end
57
+
58
+ def to_i(v, default)
59
+ return default if v.nil?
60
+
61
+ i = Integer(v.to_s, exception: false)
62
+ i.nil? || i <= 0 ? default : i
63
+ rescue StandardError
64
+ default
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ # Thread/fiber-local "current trace" holder. Every entry point (Rack
5
+ # middleware, ActiveSupport subscribers, DB/HTTP patches) reads the active
6
+ # trace from here so spans attach to the right request. Fiber-local storage
7
+ # (Thread#[]) keeps concurrent requests isolated on threaded servers.
8
+ module Context
9
+ KEY = :__metrixwire_current_trace__
10
+
11
+ module_function
12
+
13
+ def current
14
+ Thread.current[KEY]
15
+ end
16
+
17
+ def current=(trace)
18
+ Thread.current[KEY] = trace
19
+ end
20
+
21
+ # Run a block with `trace` as the active trace, restoring the previous one
22
+ # afterwards. Restores even if the block raises.
23
+ def with(trace)
24
+ prev = Thread.current[KEY]
25
+ Thread.current[KEY] = trace
26
+ yield
27
+ ensure
28
+ Thread.current[KEY] = prev
29
+ end
30
+
31
+ def clear
32
+ Thread.current[KEY] = nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ module Instrument
5
+ # Subscribes to ActiveSupport::Notifications for the idiomatic, reliable
6
+ # Rails path: SQL queries, controller actions (route refinement + errors),
7
+ # and cache reads/writes. Each subscriber is guarded and never throws.
8
+ module ActiveSupportSubscriber
9
+ SCHEMA_SQL = /\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|SET|SHOW|PRAGMA)\b/i.freeze
10
+
11
+ module_function
12
+
13
+ def install
14
+ return unless defined?(::ActiveSupport::Notifications)
15
+ return if @installed
16
+
17
+ subscribe_sql
18
+ subscribe_process_action
19
+ subscribe_cache
20
+ subscribe_transaction
21
+ @installed = true
22
+ rescue StandardError
23
+ nil
24
+ end
25
+
26
+ # db_query spans from sql.active_record. rowCount from the result payload
27
+ # when ActiveRecord exposes it.
28
+ def subscribe_sql
29
+ ::ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
30
+ begin
31
+ trace = Context.current
32
+ next if trace.nil?
33
+
34
+ event = event_of(args)
35
+ payload = event.payload
36
+ name = payload[:name].to_s
37
+ sql = payload[:sql].to_s
38
+ # Skip schema/transaction control + AR's own housekeeping queries.
39
+ next if sql.empty? || SCHEMA_SQL.match?(sql)
40
+ next if name == "SCHEMA" || name == "TRANSACTION"
41
+
42
+ meta = row_count_from_payload(payload)
43
+ Helpers.add_span("db_query", sql, event.duration, meta: meta, source_location: nil)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+
50
+ # Refine the route to controller#action, capture 5xx + exceptions.
51
+ def subscribe_process_action
52
+ ::ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
53
+ begin
54
+ trace = Context.current
55
+ next if trace.nil?
56
+
57
+ payload = event_of(args).payload
58
+ controller = payload[:controller]
59
+ action = payload[:action]
60
+ method = payload[:method] || trace.method
61
+ if controller && action
62
+ trace.route = "#{method} #{controller}##{action}"
63
+ end
64
+
65
+ status = payload[:status].to_i
66
+ trace.status = "error" if status >= 500
67
+
68
+ if (ex = payload[:exception_object])
69
+ trace.capture_exception(ex)
70
+ elsif (ex_arr = payload[:exception])
71
+ # [class_name, message]
72
+ trace.set_meta(:exception, {
73
+ type: ex_arr[0].to_s,
74
+ message: ex_arr[1].to_s
75
+ })
76
+ trace.status = "error"
77
+ end
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+
84
+ # Cache reads/writes → custom cache spans. hit is known for cache_read.
85
+ def subscribe_cache
86
+ ::ActiveSupport::Notifications.subscribe(/\Acache_(read|fetch_hit|write|delete)\.active_support\z/) do |*args|
87
+ begin
88
+ trace = Context.current
89
+ next if trace.nil?
90
+
91
+ event = event_of(args)
92
+ name = event.name.to_s
93
+ payload = event.payload
94
+ op, hit = cache_op(name, payload)
95
+ meta = { kind: "cache", op: op }
96
+ meta[:hit] = hit unless hit.nil?
97
+ Helpers.add_span("custom", "cache #{op}", event.duration, meta: meta, source_location: nil)
98
+ rescue StandardError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+
104
+ # ActiveRecord transaction notifications (Rails 7.2+) → transaction spans.
105
+ def subscribe_transaction
106
+ ::ActiveSupport::Notifications.subscribe("transaction.active_record") do |*args|
107
+ begin
108
+ trace = Context.current
109
+ next if trace.nil?
110
+
111
+ event = event_of(args)
112
+ Helpers.add_span("custom", "DB transaction", event.duration,
113
+ meta: { kind: "transaction" }, source_location: nil)
114
+ rescue StandardError
115
+ nil
116
+ end
117
+ end
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ def cache_op(name, payload)
123
+ case name
124
+ when "cache_read.active_support"
125
+ ["read", payload.key?(:hit) ? (payload[:hit] ? true : false) : nil]
126
+ when "cache_fetch_hit.active_support"
127
+ ["read", true]
128
+ when "cache_write.active_support"
129
+ ["write", nil]
130
+ when "cache_delete.active_support"
131
+ ["delete", nil]
132
+ else
133
+ [name.split(".").first.sub("cache_", ""), nil]
134
+ end
135
+ end
136
+
137
+ def row_count_from_payload(payload)
138
+ row_count = payload[:row_count]
139
+ return { rowCount: row_count.to_i } if row_count
140
+
141
+ result = payload[:result]
142
+ n = Helpers.extract_row_count(result)
143
+ n.nil? ? nil : { rowCount: n }
144
+ rescue StandardError
145
+ nil
146
+ end
147
+
148
+ # Build an ActiveSupport::Notifications::Event from the raw subscribe args.
149
+ def event_of(args)
150
+ ::ActiveSupport::Notifications::Event.new(*args)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ module Instrument
5
+ # Shared helpers used by every low-level patch (PG, mysql2, Net::HTTP, …).
6
+ # These attach spans to the *current* trace only; if there's no active trace
7
+ # (work outside a request), they no-op. Never throw.
8
+ module Helpers
9
+ module_function
10
+
11
+ # Add a span to the active trace. `duration_ms` is a float; description is
12
+ # e.g. the SQL text or "GET https://host/path".
13
+ def add_span(type, description, duration_ms, meta: nil, source_location: :auto)
14
+ trace = Context.current
15
+ return if trace.nil? || description.nil? || description.to_s.empty?
16
+
17
+ loc = source_location == :auto ? SourceLocation.nearest : source_location
18
+ span = Span.new(
19
+ type: type,
20
+ description: description.to_s,
21
+ started_at: MetrixWire.iso8601(Time.now - (duration_ms / 1000.0)),
22
+ duration_ms: [duration_ms, 0].max.round,
23
+ source_location: loc,
24
+ meta: (meta && !meta.empty? ? meta : nil)
25
+ )
26
+ trace.add_span(span)
27
+ rescue StandardError
28
+ # instrumentation must never throw
29
+ end
30
+
31
+ # Time a block, then record a db_query span. Returns the block's result.
32
+ def db_query(sql)
33
+ return yield if Context.current.nil?
34
+
35
+ t0 = MetrixWire.monotonic_ms
36
+ result = yield
37
+ add_span("db_query", sql, MetrixWire.monotonic_ms - t0, meta: row_count_meta(result))
38
+ result
39
+ rescue StandardError
40
+ raise
41
+ end
42
+
43
+ def row_count_meta(result)
44
+ n = extract_row_count(result)
45
+ n.nil? ? nil : { rowCount: n }
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ # Best-effort row count across PG::Result / Mysql2::Result / Array / etc.
51
+ def extract_row_count(result)
52
+ if result.respond_to?(:cmd_tuples) && result.respond_to?(:ntuples)
53
+ # PG::Result: prefer affected rows for writes, tuple count for reads.
54
+ nt = result.ntuples
55
+ return nt if nt && nt > 0
56
+
57
+ ct = result.cmd_tuples
58
+ return ct if ct && ct >= 0
59
+
60
+ nt
61
+ elsif result.respond_to?(:count) && !result.is_a?(String)
62
+ result.count
63
+ elsif result.respond_to?(:size)
64
+ result.size
65
+ end
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ module Instrument
5
+ # Best-effort patch of the `mysql2` gem (Mysql2::Client#query). Guarded:
6
+ # skips silently if the gem isn't loaded.
7
+ module Mysql2
8
+ module_function
9
+
10
+ def install
11
+ return unless defined?(::Mysql2::Client)
12
+ return if ::Mysql2::Client.instance_variable_get(:@__metrixwire_patched)
13
+
14
+ ::Mysql2::Client.prepend(Patch)
15
+ ::Mysql2::Client.instance_variable_set(:@__metrixwire_patched, true)
16
+ rescue StandardError
17
+ nil
18
+ end
19
+
20
+ module Patch
21
+ def query(*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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module MetrixWire
6
+ module Instrument
7
+ # Patches Net::HTTP#request so outbound HTTP calls become http_call spans
8
+ # with the response status code. Covers Net::HTTP directly and libraries
9
+ # built on it. Skips requests to the MetrixWire ingest endpoint itself so we
10
+ # never trace our own telemetry.
11
+ module NetHttp
12
+ module_function
13
+
14
+ def install
15
+ return if ::Net::HTTP.instance_variable_get(:@__metrixwire_patched)
16
+
17
+ ::Net::HTTP.prepend(Patch)
18
+ ::Net::HTTP.instance_variable_set(:@__metrixwire_patched, true)
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ module Patch
24
+ def request(req, body = nil, &block)
25
+ return super if Context.current.nil?
26
+
27
+ desc = MetrixWire::Instrument::NetHttp.describe(self, req)
28
+ if desc.nil? || MetrixWire::Instrument::NetHttp.own_endpoint?(desc)
29
+ return super
30
+ end
31
+
32
+ t0 = MetrixWire.monotonic_ms
33
+ resp = super
34
+ status = begin
35
+ resp.respond_to?(:code) ? resp.code.to_i : nil
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ Helpers.add_span("http_call", desc, MetrixWire.monotonic_ms - t0,
40
+ meta: (status ? { statusCode: status } : nil))
41
+ resp
42
+ rescue StandardError => e
43
+ raise e
44
+ end
45
+ end
46
+
47
+ # Build "GET https://host/path" from the connection + request objects.
48
+ def describe(http, req)
49
+ method = req.respond_to?(:method) ? req.method : "GET"
50
+ scheme = http.respond_to?(:use_ssl?) && http.use_ssl? ? "https" : "http"
51
+ host = http.respond_to?(:address) ? http.address : nil
52
+ port = http.respond_to?(:port) ? http.port : nil
53
+ return nil if host.nil?
54
+
55
+ path = req.respond_to?(:path) ? req.path.to_s.split("?").first : "/"
56
+ hostport = default_port?(scheme, port) ? host : "#{host}:#{port}"
57
+ "#{method} #{scheme}://#{hostport}#{path}"
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def default_port?(scheme, port)
63
+ (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
64
+ rescue StandardError
65
+ true
66
+ end
67
+
68
+ def own_endpoint?(desc)
69
+ ep = MetrixWire.endpoint
70
+ return false if ep.nil? || ep.empty?
71
+
72
+ host = URI.parse(ep).host
73
+ host && desc.include?("//#{host}") || (host && desc.include?("//#{host}:"))
74
+ rescue StandardError
75
+ false
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ module Instrument
5
+ # Patches the `pg` gem (PG::Connection) so raw queries outside Rails become
6
+ # db_query spans with a row count. Guarded: skips silently if `pg` isn't
7
+ # loaded. Also detects BEGIN…COMMIT/ROLLBACK to emit a transaction span.
8
+ module Pg
9
+ module_function
10
+
11
+ def install
12
+ return unless defined?(::PG::Connection)
13
+ return if ::PG::Connection.instance_variable_get(:@__metrixwire_patched)
14
+
15
+ ::PG::Connection.prepend(Patch)
16
+ ::PG::Connection.instance_variable_set(:@__metrixwire_patched, true)
17
+ rescue StandardError
18
+ nil
19
+ end
20
+
21
+ # A per-connection cursor tracking an open transaction's start time.
22
+ module Patch
23
+ %i[exec exec_params async_exec sync_exec query].each do |m|
24
+ define_method(m) do |*args, &blk|
25
+ sql = args.first
26
+ return super(*args, &blk) if Context.current.nil? || !sql.is_a?(String)
27
+
28
+ MetrixWire::Instrument::Pg.handle_transaction(self, sql)
29
+
30
+ t0 = MetrixWire.monotonic_ms
31
+ result = super(*args, &blk)
32
+ unless MetrixWire::Instrument::Pg.control_statement?(sql)
33
+ Helpers.add_span("db_query", sql, MetrixWire.monotonic_ms - t0,
34
+ meta: Helpers.row_count_meta(result))
35
+ end
36
+ result
37
+ rescue StandardError
38
+ # If our bookkeeping fails, still run the real query.
39
+ super(*args, &blk)
40
+ end
41
+ end
42
+ end
43
+
44
+ # Emit a transaction span on COMMIT/ROLLBACK, timed from the BEGIN.
45
+ def handle_transaction(conn, sql)
46
+ stmt = sql.strip
47
+ if /\A(BEGIN|START\s+TRANSACTION)\b/i.match?(stmt)
48
+ conn.instance_variable_set(:@__metrixwire_tx_start, MetrixWire.monotonic_ms)
49
+ elsif /\A(COMMIT|ROLLBACK)\b/i.match?(stmt)
50
+ started = conn.instance_variable_get(:@__metrixwire_tx_start)
51
+ conn.instance_variable_set(:@__metrixwire_tx_start, nil)
52
+ if started
53
+ Helpers.add_span("custom", "DB transaction", MetrixWire.monotonic_ms - started,
54
+ meta: { kind: "transaction" })
55
+ end
56
+ end
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def control_statement?(sql)
62
+ /\A\s*(BEGIN|START\s+TRANSACTION|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.match?(sql)
63
+ rescue StandardError
64
+ false
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetrixWire
4
+ module Instrument
5
+ # Auto-inserts {MetrixWire::Rack} into any Rack app that isn't Rails, by
6
+ # patching Rack::Builder#to_app to wrap the built app once. This is what
7
+ # makes Sinatra / bare Rack "zero-wiring": the user never adds `use`.
8
+ #
9
+ # Rails is handled by the Railtie instead (this patch skips when Rails is
10
+ # defined so we don't double-wrap).
11
+ module RackBuilder
12
+ module_function
13
+
14
+ def install
15
+ return unless defined?(::Rack::Builder)
16
+ return if ::Rack::Builder.instance_variable_get(:@__metrixwire_patched)
17
+
18
+ ::Rack::Builder.prepend(Patch)
19
+ ::Rack::Builder.instance_variable_set(:@__metrixwire_patched, true)
20
+ rescue StandardError
21
+ nil
22
+ end
23
+
24
+ module Patch
25
+ def to_app
26
+ app = super
27
+ return app if app.nil?
28
+ return app if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
29
+
30
+ # Wrap once. Marking the built app prevents re-wrapping if to_app runs
31
+ # more than once (e.g. nested builders).
32
+ return app if app.instance_variable_get(:@__metrixwire_wrapped)
33
+
34
+ wrapped = MetrixWire::Rack.new(app)
35
+ wrapped.instance_variable_set(:@__metrixwire_wrapped, true)
36
+ wrapped
37
+ rescue StandardError
38
+ super
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end