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
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
|