forge_ops_tracker 0.12.0 → 0.14.0
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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +110 -0
- data/lib/forge_ops_tracker/change.rb +47 -0
- data/lib/forge_ops_tracker/change_snapshot.rb +133 -0
- data/lib/forge_ops_tracker/client.rb +17 -0
- data/lib/forge_ops_tracker/configuration.rb +41 -0
- data/lib/forge_ops_tracker/delivery_queue.rb +5 -2
- data/lib/forge_ops_tracker/performance_instrumentation.rb +32 -0
- data/lib/forge_ops_tracker/query_explainer.rb +249 -0
- data/lib/forge_ops_tracker/railtie.rb +24 -2
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +47 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b7f1809933ffeccac536f117de7c234002826fe29414fc3fa92f12c22bb1a18
|
|
4
|
+
data.tar.gz: 84a9edadbaeb72dbe62164a84f26c7f23b6a600f176126ca82370709dc72b5c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca419d26d17f08e4544bb213c2949dedf7001402f163a15b0def3c0942ffcc534637911d1bc7e8890f6a3dc556f174a3ecea703351fe291d492c1c151838e3f9
|
|
7
|
+
data.tar.gz: 6890f1adbbf73ae399699ee3482a005bbfee521909c6af6acedb8a805a2aa69c3d46f75bf33a3a4722be9f0e6e84a0263850729e42380d1d1b26e23e50f285cb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0 (2026-09-25)
|
|
4
|
+
|
|
5
|
+
- Every Active Record database span now carries the query's SQL (`db.statement`), masked so every
|
|
6
|
+
string and number is `?` and bind values are never sent, and the database it ran on
|
|
7
|
+
(`db.system`: `postgresql`, `mysql`, `sqlite`, `mssql`, `oracle`, or the adapter's own name).
|
|
8
|
+
- `ForgeOpsTracker.span` takes optional `statement:` and `db_system:` on a `kind: "database"`
|
|
9
|
+
span, masked the same way.
|
|
10
|
+
- New opt-in `config.explain_slow_queries` (default false) and `config.explain_threshold_ms`
|
|
11
|
+
(default 500). On PostgreSQL, a single plain `SELECT` at least that slow gets a plain
|
|
12
|
+
`EXPLAIN (FORMAT JSON)` (never `EXPLAIN ANALYZE`), run on a background thread with its own
|
|
13
|
+
pooled connection, in a read-only transaction with a 2 second statement timeout that is always
|
|
14
|
+
rolled back. The plan is sent with every string in it masked. Limited to once per 10 minutes per
|
|
15
|
+
distinct query and 10 per minute per process; plans over 64 KB are dropped; any failure is
|
|
16
|
+
logged at debug and never raised.
|
|
17
|
+
|
|
18
|
+
## 0.13.0 (2026-09-25)
|
|
19
|
+
|
|
20
|
+
- New `ForgeOpsTracker.record_change(kind:, title:, details: {}, environment: nil, service: nil,
|
|
21
|
+
actor: nil, url: nil, id: nil, occurred_at: nil)` records something that changed in your system
|
|
22
|
+
(a feature flag, a config value, a hand-run migration) so ForgeOps can show it next to the errors
|
|
23
|
+
that followed. `kind` is one of `feature_flag`, `config`, `migration`, `dependency`,
|
|
24
|
+
`infrastructure`, or `other`; anything else is sent as `other`. Sent from a background thread,
|
|
25
|
+
never raises, and a no-op wherever reporting is off.
|
|
26
|
+
- Changes between deploys are now detected automatically. Once per process, after Rails finishes
|
|
27
|
+
booting (or on `ForgeOpsTracker.configure` outside Rails), a background thread sends a snapshot of
|
|
28
|
+
the Ruby version, the gem versions in `Gemfile.lock`, and the current Active Record migration
|
|
29
|
+
version, and ForgeOps records whatever changed since the previous boot. New
|
|
30
|
+
`config.detect_changes` (default true) turns this off.
|
|
31
|
+
- New `config.track_env_var_names` (default false) adds environment variable names, never values,
|
|
32
|
+
to that snapshot. Host-specific names (`HOSTNAME`, `PATH`, `PORT`, `LC_*`, Kubernetes service
|
|
33
|
+
variables, and others) and the gem's own `FORGE_OPS_*` settings are always left out.
|
|
34
|
+
|
|
3
35
|
## 0.12.0
|
|
4
36
|
|
|
5
37
|
- Every error event reported during a request now says where it happened: `transaction_name` (the
|
data/README.md
CHANGED
|
@@ -411,6 +411,116 @@ Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn
|
|
|
411
411
|
simply rejected server-side and dropped, exactly like any other delivery failure. The `trace_id`
|
|
412
412
|
on error events is sent either way.
|
|
413
413
|
|
|
414
|
+
### The SQL behind each database span
|
|
415
|
+
|
|
416
|
+
Every Active Record query span carries the query's SQL and the database it ran on, so a slow trace
|
|
417
|
+
shows exactly which query was slow. The SQL is masked before it leaves your process: every string
|
|
418
|
+
and number is replaced by `?`, so `WHERE email = 'a@b.co' AND id = 42` is sent as
|
|
419
|
+
`WHERE email = ? AND id = ?`, and bind values are never sent at all. ForgeOps masks it again on
|
|
420
|
+
arrival. The database comes from the connection's adapter (`postgresql`, `mysql`, `sqlite`,
|
|
421
|
+
`mssql`, `oracle`, or the adapter's own name otherwise).
|
|
422
|
+
|
|
423
|
+
Spans you create yourself can carry SQL too. Pass `statement:` (and optionally `db_system:`) on a
|
|
424
|
+
`kind: "database"` span; it's masked the same way:
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
sql = ActiveRecord::Base.sanitize_sql_array(
|
|
428
|
+
[ "SELECT * FROM orders WHERE customer_id = ? AND status = ?", customer.id, "open" ]
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
ForgeOpsTracker.span("Load open orders", kind: "database", statement: sql, db_system: "postgresql") do
|
|
432
|
+
ReportingDatabase.connection.select_all(sql)
|
|
433
|
+
end
|
|
434
|
+
# Sent as "SELECT * FROM orders WHERE customer_id = ? AND status = ?"
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Query plans for slow queries (PostgreSQL)
|
|
438
|
+
|
|
439
|
+
Opt in, and a slow `SELECT` also sends Postgres's plan for it, so ForgeOps can show why it was
|
|
440
|
+
slow (a sequential scan over a big table, a missing index, a bad join order):
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
ForgeOpsTracker.configure do |config|
|
|
444
|
+
config.explain_slow_queries = true # default false
|
|
445
|
+
config.explain_threshold_ms = 500 # default 500; only queries at least this slow
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
What it does:
|
|
450
|
+
|
|
451
|
+
- For a query that took at least `explain_threshold_ms`, runs `EXPLAIN (FORMAT JSON)` with the
|
|
452
|
+
same SQL and bind values, and sends the plan with the query's masked SQL. Every string inside the
|
|
453
|
+
plan (a filter like `(email = 'a@b.co'::text)`) is masked the same way before it's sent. A plan
|
|
454
|
+
over 64 KB is dropped rather than sent.
|
|
455
|
+
- Runs on the gem's own background thread, on a connection it checks out of your app's pool itself,
|
|
456
|
+
inside its own `READ ONLY` transaction with a 2 second `statement_timeout`, always rolled back.
|
|
457
|
+
Never on the connection your request is using, and never inside your request's transaction.
|
|
458
|
+
- At most once per 10 minutes for each distinct (masked) query, and at most 10 per minute per
|
|
459
|
+
process overall.
|
|
460
|
+
|
|
461
|
+
What it doesn't do:
|
|
462
|
+
|
|
463
|
+
- It never runs `EXPLAIN ANALYZE`, so your query is planned again but never executed again.
|
|
464
|
+
- It only explains a single plain `SELECT`: nothing that starts with anything else (including
|
|
465
|
+
`WITH`), nothing with a second statement, no `FOR UPDATE`/`FOR SHARE` (or `FOR NO KEY UPDATE`/
|
|
466
|
+
`FOR KEY SHARE`), and nothing with `INSERT`, `UPDATE`, `DELETE` or `MERGE` anywhere in it.
|
|
467
|
+
- It only runs on PostgreSQL. Other databases are left alone.
|
|
468
|
+
- It never raises into your app. A timeout, a permission error, an exhausted connection pool or
|
|
469
|
+
anything else is logged at debug level and skipped.
|
|
470
|
+
- The original SQL and its bind values are only held in memory until the `EXPLAIN` runs. Only the
|
|
471
|
+
masked SQL and the masked plan are sent.
|
|
472
|
+
|
|
473
|
+
It does use one pooled connection, briefly, for each plan. With the limits above that's at most 10
|
|
474
|
+
short checkouts a minute, but on a pool that's already at capacity, leave it off or raise
|
|
475
|
+
`explain_threshold_ms`. Plans are part of performance monitoring; on a plan without it, they're
|
|
476
|
+
rejected server-side and dropped.
|
|
477
|
+
|
|
478
|
+
## What changed
|
|
479
|
+
|
|
480
|
+
ForgeOps can show what changed in your system next to the errors and slowdowns that followed it.
|
|
481
|
+
Two ways in:
|
|
482
|
+
|
|
483
|
+
**Record a change yourself** when something changes that no deploy captures, like a feature flag
|
|
484
|
+
flipped, a config value edited, or a migration run by hand:
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
ForgeOpsTracker.record_change(
|
|
488
|
+
kind: "feature_flag", # feature_flag, config, migration, dependency, infrastructure, or other
|
|
489
|
+
title: "Enabled new_checkout for 10% of users",
|
|
490
|
+
details: { flag: "new_checkout", rollout_percent: 10 },
|
|
491
|
+
actor: current_user.email,
|
|
492
|
+
url: "https://flags.example.com/new_checkout"
|
|
493
|
+
)
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`kind` and `title` are required; `details`, `environment` (defaults to `config.environment`),
|
|
497
|
+
`service`, `actor`, `url`, `id` (an idempotency key, so sending the same change twice records it
|
|
498
|
+
once), and `occurred_at` (defaults to now) are optional. An unknown `kind` is sent as `other`. It's
|
|
499
|
+
queued and sent from a background thread, so it never slows down the caller, never raises, and is
|
|
500
|
+
a no-op wherever reporting is off.
|
|
501
|
+
|
|
502
|
+
**Changes between deploys are detected for you.** Once per process, right after Rails finishes
|
|
503
|
+
booting, a background thread sends ForgeOps a snapshot of what the app is running: the Ruby
|
|
504
|
+
version, every gem version in your `Gemfile.lock`, and the current Active Record migration version
|
|
505
|
+
(left out if the database can't be reached). ForgeOps compares it with the previous boot's and
|
|
506
|
+
records whatever changed, such as a gem upgrade or a new migration. Outside Rails the snapshot goes
|
|
507
|
+
out when you call `ForgeOpsTracker.configure`, without the migration version.
|
|
508
|
+
|
|
509
|
+
```ruby
|
|
510
|
+
ForgeOpsTracker.configure do |config|
|
|
511
|
+
config.detect_changes = true # default; false sends no startup snapshot
|
|
512
|
+
config.track_env_var_names = false # default; true also sends environment variable names
|
|
513
|
+
end
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
With `track_env_var_names` on, the snapshot lists the names of your environment variables (never
|
|
517
|
+
their values), so an added or removed variable shows up as a change. Names that differ from host to
|
|
518
|
+
host, like `HOSTNAME`, `PATH`, `PORT`, `LC_*`, and Kubernetes service variables, are left out, as
|
|
519
|
+
are the gem's own `FORGE_OPS_*` settings.
|
|
520
|
+
|
|
521
|
+
Requires a ForgeOps plan that includes change tracking; on a plan that doesn't, both are rejected
|
|
522
|
+
server-side and dropped, exactly like any other delivery failure.
|
|
523
|
+
|
|
414
524
|
## Custom metrics
|
|
415
525
|
|
|
416
526
|
Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Builds the body for one ForgeOpsTracker.record_change call (POST /api/v1/changes): something
|
|
5
|
+
# that changed in a running system (a feature flag flipped, a config value edited, a migration
|
|
6
|
+
# run by hand), so ForgeOps can line it up against the errors and slowdowns around it.
|
|
7
|
+
module Change
|
|
8
|
+
# The server rejects any other kind outright (422), so an unknown one is sent as "other"
|
|
9
|
+
# rather than dropped: the change still gets recorded, just less specifically categorized.
|
|
10
|
+
KINDS = %w[feature_flag config migration dependency infrastructure other].freeze
|
|
11
|
+
MAX_TITLE_LENGTH = 200
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Returns nil when there's nothing sendable (a blank title): the server requires one, so
|
|
16
|
+
# posting it anyway would only ever come back 422.
|
|
17
|
+
def build(configuration, kind:, title:, details: {}, environment: nil, service: nil, actor: nil, url: nil, id: nil, occurred_at: nil)
|
|
18
|
+
title = title.to_s.strip
|
|
19
|
+
return nil if title.empty?
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
kind: normalize_kind(kind),
|
|
23
|
+
title: title[0, MAX_TITLE_LENGTH],
|
|
24
|
+
details: details.is_a?(Hash) ? details : {},
|
|
25
|
+
environment: (environment || configuration.environment)&.to_s,
|
|
26
|
+
service: service&.to_s,
|
|
27
|
+
actor: actor&.to_s,
|
|
28
|
+
url: url&.to_s,
|
|
29
|
+
id: id&.to_s,
|
|
30
|
+
occurred_at: iso8601(occurred_at || Time.now)
|
|
31
|
+
}.compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_kind(kind)
|
|
35
|
+
kind = kind.to_s
|
|
36
|
+
KINDS.include?(kind) ? kind : "other"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def iso8601(value)
|
|
40
|
+
case value
|
|
41
|
+
when Time then value.utc.iso8601(3)
|
|
42
|
+
when String then value
|
|
43
|
+
else value.respond_to?(:to_time) ? value.to_time.utc.iso8601(3) : Time.now.utc.iso8601(3)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Tells ForgeOps what this process sees at startup (the Ruby version, the locked gem versions, the
|
|
5
|
+
# current Active Record migration version, and optionally the names of its environment variables),
|
|
6
|
+
# once per process, from a background thread. The server diffs it against the last snapshot for
|
|
7
|
+
# the same project and environment and records whatever changed as a change, so this gem stays
|
|
8
|
+
# stateless: it never remembers what it sent last time.
|
|
9
|
+
#
|
|
10
|
+
# Any key that can't be determined reliably is left out rather than guessed at, since the server
|
|
11
|
+
# reads a missing key as "unknown", never as "everything was removed".
|
|
12
|
+
class ChangeSnapshot
|
|
13
|
+
MAX_DEPENDENCIES = 3000
|
|
14
|
+
MAX_NAME_LENGTH = 200
|
|
15
|
+
MAX_VERSION_LENGTH = 100
|
|
16
|
+
MAX_ENV_VAR_NAMES = 3000
|
|
17
|
+
|
|
18
|
+
# Host-specific variables that differ from one box to the next (or one boot to the next) without
|
|
19
|
+
# anything about the deploy having changed, so a fleet doesn't look like it's changing on every
|
|
20
|
+
# restart. The gem's own FORGE_OPS_* settings are dropped too.
|
|
21
|
+
ENV_VAR_DENYLIST = %w[
|
|
22
|
+
HOSTNAME HOST HOME PATH PWD OLDPWD SHLVL _ TERM USER LOGNAME SHELL LANG TMPDIR TZ PORT DYNO
|
|
23
|
+
INVOCATION_ID JOURNAL_STREAM
|
|
24
|
+
].freeze
|
|
25
|
+
ENV_VAR_DENYLIST_PATTERNS = [
|
|
26
|
+
/\ALC_/, /\ASYSTEMD_/, /\AMEMORY_PRESSURE_/, /\AKUBERNETES_/, /\AFORGE_OPS_/,
|
|
27
|
+
/_SERVICE_HOST\z/, /_SERVICE_PORT/, /_PORT_.*_TCP/
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
MUTEX = Mutex.new
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Starts the one snapshot this process sends, on its own thread so it never delays boot. A
|
|
34
|
+
# second call is a no-op, so the Railtie and a plain ForgeOpsTracker.configure can't both
|
|
35
|
+
# send one. Does nothing (and doesn't use up the once) when the gem isn't enabled or
|
|
36
|
+
# detect_changes is off. Never raises.
|
|
37
|
+
def start(configuration)
|
|
38
|
+
return false unless configuration.enabled? && configuration.detect_changes
|
|
39
|
+
|
|
40
|
+
MUTEX.synchronize do
|
|
41
|
+
return false if @started
|
|
42
|
+
|
|
43
|
+
@started = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
thread = Thread.new { new(configuration).deliver }
|
|
47
|
+
thread.report_on_exception = false
|
|
48
|
+
true
|
|
49
|
+
rescue StandardError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Names only, never values, sorted so the same set always serializes the same way.
|
|
54
|
+
def env_var_names(env = ENV)
|
|
55
|
+
env.keys.reject { |name| denied_env_var?(name) }.sort.first(MAX_ENV_VAR_NAMES)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def denied_env_var?(name)
|
|
59
|
+
ENV_VAR_DENYLIST.include?(name) || ENV_VAR_DENYLIST_PATTERNS.any? { |pattern| pattern.match?(name) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(configuration, client: Client.new(configuration))
|
|
64
|
+
@configuration = configuration
|
|
65
|
+
@client = client
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def deliver
|
|
69
|
+
client.deliver_change_snapshot(payload)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
configuration.logger&.debug { "[ForgeOpsTracker] change snapshot failed: #{e.class}: #{e.message}" }
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def payload
|
|
76
|
+
{
|
|
77
|
+
environment: configuration.environment.to_s,
|
|
78
|
+
state: {
|
|
79
|
+
schema_version: schema_version,
|
|
80
|
+
runtime: runtime,
|
|
81
|
+
dependencies: dependencies,
|
|
82
|
+
env_var_names: configuration.track_env_var_names ? self.class.env_var_names : nil
|
|
83
|
+
}.compact
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def runtime
|
|
88
|
+
"#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The app's Gemfile.lock (through Bundler, so it's the exact resolved versions), falling back to
|
|
92
|
+
# whatever gems are actually activated when there's no bundle at all. nil, and so left out,
|
|
93
|
+
# when neither is available.
|
|
94
|
+
def dependencies
|
|
95
|
+
specs = bundler_specs || Gem.loaded_specs.values
|
|
96
|
+
return nil if specs.empty?
|
|
97
|
+
|
|
98
|
+
specs.each_with_object({}) do |spec, deps|
|
|
99
|
+
deps[spec.name.to_s[0, MAX_NAME_LENGTH]] = spec.version.to_s[0, MAX_VERSION_LENGTH]
|
|
100
|
+
end.sort.first(MAX_DEPENDENCIES).to_h
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The newest migration Active Record has run against this app's database, as a string. Left
|
|
106
|
+
# out when Active Record isn't loaded, the database can't be reached, or nothing has been
|
|
107
|
+
# migrated yet: a snapshot must never fail (or fail boot) because the database is down.
|
|
108
|
+
def schema_version
|
|
109
|
+
return nil unless defined?(::ActiveRecord::Base)
|
|
110
|
+
|
|
111
|
+
version = ::ActiveRecord::Base.connection_pool.with_connection do |connection|
|
|
112
|
+
pool = ::ActiveRecord::Base.connection_pool
|
|
113
|
+
context = pool.respond_to?(:migration_context) ? pool.migration_context : connection.migration_context
|
|
114
|
+
context.current_version
|
|
115
|
+
end
|
|
116
|
+
version.to_i.positive? ? version.to_s : nil
|
|
117
|
+
rescue StandardError, ScriptError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
attr_reader :configuration, :client
|
|
123
|
+
|
|
124
|
+
def bundler_specs
|
|
125
|
+
return nil unless defined?(::Bundler)
|
|
126
|
+
|
|
127
|
+
specs = ::Bundler.locked_gems&.specs
|
|
128
|
+
specs.nil? || specs.empty? ? nil : specs
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -60,6 +60,23 @@ module ForgeOpsTracker
|
|
|
60
60
|
post(configuration.spans_uri, { trace_id: trace_id, spans: spans })
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Same delivery contract again; see Configuration#changes_uri and ForgeOpsTracker.record_change.
|
|
64
|
+
# One change per call. A plan without change tracking answers 403, which is just a false here.
|
|
65
|
+
def deliver_change(payload)
|
|
66
|
+
post(configuration.changes_uri, payload)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Same again; see Configuration#change_snapshots_uri and ChangeSnapshot.
|
|
70
|
+
def deliver_change_snapshot(payload)
|
|
71
|
+
post(configuration.change_snapshots_uri, payload)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Same again; see Configuration#query_plans_uri and QueryExplainer. One plan per call. A plan
|
|
75
|
+
# without performance monitoring answers 403, which is just a false here.
|
|
76
|
+
def deliver_query_plan(payload)
|
|
77
|
+
post(configuration.query_plans_uri, payload)
|
|
78
|
+
end
|
|
79
|
+
|
|
63
80
|
private
|
|
64
81
|
attr_reader :configuration
|
|
65
82
|
|
|
@@ -17,7 +17,9 @@ module ForgeOpsTracker
|
|
|
17
17
|
attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
|
|
18
18
|
attr_accessor :gauge_poll_interval
|
|
19
19
|
attr_accessor :track_tracing, :trace_capture_threshold_ms
|
|
20
|
+
attr_accessor :explain_slow_queries, :explain_threshold_ms
|
|
20
21
|
attr_accessor :propagate_traces, :trace_propagation_targets
|
|
22
|
+
attr_accessor :detect_changes, :track_env_var_names
|
|
21
23
|
|
|
22
24
|
def initialize
|
|
23
25
|
@dsn = ENV["FORGE_OPS_DSN"]
|
|
@@ -125,6 +127,13 @@ module ForgeOpsTracker
|
|
|
125
127
|
# PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
|
|
126
128
|
# many requests, this describes a single one being outright slow.
|
|
127
129
|
@trace_capture_threshold_ms = 1_000
|
|
130
|
+
# Opt-in: for a SELECT on PostgreSQL that took at least explain_threshold_ms, ask Postgres for
|
|
131
|
+
# its plan (plain EXPLAIN, never EXPLAIN ANALYZE, so the query is never run again) and send
|
|
132
|
+
# it, masked, so ForgeOps can show why the query was slow. Off by default because it runs one
|
|
133
|
+
# extra statement against the app's own database. See QueryExplainer for exactly what it
|
|
134
|
+
# does and doesn't do, and its rate limits.
|
|
135
|
+
@explain_slow_queries = false
|
|
136
|
+
@explain_threshold_ms = 500
|
|
128
137
|
# Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
|
|
129
138
|
# Net::HTTP request made during a request, so the service on the other end can continue this
|
|
130
139
|
# request's trace instead of starting its own (see Integrations::NetHTTP). On by default, the
|
|
@@ -138,6 +147,15 @@ module ForgeOpsTracker
|
|
|
138
147
|
# Regexp is matched against the host. Worth narrowing for an app that calls third-party APIs
|
|
139
148
|
# which reject unknown headers, or that shouldn't learn this app's trace ids at all.
|
|
140
149
|
@trace_propagation_targets = nil
|
|
150
|
+
# Sends one ChangeSnapshot per process at startup (Ruby version, locked gem versions, the
|
|
151
|
+
# current migration version), which ForgeOps diffs against the previous boot's to record what
|
|
152
|
+
# changed between deploys. On by default, like every other automatic behavior here.
|
|
153
|
+
@detect_changes = true
|
|
154
|
+
# Whether that snapshot also lists the names of this process's environment variables, so an
|
|
155
|
+
# added or removed variable shows up as a change. Off by default: names only, never values,
|
|
156
|
+
# but even names can describe more about an app than some teams want to share. See
|
|
157
|
+
# ChangeSnapshot::ENV_VAR_DENYLIST for the host-specific names that are always left out.
|
|
158
|
+
@track_env_var_names = false
|
|
141
159
|
end
|
|
142
160
|
|
|
143
161
|
# Whether an outbound request to host should carry a traceparent header; see
|
|
@@ -231,11 +249,34 @@ module ForgeOpsTracker
|
|
|
231
249
|
uri
|
|
232
250
|
end
|
|
233
251
|
|
|
252
|
+
def query_plans_uri
|
|
253
|
+
sibling_uri("/query_plans")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def changes_uri
|
|
257
|
+
sibling_uri("/changes")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def change_snapshots_uri
|
|
261
|
+
sibling_uri("/change_snapshots")
|
|
262
|
+
end
|
|
263
|
+
|
|
234
264
|
def enabled?
|
|
235
265
|
!blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
|
|
236
266
|
end
|
|
237
267
|
|
|
238
268
|
private
|
|
269
|
+
# Same substitution every *_uri method above spells out by hand: a sibling path under the
|
|
270
|
+
# DSN's own /api/v1/events.
|
|
271
|
+
def sibling_uri(path)
|
|
272
|
+
uri = ingestion_uri
|
|
273
|
+
return nil unless uri
|
|
274
|
+
|
|
275
|
+
uri = uri.dup
|
|
276
|
+
uri.path = uri.path.sub(%r{/events\z}, path)
|
|
277
|
+
uri
|
|
278
|
+
end
|
|
279
|
+
|
|
239
280
|
def parsed_dsn
|
|
240
281
|
return nil if blank?(dsn)
|
|
241
282
|
|
|
@@ -8,9 +8,12 @@ module ForgeOpsTracker
|
|
|
8
8
|
# in each forked Puma/Passenger worker process rather than being carried
|
|
9
9
|
# across a fork, which would leave it dead in the child.
|
|
10
10
|
class DeliveryQueue
|
|
11
|
-
|
|
11
|
+
# deliver_with names the Client method each payload goes through: :deliver (error events) by
|
|
12
|
+
# default, :deliver_change for ForgeOpsTracker.record_change's own queue.
|
|
13
|
+
def initialize(configuration, client: Client.new(configuration), deliver_with: :deliver)
|
|
12
14
|
@configuration = configuration
|
|
13
15
|
@client = client
|
|
16
|
+
@deliver_with = deliver_with
|
|
14
17
|
@queue = SizedQueue.new(configuration.queue_size)
|
|
15
18
|
@mutex = Mutex.new
|
|
16
19
|
@thread = nil
|
|
@@ -44,7 +47,7 @@ module ForgeOpsTracker
|
|
|
44
47
|
|
|
45
48
|
def run
|
|
46
49
|
loop do
|
|
47
|
-
client.
|
|
50
|
+
client.public_send(@deliver_with, @queue.pop)
|
|
48
51
|
rescue StandardError => e
|
|
49
52
|
log { "delivery thread error: #{e.class}: #{e.message}" }
|
|
50
53
|
end
|
|
@@ -70,6 +70,38 @@ module ForgeOpsTracker
|
|
|
70
70
|
name.nil? || name.empty? ? "SQL" : name
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Active Record's adapter names, as ForgeOps' "db.system" values. Anything not listed here is
|
|
74
|
+
# sent as its own adapter name, lowercased.
|
|
75
|
+
DB_SYSTEMS = {
|
|
76
|
+
"postgresql" => "postgresql", "postgis" => "postgresql",
|
|
77
|
+
"mysql" => "mysql", "mysql2" => "mysql", "trilogy" => "mysql",
|
|
78
|
+
"sqlite" => "sqlite", "sqlite3" => "sqlite",
|
|
79
|
+
"sqlserver" => "mssql",
|
|
80
|
+
"oracle" => "oracle", "oracleenhanced" => "oracle"
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
# The "data" a database span carries: the query's SQL, masked by SqlStatement (every string
|
|
84
|
+
# and number literal becomes "?", so no value the app queried with is ever sent), and which
|
|
85
|
+
# database it ran on, from the connection's adapter. Either is left out when it isn't known.
|
|
86
|
+
def database_span_data(payload)
|
|
87
|
+
{
|
|
88
|
+
"db.statement" => SqlStatement.mask(payload[:sql].to_s),
|
|
89
|
+
"db.system" => db_system(payload[:connection])
|
|
90
|
+
}.compact
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# payload[:connection] is the adapter instance the query ran on (Rails 6.0 and later).
|
|
94
|
+
def db_system(connection)
|
|
95
|
+
return nil unless connection.respond_to?(:adapter_name)
|
|
96
|
+
|
|
97
|
+
name = connection.adapter_name.to_s.downcase
|
|
98
|
+
return nil if name.empty?
|
|
99
|
+
|
|
100
|
+
DB_SYSTEMS.fetch(name, name)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
73
105
|
def job_transaction_name(payload)
|
|
74
106
|
payload[:job].class.name
|
|
75
107
|
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "thread"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ForgeOpsTracker
|
|
6
|
+
# Opt-in (configuration.explain_slow_queries, default false): asks PostgreSQL for the plan of a
|
|
7
|
+
# slow SELECT and sends it, masked, to ForgeOps so a slow query shows why it was slow.
|
|
8
|
+
#
|
|
9
|
+
# What it never does, by design:
|
|
10
|
+
# - EXPLAIN ANALYZE. Plain EXPLAIN only plans the statement, it never executes it.
|
|
11
|
+
# - Touch the app's own connection or transaction. #consider only reads the notification payload
|
|
12
|
+
# and remembers the connection's pool; the EXPLAIN runs later, on this class's own background
|
|
13
|
+
# thread, on a connection it checks out of that pool itself, inside its own READ ONLY
|
|
14
|
+
# transaction with a 2 second statement_timeout, always rolled back.
|
|
15
|
+
# - Explain anything but a single plain SELECT (see .explainable?).
|
|
16
|
+
# - Raise. Every failure (a timeout, a permission error, an exhausted pool, a statement Postgres
|
|
17
|
+
# can't parse) is logged at debug and dropped.
|
|
18
|
+
# - Send the original statement or its bind values anywhere. Both are held in memory only until
|
|
19
|
+
# the EXPLAIN runs; what goes out is the masked statement (exactly as sent on the span) and the
|
|
20
|
+
# plan with every string in it masked the same way, since plan conditions quote literals
|
|
21
|
+
# ("Filter": "(email = 'a@b.co'::text)").
|
|
22
|
+
#
|
|
23
|
+
# Rate limited per process: each distinct masked statement at most once per 10 minutes, and at
|
|
24
|
+
# most 10 EXPLAINs per minute overall. Both are reserved when a query is accepted, not when its
|
|
25
|
+
# EXPLAIN succeeds, so a statement whose EXPLAIN keeps failing still can't be retried more often.
|
|
26
|
+
class QueryExplainer
|
|
27
|
+
STATEMENT_INTERVAL = 600.0
|
|
28
|
+
GLOBAL_WINDOW = 60.0
|
|
29
|
+
GLOBAL_LIMIT = 10
|
|
30
|
+
MAX_PLAN_BYTES = 64 * 1024
|
|
31
|
+
STATEMENT_TIMEOUT = "2s".freeze
|
|
32
|
+
CHECKOUT_TIMEOUT = 1
|
|
33
|
+
QUEUE_SIZE = GLOBAL_LIMIT
|
|
34
|
+
# Past this many remembered statements, the ones whose 10 minutes are up are forgotten, so an
|
|
35
|
+
# app with endless distinct statements can't grow this without bound.
|
|
36
|
+
MAX_REMEMBERED_STATEMENTS = 1_000
|
|
37
|
+
|
|
38
|
+
# One pass, left to right, so a quote inside a comment or comment syntax inside a string can't
|
|
39
|
+
# confuse the other: string literals (single-quoted and dollar-quoted) become "?", comments
|
|
40
|
+
# become a space.
|
|
41
|
+
LITERALS_AND_COMMENTS = %r{
|
|
42
|
+
'(?:[^']|'')*(?:'|\z)
|
|
43
|
+
| (?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z)
|
|
44
|
+
| /\*.*?(?:\*/|\z)
|
|
45
|
+
| --[^\n]*
|
|
46
|
+
}mx
|
|
47
|
+
LOCKING_CLAUSE = /\bFOR\s+(?:NO\s+KEY\s+UPDATE|KEY\s+SHARE|UPDATE|SHARE)\b/i
|
|
48
|
+
DATA_MODIFYING = /\b(?:INSERT|UPDATE|DELETE|MERGE)\b/i
|
|
49
|
+
|
|
50
|
+
Job = Struct.new(:sql, :binds, :pool, :statement, :duration_ms, :captured_at, keyword_init: true)
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# True on this class's own worker thread while it's running an EXPLAIN. Railtie's
|
|
54
|
+
# sql.active_record subscriber skips those queries entirely, so the EXPLAIN (and its BEGIN,
|
|
55
|
+
# SET LOCAL and ROLLBACK) never shows up as the app's own query timing, breadcrumbs or spans.
|
|
56
|
+
def explaining?
|
|
57
|
+
Thread.current[:forge_ops_tracker_explaining] == true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether statement is a single plain SELECT: after dropping comments and string literals,
|
|
61
|
+
# it starts with SELECT, has no ";" other than one trailing one, no locking clause (FOR
|
|
62
|
+
# UPDATE, FOR SHARE, FOR NO KEY UPDATE, FOR KEY SHARE), and no INSERT, UPDATE, DELETE or
|
|
63
|
+
# MERGE anywhere (so no data-modifying CTE either).
|
|
64
|
+
def explainable?(statement)
|
|
65
|
+
return false unless statement.is_a?(String)
|
|
66
|
+
|
|
67
|
+
sql = statement.gsub(LITERALS_AND_COMMENTS) { |match| match.start_with?("'", "$") ? "?" : " " }.strip
|
|
68
|
+
return false unless sql.match?(/\ASELECT\b/i)
|
|
69
|
+
return false if sql.delete_suffix(";").include?(";")
|
|
70
|
+
|
|
71
|
+
!sql.match?(LOCKING_CLAUSE) && !sql.match?(DATA_MODIFYING)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Masks every String inside a parsed EXPLAIN (FORMAT JSON) result with SqlStatement, leaving
|
|
75
|
+
# keys, numbers, booleans, arrays and the structure itself exactly as they were.
|
|
76
|
+
def mask_plan(value)
|
|
77
|
+
case value
|
|
78
|
+
when Hash then value.transform_values { |inner| mask_plan(inner) }
|
|
79
|
+
when Array then value.map { |inner| mask_plan(inner) }
|
|
80
|
+
when String then SqlStatement.mask(value) || value
|
|
81
|
+
else value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize(configuration, client: Client.new(configuration), clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
87
|
+
@configuration = configuration
|
|
88
|
+
@client = client
|
|
89
|
+
@clock = clock
|
|
90
|
+
@queue = SizedQueue.new(QUEUE_SIZE)
|
|
91
|
+
@mutex = Mutex.new
|
|
92
|
+
@thread = nil
|
|
93
|
+
@last_explained = {}
|
|
94
|
+
@recent = []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Called from the sql.active_record subscriber with that event's payload and duration. Cheap
|
|
98
|
+
# checks first, so a fast query costs one comparison. Returns true only when an EXPLAIN was
|
|
99
|
+
# queued; never raises.
|
|
100
|
+
def consider(payload, duration_ms:)
|
|
101
|
+
return false unless configuration.explain_slow_queries
|
|
102
|
+
return false unless duration_ms && duration_ms >= configuration.explain_threshold_ms.to_f
|
|
103
|
+
|
|
104
|
+
connection = payload[:connection]
|
|
105
|
+
return false unless PerformanceInstrumentation.db_system(connection) == "postgresql"
|
|
106
|
+
|
|
107
|
+
sql = payload[:sql]
|
|
108
|
+
return false unless self.class.explainable?(sql)
|
|
109
|
+
|
|
110
|
+
pool = connection.respond_to?(:pool) ? connection.pool : nil
|
|
111
|
+
return false unless pool
|
|
112
|
+
|
|
113
|
+
statement = SqlStatement.mask(sql)
|
|
114
|
+
return false unless reserve(statement)
|
|
115
|
+
|
|
116
|
+
enqueue(Job.new(
|
|
117
|
+
sql: sql.dup, binds: bind_values(payload), pool: pool, statement: statement,
|
|
118
|
+
duration_ms: duration_ms.to_f.round(1), captured_at: Time.now.utc.iso8601(3)
|
|
119
|
+
))
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
log { "EXPLAIN skipped: #{e.class}: #{e.message}" }
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Runs one queued EXPLAIN and delivers its plan. Normally called on the worker thread; public so
|
|
126
|
+
# a test can run a job synchronously. Never raises.
|
|
127
|
+
def perform(job)
|
|
128
|
+
plan = explain(job)
|
|
129
|
+
return false unless plan
|
|
130
|
+
|
|
131
|
+
masked_plan = self.class.mask_plan(plan)
|
|
132
|
+
if JSON.generate(masked_plan).bytesize > MAX_PLAN_BYTES
|
|
133
|
+
log { "EXPLAIN plan over #{MAX_PLAN_BYTES} bytes, dropped" }
|
|
134
|
+
return false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
client.deliver_query_plan(
|
|
138
|
+
statement: job.statement, db_system: "postgresql", plan: masked_plan,
|
|
139
|
+
duration_ms: job.duration_ms, environment: configuration.environment, captured_at: job.captured_at
|
|
140
|
+
)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
log { "EXPLAIN failed: #{e.class}: #{e.message}" }
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
attr_reader :configuration, :client
|
|
148
|
+
|
|
149
|
+
# Both rate limits at once, under one lock, so two threads finishing the same slow query
|
|
150
|
+
# together still only explain it once.
|
|
151
|
+
def reserve(statement)
|
|
152
|
+
return false if statement.nil?
|
|
153
|
+
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
now = @clock.call
|
|
156
|
+
@recent.reject! { |at| now - at >= GLOBAL_WINDOW }
|
|
157
|
+
return false if @recent.size >= GLOBAL_LIMIT
|
|
158
|
+
|
|
159
|
+
last = @last_explained[statement]
|
|
160
|
+
return false if last && now - last < STATEMENT_INTERVAL
|
|
161
|
+
|
|
162
|
+
if @last_explained.size >= MAX_REMEMBERED_STATEMENTS
|
|
163
|
+
@last_explained.reject! { |_, at| now - at >= STATEMENT_INTERVAL }
|
|
164
|
+
end
|
|
165
|
+
@last_explained[statement] = now
|
|
166
|
+
@recent << now
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The values the app's own query was run with, already type cast for the database. Rails
|
|
172
|
+
# passes these either as an Array or as a lambda that builds one.
|
|
173
|
+
def bind_values(payload)
|
|
174
|
+
binds = payload[:type_casted_binds]
|
|
175
|
+
binds = binds.call if binds.respond_to?(:call)
|
|
176
|
+
Array(binds).dup
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def enqueue(job)
|
|
180
|
+
ensure_worker_started
|
|
181
|
+
@queue.push(job, true)
|
|
182
|
+
true
|
|
183
|
+
rescue ThreadError
|
|
184
|
+
log { "EXPLAIN queue full, dropping" }
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def ensure_worker_started
|
|
189
|
+
return if @thread&.alive?
|
|
190
|
+
|
|
191
|
+
@mutex.synchronize do
|
|
192
|
+
return if @thread&.alive?
|
|
193
|
+
|
|
194
|
+
@thread = Thread.new { run }
|
|
195
|
+
@thread.abort_on_exception = false
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def run
|
|
200
|
+
Thread.current[:forge_ops_tracker_explaining] = true
|
|
201
|
+
loop do
|
|
202
|
+
perform(@queue.pop)
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
log { "EXPLAIN thread error: #{e.class}: #{e.message}" }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The parsed plan (the JSON array Postgres returns), or nil. The connection is checked out
|
|
209
|
+
# explicitly rather than borrowed with with_connection, so it's never one this thread (or
|
|
210
|
+
# any other) already holds. Checked back in only once its transaction is rolled back; if
|
|
211
|
+
# even the ROLLBACK fails, the connection is removed from the pool and closed instead, so a
|
|
212
|
+
# connection in an unknown state is never handed to the app.
|
|
213
|
+
def explain(job)
|
|
214
|
+
connection = job.pool.checkout(CHECKOUT_TIMEOUT)
|
|
215
|
+
begin
|
|
216
|
+
connection.execute("BEGIN READ ONLY")
|
|
217
|
+
connection.execute("SET LOCAL statement_timeout = '#{STATEMENT_TIMEOUT}'")
|
|
218
|
+
result = connection.exec_query("EXPLAIN (FORMAT JSON) #{job.sql}", "EXPLAIN", job.binds)
|
|
219
|
+
value = result.rows.first&.first
|
|
220
|
+
value.is_a?(String) ? JSON.parse(value) : value
|
|
221
|
+
ensure
|
|
222
|
+
release(job.pool, connection, rollback(connection))
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def rollback(connection)
|
|
227
|
+
connection.execute("ROLLBACK")
|
|
228
|
+
true
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
log { "EXPLAIN rollback failed: #{e.class}: #{e.message}" }
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def release(pool, connection, healthy)
|
|
235
|
+
if healthy
|
|
236
|
+
pool.checkin(connection)
|
|
237
|
+
else
|
|
238
|
+
pool.remove(connection)
|
|
239
|
+
connection.disconnect!
|
|
240
|
+
end
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
log { "EXPLAIN connection release failed: #{e.class}: #{e.message}" }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def log
|
|
246
|
+
configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -28,6 +28,11 @@ module ForgeOpsTracker
|
|
|
28
28
|
def span_queue
|
|
29
29
|
@span_queue ||= ForgeOpsTracker::SpanQueue.new(ForgeOpsTracker.configuration)
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
# One per process, so its rate limits (see QueryExplainer) are per process too.
|
|
33
|
+
def query_explainer
|
|
34
|
+
@query_explainer ||= ForgeOpsTracker::QueryExplainer.new(ForgeOpsTracker.configuration)
|
|
35
|
+
end
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
|
|
@@ -167,6 +172,9 @@ module ForgeOpsTracker
|
|
|
167
172
|
end
|
|
168
173
|
|
|
169
174
|
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
175
|
+
# QueryExplainer's own EXPLAIN (and its BEGIN/SET LOCAL/ROLLBACK), on its own thread: not
|
|
176
|
+
# the app's query, so never timed, traced or explained itself.
|
|
177
|
+
next if QueryExplainer.explaining?
|
|
170
178
|
next unless configuration.enabled?
|
|
171
179
|
|
|
172
180
|
event = ActiveSupport::Notifications::Event.new(*args)
|
|
@@ -184,13 +192,19 @@ module ForgeOpsTracker
|
|
|
184
192
|
# A leaf span, parented onto whatever's currently open (the controller root, or a
|
|
185
193
|
# manually-wrapped ForgeOpsTracker.span block around it): a query has no children of its
|
|
186
194
|
# own to nest anything under, so unlike the controller root above, this never touches the
|
|
187
|
-
# stack itself, just records against whatever's already on top of it.
|
|
195
|
+
# stack itself, just records against whatever's already on top of it. Carries the masked
|
|
196
|
+
# SQL and the database system (see PerformanceInstrumentation.database_span_data).
|
|
188
197
|
if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
|
|
189
198
|
buffer.record(
|
|
190
199
|
span_id: SecureRandom.hex(8), name: transaction_name, kind: "database",
|
|
191
|
-
started_at: Time.at(event.time), duration_ms: event.duration
|
|
200
|
+
started_at: Time.at(event.time), duration_ms: event.duration,
|
|
201
|
+
data: PerformanceInstrumentation.database_span_data(event.payload)
|
|
192
202
|
)
|
|
193
203
|
end
|
|
204
|
+
|
|
205
|
+
# Opt-in, PostgreSQL only; a no-op unless config.explain_slow_queries is on. Only reads the
|
|
206
|
+
# payload here; the EXPLAIN itself runs later on QueryExplainer's own thread and connection.
|
|
207
|
+
Railtie.query_explainer.consider(event.payload, duration_ms: event.duration) if configuration.explain_slow_queries
|
|
194
208
|
end
|
|
195
209
|
|
|
196
210
|
ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
|
|
@@ -305,6 +319,14 @@ module ForgeOpsTracker
|
|
|
305
319
|
# sidekiq, ActiveJob's redis queue adapter, ...), which this gem has no control over; deferring
|
|
306
320
|
# the defined? check to after every railtie (including this app's own dependencies) has
|
|
307
321
|
# finished initializing avoids a load-order-dependent false negative.
|
|
322
|
+
# After initialization, not in a plain initializer, so the app's own
|
|
323
|
+
# config/initializers/forge_ops_tracker.rb has already run (detect_changes/track_env_var_names
|
|
324
|
+
# and the DSN are final) and Active Record is configured enough to report the migration
|
|
325
|
+
# version. ChangeSnapshot does the actual work on its own thread, so boot never waits on it.
|
|
326
|
+
config.after_initialize do
|
|
327
|
+
ForgeOpsTracker::ChangeSnapshot.start(ForgeOpsTracker.configuration)
|
|
328
|
+
end
|
|
329
|
+
|
|
308
330
|
config.after_initialize do
|
|
309
331
|
next unless defined?(::RedisClient)
|
|
310
332
|
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -6,6 +6,8 @@ require "forge_ops_tracker/sql_statement"
|
|
|
6
6
|
require "forge_ops_tracker/event_builder"
|
|
7
7
|
require "forge_ops_tracker/client"
|
|
8
8
|
require "forge_ops_tracker/delivery_queue"
|
|
9
|
+
require "forge_ops_tracker/change"
|
|
10
|
+
require "forge_ops_tracker/change_snapshot"
|
|
9
11
|
require "forge_ops_tracker/error_subscriber"
|
|
10
12
|
require "forge_ops_tracker/session_flusher"
|
|
11
13
|
require "forge_ops_tracker/histogram_bucketer"
|
|
@@ -18,6 +20,7 @@ require "forge_ops_tracker/periodic_poller"
|
|
|
18
20
|
require "forge_ops_tracker/breadcrumb_buffer"
|
|
19
21
|
require "forge_ops_tracker/span_buffer"
|
|
20
22
|
require "forge_ops_tracker/span_queue"
|
|
23
|
+
require "forge_ops_tracker/query_explainer"
|
|
21
24
|
require "forge_ops_tracker/trace_parent"
|
|
22
25
|
require "forge_ops_tracker/request_state"
|
|
23
26
|
require "forge_ops_tracker/middleware/session_tracking"
|
|
@@ -32,8 +35,33 @@ module ForgeOpsTracker
|
|
|
32
35
|
@configuration ||= Configuration.new
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
# Outside Rails this is also where the startup ChangeSnapshot goes out, since there's no
|
|
39
|
+
# Railtie to send it once the app has booted; under Rails the Railtie sends it instead, after
|
|
40
|
+
# initialization, when Active Record can report the migration version.
|
|
35
41
|
def configure
|
|
36
42
|
yield configuration
|
|
43
|
+
ChangeSnapshot.start(configuration) unless defined?(::Rails::Railtie)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Records one thing that changed in a running system, e.g.
|
|
47
|
+
# `ForgeOpsTracker.record_change(kind: "feature_flag", title: "Enabled new_checkout for 10%")`,
|
|
48
|
+
# so ForgeOps can show it next to the errors and slowdowns that followed. kind is one of
|
|
49
|
+
# Change::KINDS (anything else is sent as "other"); environment defaults to the configured one.
|
|
50
|
+
# Delivered from a background thread like error events, so it never blocks the caller, and a
|
|
51
|
+
# no-op when the gem isn't enabled. Never raises: returns false when nothing was queued.
|
|
52
|
+
def record_change(kind:, title:, details: {}, environment: nil, service: nil, actor: nil, url: nil, id: nil, occurred_at: nil)
|
|
53
|
+
return false unless configuration.enabled?
|
|
54
|
+
|
|
55
|
+
payload = Change.build(
|
|
56
|
+
configuration, kind: kind, title: title, details: details, environment: environment,
|
|
57
|
+
service: service, actor: actor, url: url, id: id, occurred_at: occurred_at
|
|
58
|
+
)
|
|
59
|
+
return false unless payload
|
|
60
|
+
|
|
61
|
+
change_queue.push(payload)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
configuration.logger&.debug { "[ForgeOpsTracker] record_change failed: #{e.class}: #{e.message}" }
|
|
64
|
+
false
|
|
37
65
|
end
|
|
38
66
|
|
|
39
67
|
# Records a named business metric (a signup, a payment, anything a customer wants to name),
|
|
@@ -101,12 +129,18 @@ module ForgeOpsTracker
|
|
|
101
129
|
# a standalone buffer for itself: a lone span with no request-level trace around it, and no
|
|
102
130
|
# middleware left running to ever flush it, would just accumulate in this thread's memory
|
|
103
131
|
# forever with nothing to ever send it, which is worse than never recording it at all.
|
|
104
|
-
|
|
132
|
+
#
|
|
133
|
+
# A span with kind: "database" can also carry the SQL it ran and which database it ran on, e.g.
|
|
134
|
+
# `ForgeOpsTracker.span("Load orders", kind: "database", statement: sql, db_system: "postgresql")`.
|
|
135
|
+
# The statement is masked by SqlStatement before it's kept (every string and number becomes
|
|
136
|
+
# "?"), so no value it was run with is ever sent. Both are ignored on any other kind of span.
|
|
137
|
+
def span(name, kind: "service", data: {}, statement: nil, db_system: nil)
|
|
105
138
|
return yield unless configuration.enabled? && configuration.track_tracing
|
|
106
139
|
|
|
107
140
|
buffer = Thread.current[:forge_ops_tracker_spans]
|
|
108
141
|
return yield unless buffer
|
|
109
142
|
|
|
143
|
+
data = database_span_data(data, statement, db_system) if kind.to_s == "database"
|
|
110
144
|
span_id = SecureRandom.hex(8)
|
|
111
145
|
started_at = Time.now.utc
|
|
112
146
|
buffer.push(span_id)
|
|
@@ -120,6 +154,18 @@ module ForgeOpsTracker
|
|
|
120
154
|
end
|
|
121
155
|
|
|
122
156
|
private
|
|
157
|
+
def database_span_data(data, statement, db_system)
|
|
158
|
+
extra = {
|
|
159
|
+
"db.statement" => (SqlStatement.mask(statement.to_s) unless statement.nil?),
|
|
160
|
+
"db.system" => (db_system.to_s.strip.downcase unless db_system.nil? || db_system.to_s.strip.empty?)
|
|
161
|
+
}.compact
|
|
162
|
+
extra.empty? ? data : data.merge(extra)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def change_queue
|
|
166
|
+
@change_queue ||= DeliveryQueue.new(configuration, deliver_with: :deliver_change)
|
|
167
|
+
end
|
|
168
|
+
|
|
123
169
|
def metric_buffer
|
|
124
170
|
@metric_buffer ||= MetricBuffer.new(configuration)
|
|
125
171
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forge_ops_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ForgeOps
|
|
@@ -90,6 +90,8 @@ files:
|
|
|
90
90
|
- README.md
|
|
91
91
|
- lib/forge_ops_tracker.rb
|
|
92
92
|
- lib/forge_ops_tracker/breadcrumb_buffer.rb
|
|
93
|
+
- lib/forge_ops_tracker/change.rb
|
|
94
|
+
- lib/forge_ops_tracker/change_snapshot.rb
|
|
93
95
|
- lib/forge_ops_tracker/client.rb
|
|
94
96
|
- lib/forge_ops_tracker/configuration.rb
|
|
95
97
|
- lib/forge_ops_tracker/delivery_queue.rb
|
|
@@ -114,6 +116,7 @@ files:
|
|
|
114
116
|
- lib/forge_ops_tracker/performance_instrumentation.rb
|
|
115
117
|
- lib/forge_ops_tracker/periodic_poller.rb
|
|
116
118
|
- lib/forge_ops_tracker/pii_scrubber.rb
|
|
119
|
+
- lib/forge_ops_tracker/query_explainer.rb
|
|
117
120
|
- lib/forge_ops_tracker/railtie.rb
|
|
118
121
|
- lib/forge_ops_tracker/request_state.rb
|
|
119
122
|
- lib/forge_ops_tracker/session_flusher.rb
|