scryer 1.2.2 → 1.3.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 +53 -1
- data/README.md +3 -1
- data/docs/rails-integration.md +181 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +21 -0
- data/lib/scryer/apm.rb +458 -0
- data/lib/scryer/railtie.rb +63 -4
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +48 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d7873062d73beb49e505138b100a9507d676b2d5a13838ba382fedb9ac31a2e
|
|
4
|
+
data.tar.gz: 6f9168a62835d3aba9a583720fcfea45c5271ddb24d16773023a298566522ad0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 784555c1e3b96c43ec90f3151fc1beeb2abe90d1905dd897b4a40b33c44a63d00173a07705f44fbefbfb98c53342f328fffb57d1f2d5c196002ee30c3037b9c1
|
|
7
|
+
data.tar.gz: cf1e78fa3112a1ad8cf28a8686ec59a8ce5e221945ef0b0eb3d26a9ea5c8b943bc36e49547de6bf6e5770623c5e57f1377c603a8d50401585a88a00eedb0d3ad
|
data/CHANGELOG.md
CHANGED
|
@@ -3,8 +3,60 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Format loosely follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
-
## [
|
|
6
|
+
## [1.3.0] - 2026-09-21
|
|
7
7
|
|
|
8
|
+
- **Added: `Scryer::APM`, runtime method-level tracing with New Relic and OpenTelemetry
|
|
9
|
+
integration** (see README's "Runtime method tracing (APM)" section in
|
|
10
|
+
`docs/rails-integration.md`). Wraps configured classes' own methods (`Module#prepend`, the same
|
|
11
|
+
technique `QueryWatcher` already uses — no `TracePoint`) and, when a supported provider's SDK is
|
|
12
|
+
loaded, creates a real nested span per call via *that SDK's own* public API: New Relic via
|
|
13
|
+
`NewRelic::Agent::Tracer.start_segment`, OpenTelemetry via
|
|
14
|
+
`OpenTelemetry.tracer_provider.tracer(...).start_span` with `OpenTelemetry::Context.attach`/
|
|
15
|
+
`.detach` for correct parent/child nesting. Only `instrumentation: :selective` is implemented
|
|
16
|
+
(`:discovery`/`:deep_trace` raise a clear "not implemented yet" error rather than silently
|
|
17
|
+
no-opping); only `provider: :new_relic` and `provider: :opentelemetry` are implemented (no
|
|
18
|
+
Datadog, no other vendor SDK). Zero new gem dependencies — both providers are soft-detected
|
|
19
|
+
(`defined?(NewRelic::Agent::Tracer)` / `defined?(OpenTelemetry::Trace)`), never required; a host
|
|
20
|
+
app adds whichever SDK it wants to its own Gemfile. 29 tests in `test/apm_test.rb` (nesting/
|
|
21
|
+
parent-child spans for both providers, exception handling, kwargs/blocks/visibility
|
|
22
|
+
preservation, inherited-method exclusion, duplicate-instrumentation guard, sampling, redaction,
|
|
23
|
+
thread isolation, New Relic segment creation + exporter-failure safety, OpenTelemetry span
|
|
24
|
+
creation + start-failure safety, Rack middleware, config flow-through) plus a new
|
|
25
|
+
`benchmark/apm_overhead.rb` with measured (not estimated) per-call overhead numbers — see the
|
|
26
|
+
doc section for actual figures and what they do/don't cover. `Scryer::Railtie` now also
|
|
27
|
+
auto-wires APM (`require`/`Scryer::APM.enable!`/middleware installation) whenever
|
|
28
|
+
`c.apm.enabled = true`, so that flag plus `c.apm.include` is genuinely sufficient in an
|
|
29
|
+
initializer — no separate manual wiring needed. This boot-time path is held to a stricter safety
|
|
30
|
+
bar than the rest of the public API: any failure while auto-enabling (a bad `include` entry, an
|
|
31
|
+
unimplemented `instrumentation` mode, a `require` failure) is caught — `rescue Exception`, not
|
|
32
|
+
just `StandardError`, deliberately, since a packaging-related `LoadError` is a `ScriptError` —
|
|
33
|
+
logged, and leaves APM cleanly disabled rather than ever failing app boot. Likewise, a single
|
|
34
|
+
method that fails to instrument (an internal error, not a config typo) no longer aborts the rest
|
|
35
|
+
of that class or `include` list. `Scryer::APM.enable!` called directly (console, tests) is
|
|
36
|
+
unchanged and still raises loudly for an unimplemented mode — only the automatic Railtie path
|
|
37
|
+
degrades gracefully. 5 more tests in `test/railtie_apm_test.rb` cover that boot-time path
|
|
38
|
+
directly (34 new tests total).
|
|
39
|
+
|
|
40
|
+
## [1.2.2] - 2026-09-12
|
|
41
|
+
|
|
42
|
+
- HTML report redesign: the top-of-report score panel is now a card (soft shadow, rounded
|
|
43
|
+
corners) instead of a flat box, the four grade badges are gradient-filled rounded squares
|
|
44
|
+
instead of plain circles, and the severity bars use a CSS grid for consistent label/track/count
|
|
45
|
+
alignment instead of flexbox eyeballing. Purely visual — no change to report content or data.
|
|
46
|
+
- Fixed: `Scryer::CLI#git` shelled out via backtick interpolation
|
|
47
|
+
(`` `git -C #{Shellwords.escape(root)} #{cmd}` ``) — flagged by Scryer's own `command_injection`
|
|
48
|
+
rule on a self-scan. Replaced with `Open3.capture3("git", "-C", root, *cmd)`, an argv array with
|
|
49
|
+
no shell involved at all, so there's nothing to escape or inject regardless of what `root`
|
|
50
|
+
contains. `cmd` callers now pass argv words instead of a pre-joined string.
|
|
51
|
+
- Added the `# frozen_string_literal: true` magic comment to every file under `lib/` that was
|
|
52
|
+
missing it (66 files) — this gem's own `frozen_string_literal` rule fired on itself.
|
|
53
|
+
- Fixed: `unbounded_table_scan` and `n_plus_one_query` false-positived on any bare
|
|
54
|
+
`Const.all`/`.where`/`.order`-shaped call, even when `Const` was a plain Ruby module/class with
|
|
55
|
+
no ActiveRecord ancestry (e.g. this gem's own `RuleSet.all.each`) — neither rule consulted
|
|
56
|
+
`known_models`/`known_non_models` at all. Both now gate their match through
|
|
57
|
+
`Ast.likely_model_name?`, same as `IdorRule` already did. `Scanner#collect_class_declarations`
|
|
58
|
+
also now records every `module X` declaration as a "definitely not a model" signal (a bare
|
|
59
|
+
module can never be `< ActiveRecord::Base`), alongside the existing no-superclass-class signal.
|
|
8
60
|
- **Breaking (report shape): one blended `Security Score` replaced with four independent
|
|
9
61
|
scores** — `security_score`, `performance_score`, `style_score`, and `dependency_score`
|
|
10
62
|
(`ReportRenderer#category_score` runs the same severity+confidence-weighted formula for each,
|
data/README.md
CHANGED
|
@@ -120,7 +120,9 @@ each one longer-form than a single section here, cross-linked from wherever it's
|
|
|
120
120
|
performance numbers, the security model, false-positive handling, the accuracy benchmark, and
|
|
121
121
|
the full Scryer vs RuboCop vs Brakeman vs bundler-audit comparison.
|
|
122
122
|
- [**Rails Integration**](docs/rails-integration.md) — the runtime query/authorization watchers,
|
|
123
|
-
|
|
123
|
+
runtime method-level tracing (New Relic or OpenTelemetry, for finding what an APM tool's own
|
|
124
|
+
"Other"/"Application Code" time actually is), the `scryer:install` generator, and testing
|
|
125
|
+
Scryer's own results in your app's test suite.
|
|
124
126
|
- [**Contributing & Releasing**](docs/contributing.md) — how to add a new rule, and how this
|
|
125
127
|
gem's own release process works.
|
|
126
128
|
|
data/docs/rails-integration.md
CHANGED
|
@@ -91,6 +91,187 @@ Two honesty points worth being precise about:
|
|
|
91
91
|
this class doesn't attempt.
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
## Runtime method tracing (APM)
|
|
95
|
+
|
|
96
|
+
The problem `QueryWatcher` and `AuthorizationWatcher` don't touch: once a request lands inside
|
|
97
|
+
application code — a service object, a repository, a background-job method — most APM tools
|
|
98
|
+
(New Relic included) can only see it as one undifferentiated block of "Application Code" or
|
|
99
|
+
"Other" time. `Scryer::APM` answers "which method inside that block actually took the time" by
|
|
100
|
+
wrapping configured classes' own methods (the same `Module#prepend` technique `QueryWatcher` uses
|
|
101
|
+
on `ActiveRecord::QueryMethods`, not `TracePoint`) and, when a supported APM agent/SDK is loaded,
|
|
102
|
+
creating a real nested span per call via *that agent's own* public API — never an undocumented
|
|
103
|
+
endpoint or a separate exporter of Scryer's own. **Two providers are implemented:**
|
|
104
|
+
|
|
105
|
+
- **`:new_relic`** — a real nested segment per call via
|
|
106
|
+
`NewRelic::Agent::Tracer.start_segment`/`Segment#finish`, the same mechanism `add_method_tracer`
|
|
107
|
+
itself uses internally.
|
|
108
|
+
- **`:opentelemetry`** — a real nested span per call via
|
|
109
|
+
`OpenTelemetry.tracer_provider.tracer(...).start_span`, with
|
|
110
|
+
`OpenTelemetry::Context.attach`/`.detach` used to make each span the "current" one so the next
|
|
111
|
+
nested instrumented call picks it up as its parent automatically — the SDK's own documented
|
|
112
|
+
manual-span API (the one used whenever the block form, `Tracer#in_span`, doesn't fit).
|
|
113
|
+
|
|
114
|
+
Neither SDK is a hard dependency of this gem (see the gemspec: zero runtime deps beyond stdlib) —
|
|
115
|
+
both are soft-detected via `defined?(...)`. If you set `c.apm.provider = :opentelemetry`, add
|
|
116
|
+
`opentelemetry-sdk` (plus whatever exporter you want, e.g. `opentelemetry-exporter-otlp`) to your
|
|
117
|
+
own app's `Gemfile`, exactly the way you already add `newrelic_rpm` yourself to use `:new_relic`.
|
|
118
|
+
If the configured provider's SDK isn't loaded, spans are still traced (available via
|
|
119
|
+
`Scryer::APM.on_span`) but nothing is exported anywhere — a warning is logged once, the
|
|
120
|
+
instrumented methods keep working normally either way.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# config/initializers/scryer.rb
|
|
124
|
+
Scryer.configure do |c|
|
|
125
|
+
c.apm.enabled = true
|
|
126
|
+
c.apm.provider = :opentelemetry # or :new_relic
|
|
127
|
+
c.apm.instrumentation = :selective # only mode implemented so far — see Limitations below
|
|
128
|
+
c.apm.include = %w[BookingService PaymentService InventoryService]
|
|
129
|
+
c.apm.exclude = %w[] # Scryer:: / ActiveRecord:: / ActionController:: / ActionDispatch:: / Rails::
|
|
130
|
+
# are always excluded regardless of this list
|
|
131
|
+
c.apm.sampling_rate = 0.10 # 0.0–1.0, decided once per request, not per span
|
|
132
|
+
c.apm.capture_exception_messages = false # class name only by default; see Redaction below
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That's genuinely the whole thing — `c.apm.enabled = true` is sufficient by itself, no separate
|
|
137
|
+
`require`/`.enable!`/`middleware.use` calls needed. `Scryer::Railtie` (loaded automatically as
|
|
138
|
+
soon as the gem is in your `Gemfile`) checks `c.apm.enabled` in a Rails initializer that runs
|
|
139
|
+
*after* every file in `config/initializers/` — including this one — has already run, and does the
|
|
140
|
+
`require "scryer/apm"` / `Scryer::APM.enable!` / `Rails.application.config.middleware.use
|
|
141
|
+
Scryer::APM::Middleware` sequence for you (`Scryer::Railtie.maybe_enable_apm`, tested directly in
|
|
142
|
+
`test/railtie_apm_test.rb`) if and only if that flag is true. Leaving `c.apm.enabled` at its
|
|
143
|
+
default (`false`) — or omitting the `c.apm` block entirely — means none of this runs and there is
|
|
144
|
+
no measurable overhead, same as never having required this file at all.
|
|
145
|
+
|
|
146
|
+
**The one thing that genuinely can't be defaulted: `c.apm.include` must still be populated.**
|
|
147
|
+
There is no "trace every method automatically" mode — an empty `include` (the default) means
|
|
148
|
+
`enabled = true` turns the machinery on but instruments nothing, by design (see Production safety
|
|
149
|
+
below for why "instrument every method in the app" is deliberately not offered).
|
|
150
|
+
|
|
151
|
+
Outside a request (a Sidekiq job, a rake task), open a trace scope yourself the same way
|
|
152
|
+
`QueryWatcher.watch` works for queries:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
Scryer::APM.trace { SomeJob.new.perform }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### What gets instrumented, precisely
|
|
159
|
+
|
|
160
|
+
`c.apm.include` takes class/module name strings. For each one, **only methods defined directly on
|
|
161
|
+
that class** (`instance_methods(false)` + private/protected equivalents) are wrapped — methods
|
|
162
|
+
*inherited* from a superclass or module are never touched, even if the named class itself has none
|
|
163
|
+
of its own. This is deliberate, not a current limitation: instrumenting inherited methods via a
|
|
164
|
+
bare class-name entry is exactly how `include: ["SomeModel"]` would end up silently wrapping
|
|
165
|
+
`ActiveRecord::Base` internals, which is the "sensitive framework internals" risk a production
|
|
166
|
+
instrumentation tool has to avoid by construction, not by asking users to get their `exclude` list
|
|
167
|
+
right. Method visibility (public/private/protected) is preserved exactly. Positional args, keyword
|
|
168
|
+
args, blocks, and return values all pass through `super` unchanged; exceptions of every class
|
|
169
|
+
(not just `StandardError`) are recorded on the span and always re-raised, never swallowed.
|
|
170
|
+
|
|
171
|
+
### What each span records
|
|
172
|
+
|
|
173
|
+
`class_name`, `method_name`, `file`/`line` (captured once when the method is wrapped, not per
|
|
174
|
+
call), `trace_id`/`span_id`/`parent_span_id` (nested calls get correct parent/child relationships
|
|
175
|
+
via a thread-local span stack — see the "nested calls" test in `test/apm_test.rb`), `duration_ms`,
|
|
176
|
+
`status` (`:ok`/`:error`), `exception_class`, `thread_id`. **Method argument values, local
|
|
177
|
+
variables, and return values are never recorded, under any configuration** — there is no opt-in
|
|
178
|
+
that changes this. `exception_message` is `nil` unless `capture_exception_messages: true`, and even
|
|
179
|
+
then passes through a best-effort (not exhaustive — see Limitations) redaction pass that masks
|
|
180
|
+
long token/hash-shaped substrings and email addresses before being kept.
|
|
181
|
+
|
|
182
|
+
### Production safety
|
|
183
|
+
|
|
184
|
+
- **`instrumentation: :off`** installs no hooks at all — measured overhead is within noise of not
|
|
185
|
+
requiring the file (see `benchmark/apm_overhead.rb`).
|
|
186
|
+
- **Sampling is per-trace, not per-span**: the decision is made once when `Scryer::APM.trace` opens
|
|
187
|
+
(or on the first instrumented call if a request somehow reaches one without a scope), so a
|
|
188
|
+
sampled request gets a complete, connected trace rather than a random scatter of orphaned spans.
|
|
189
|
+
- **Provider failures never break the traced method.** New Relic's `start_segment`/`finish`/
|
|
190
|
+
`notice_error` and OpenTelemetry's `start_span`/`finish`/`record_exception` are each wrapped in
|
|
191
|
+
their own `rescue StandardError` — see `test_new_relic_segment_start_failure_does_not_break_the_traced_method`
|
|
192
|
+
and `test_open_telemetry_span_start_failure_does_not_break_the_traced_method` in
|
|
193
|
+
`test/apm_test.rb`, which stub a failing `Tracer.start_segment`/`Tracer#start_span` and confirm
|
|
194
|
+
the real method's return value still comes through unaffected.
|
|
195
|
+
- **No custom export queue, batching, or retry/backoff exists in this module, and none is needed**:
|
|
196
|
+
spans are handed directly to the already-async, already-batched, already-retried agent/SDK
|
|
197
|
+
transport via its own span API — Scryer::APM never talks to a network endpoint itself. The one
|
|
198
|
+
thing this means: if your configured provider isn't loaded but you keep `Scryer::APM.on_span`
|
|
199
|
+
wired to your own sink, *that* sink is your responsibility to make non-blocking/bounded — nothing
|
|
200
|
+
here queues or batches on your behalf.
|
|
201
|
+
- **Calling `.instrument_by_name`/`.instrument_method` more than once for the same class/method is a
|
|
202
|
+
no-op** (a process-wide dedupe guard), so re-running an initializer (e.g., Rails' `to_prepare` in
|
|
203
|
+
development) can't double-wrap a method into two nested spans for one call.
|
|
204
|
+
- **A misconfiguration can never fail app boot, even in production.** This is the specific
|
|
205
|
+
guarantee `test/railtie_apm_test.rb` exists to prove, not just assert: a bad class name in
|
|
206
|
+
`include`, `instrumentation` set to an unimplemented mode, or any other failure while
|
|
207
|
+
`Scryer::Railtie` auto-enables APM (see "That's genuinely the whole thing" above) is caught —
|
|
208
|
+
broadly, `rescue Exception` rather than just `StandardError`, since a `require` failure from a
|
|
209
|
+
packaging issue would raise `LoadError`, a `ScriptError` — logged once, and leaves APM cleanly
|
|
210
|
+
disabled for that process; the rest of the app boots and runs normally. One method failing to
|
|
211
|
+
instrument (an internal error, not a bad config value) is scoped even tighter: only that method
|
|
212
|
+
is skipped, not the rest of its class or the rest of `include`. **This graceful-degradation bar
|
|
213
|
+
is specific to the automatic Railtie path** — `Scryer::APM.enable!` called directly (a console, a
|
|
214
|
+
test, a non-Rails script) still raises immediately for an unimplemented mode, which is the
|
|
215
|
+
correct behavior when a developer is watching and wants fast feedback rather than a silently
|
|
216
|
+
disabled feature.
|
|
217
|
+
|
|
218
|
+
### Measured overhead
|
|
219
|
+
|
|
220
|
+
From `benchmark/apm_overhead.rb` (Apple silicon dev machine, Ruby 3.4.8, 200,000 calls per
|
|
221
|
+
scenario — re-run this yourself before trusting these numbers for capacity planning on different
|
|
222
|
+
hardware):
|
|
223
|
+
|
|
224
|
+
| scenario | ns/call | vs. uninstrumented |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| baseline (no Scryer::APM at all) | ~28 ns | 1.0x |
|
|
227
|
+
| `instrumentation: :off` | ~28 ns | ~1.0x (no measurable difference) |
|
|
228
|
+
| `:selective`, inside a trace, `sampling_rate: 0.0` | ~172 ns | ~6.3x |
|
|
229
|
+
| `:selective`, inside a trace, `sampling_rate: 1.0` (full span) | ~1771 ns | ~64x |
|
|
230
|
+
|
|
231
|
+
The `:off` vs. unsampled-but-wrapped gap (~6x) is the fixed cost of the `prepend`/`define_method`
|
|
232
|
+
dispatch layer itself, before any span is even considered — this is the floor cost paid by every
|
|
233
|
+
call to a wrapped method regardless of sampling, which is why `include` should name specific
|
|
234
|
+
service/business-logic classes, not broad namespaces. Full span creation (~1771 ns, ~1.8 µs) is
|
|
235
|
+
still small in absolute terms next to typical service-method bodies (a DB query alone is usually
|
|
236
|
+
1–20+ ms), but at high call volume with `sampling_rate: 1.0` it is not free — this is exactly what
|
|
237
|
+
`sampling_rate` exists to bound. Retained-memory cost: 50,000 held `Span` objects (worst case — a
|
|
238
|
+
consumer that doesn't drain them) measured ~53 MB RSS growth, roughly ~1 KB/span.
|
|
239
|
+
|
|
240
|
+
### Limitations — implemented vs. not yet
|
|
241
|
+
|
|
242
|
+
**Implemented and tested** (see `test/apm_test.rb`, 29 examples, and `test/railtie_apm_test.rb`, 5
|
|
243
|
+
more): `:off` and `:selective` modes; nested parent/child spans; exception recording + re-raise;
|
|
244
|
+
keyword args/blocks/return values; method visibility preservation; inherited-method exclusion;
|
|
245
|
+
duplicate-instrumentation guard; per-class `exclude`; trace-scoped sampling; thread isolation
|
|
246
|
+
(concurrent requests on different threads never share trace/span state); New Relic segment
|
|
247
|
+
creation, error notification, and exporter-failure safety; OpenTelemetry span creation
|
|
248
|
+
(with correct parent/child nesting via `Context.attach`/`.detach`), exception recording, and
|
|
249
|
+
start-failure safety; the Rack middleware; reading config through `Scryer.configure`;
|
|
250
|
+
`c.apm.enabled = true` alone (no manual `require`/`.enable!`/`middleware.use`) genuinely
|
|
251
|
+
triggering tracing via the Railtie; a bad `include` entry or an unimplemented mode failing to
|
|
252
|
+
crash boot (see "Production safety" above); a single method's instrumentation failure not taking
|
|
253
|
+
out the rest of its class.
|
|
254
|
+
|
|
255
|
+
**Not implemented** — raises a clear `ArgumentError` naming this doc rather than silently
|
|
256
|
+
no-opping if configured:
|
|
257
|
+
- `instrumentation: :discovery` and `:deep_trace` modes (low-overhead profiling to *find* expensive
|
|
258
|
+
methods automatically, and extra detail captured only for slow/failed requests, respectively).
|
|
259
|
+
- Any provider other than `:new_relic` and `:opentelemetry` — no Datadog, no vendor-specific SDK
|
|
260
|
+
beyond those two.
|
|
261
|
+
- Singleton (class) method instrumentation — only instance methods.
|
|
262
|
+
- Distributed trace-context propagation across a process boundary (an outgoing HTTP call to
|
|
263
|
+
another service, or an inbound request that already carries a W3C `traceparent` header). This
|
|
264
|
+
module's own `trace_id`/`span_id` fields are local-process only. The `:opentelemetry` provider's
|
|
265
|
+
*spans* do still nest correctly with each other within one process via `Context.attach`/`.detach`
|
|
266
|
+
— what's missing is reading/writing the `traceparent` header itself to connect that local trace
|
|
267
|
+
to one already in progress elsewhere.
|
|
268
|
+
|
|
269
|
+
**Known caveat, not a bug**: `Scryer::APM.disable!` (test-only) clears configuration/dedupe state
|
|
270
|
+
but cannot un-`prepend` an already-instrumented class — Ruby has no supported way to do that. Tests
|
|
271
|
+
that need a clean slate instrument a freshly-defined, uniquely-named class per example rather than
|
|
272
|
+
reusing one across examples; a host app calling `.enable!` more than once in a running process
|
|
273
|
+
should expect the same constraint.
|
|
274
|
+
|
|
94
275
|
## Generators
|
|
95
276
|
|
|
96
277
|
Scryer ships one generator: `scryer:install`. Run `bin/rails generate scryer:install --help`
|
|
@@ -42,3 +42,24 @@ end
|
|
|
42
42
|
# Scryer::QueryWatcher.enable!
|
|
43
43
|
# Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
|
|
44
44
|
# end
|
|
45
|
+
|
|
46
|
+
# Runtime method-level tracing (see README's "Runtime method tracing (APM)")
|
|
47
|
+
# — finds which method inside a request is actually responsible for time
|
|
48
|
+
# an APM tool would otherwise lump into "Other"/"Application Code". Providers
|
|
49
|
+
# :new_relic and :opentelemetry (each soft-detected; no hard dependency added
|
|
50
|
+
# by enabling this — add whichever SDK you pick to your own Gemfile) and
|
|
51
|
+
# instrumentation :selective (named classes only) are implemented so far.
|
|
52
|
+
# `c.apm.enabled = true` below is genuinely sufficient on its own —
|
|
53
|
+
# Scryer::Railtie wires up the require/.enable!/middleware for you once this
|
|
54
|
+
# flag is true, after this file has already run. Safe to leave on in
|
|
55
|
+
# production — leaving `include` empty (or `enabled` false) has no
|
|
56
|
+
# measurable overhead, and sampling_rate bounds the cost of the classes you
|
|
57
|
+
# do name:
|
|
58
|
+
#
|
|
59
|
+
# Scryer.configure do |c|
|
|
60
|
+
# c.apm.enabled = true
|
|
61
|
+
# c.apm.provider = :opentelemetry # or :new_relic
|
|
62
|
+
# c.apm.instrumentation = :selective
|
|
63
|
+
# c.apm.include = %w[] # e.g. %w[BookingService PaymentService] — required, see README
|
|
64
|
+
# c.apm.sampling_rate = 0.10
|
|
65
|
+
# end
|
data/lib/scryer/apm.rb
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Scryer
|
|
5
|
+
# Runtime method-level tracing — answers "which method actually ate the
|
|
6
|
+
# time this New Relic (or any other APM) lumps into Others/Application
|
|
7
|
+
# Code". Built the same way Scryer::QueryWatcher already is: a
|
|
8
|
+
# `Module#prepend` generated per instrumented class, state kept in
|
|
9
|
+
# `Thread.current` (never a global mutable collection — concurrent
|
|
10
|
+
# requests on a multi-threaded server must never see each other's spans,
|
|
11
|
+
# the same lesson QueryWatcher's own docs call out), and zero new gem
|
|
12
|
+
# dependencies. No custom HTTP exporter of its own: when a supported APM
|
|
13
|
+
# agent/SDK is loaded (currently: the New Relic Ruby agent, or the
|
|
14
|
+
# opentelemetry-ruby SDK), spans are created through *that agent's own*
|
|
15
|
+
# public, documented API — `NewRelic::Agent::Tracer.start_segment` for
|
|
16
|
+
# New Relic, `OpenTelemetry.tracer_provider.tracer(...).start_span` for
|
|
17
|
+
# OpenTelemetry — so they nest inside that agent's own trace/transaction
|
|
18
|
+
# and ride its own already-async, already-batched, already-retried export
|
|
19
|
+
# pipeline. Either SDK is soft-detected (`defined?(...)`), never a hard
|
|
20
|
+
# gem dependency of Scryer itself — a host app that wants the
|
|
21
|
+
# `:opentelemetry` provider adds `opentelemetry-sdk` (and an exporter, e.g.
|
|
22
|
+
# `opentelemetry-exporter-otlp`) to its own Gemfile, same as it already
|
|
23
|
+
# does for `newrelic_rpm` today. This module doesn't need to (and
|
|
24
|
+
# doesn't) implement its own export queue, batching, or backoff, because
|
|
25
|
+
# it never talks to a network endpoint itself.
|
|
26
|
+
#
|
|
27
|
+
# Three things this deliberately does NOT do, all by design, not omission:
|
|
28
|
+
# - Capture method argument values, local variables, or return values.
|
|
29
|
+
# Only class name, method name, file:line (captured once at wrap
|
|
30
|
+
# time, not per call), timing, and exception *class* are recorded.
|
|
31
|
+
# See `capture_exception_messages` below for the one opt-in exception
|
|
32
|
+
# to "no payload data ever".
|
|
33
|
+
# - Instrument methods *inherited* from a superclass/module. Only
|
|
34
|
+
# methods defined directly on the named class/module
|
|
35
|
+
# (`instance_methods(false)`) are wrapped — instrumenting inherited
|
|
36
|
+
# framework methods (ActiveRecord::Base, etc.) via a plain class-name
|
|
37
|
+
# entry in `include` would be exactly the "sensitive framework
|
|
38
|
+
# internals" this module is required to avoid.
|
|
39
|
+
# - Implement :discovery or :deep_trace modes. Only :off and :selective
|
|
40
|
+
# exist so far — see README's "Runtime method tracing" section for
|
|
41
|
+
# what's planned vs. implemented.
|
|
42
|
+
class APM
|
|
43
|
+
# One method call. `parent_span_id` is nil for the outermost traced
|
|
44
|
+
# call in a given `.trace` block. `exception_message` is nil unless
|
|
45
|
+
# `capture_exception_messages: true` was configured AND `redact_exception_message`
|
|
46
|
+
# didn't blank it out.
|
|
47
|
+
Span = Struct.new(
|
|
48
|
+
:trace_id,
|
|
49
|
+
:span_id,
|
|
50
|
+
:parent_span_id,
|
|
51
|
+
:class_name,
|
|
52
|
+
:method_name,
|
|
53
|
+
:file,
|
|
54
|
+
:line,
|
|
55
|
+
:started_at, # Float, Process.clock_gettime(Process::CLOCK_MONOTONIC) — for duration math
|
|
56
|
+
:wall_time, # Time — for correlating against logs/other tools
|
|
57
|
+
:duration_ms,
|
|
58
|
+
:status, # :ok | :error
|
|
59
|
+
:exception_class,
|
|
60
|
+
:exception_message,
|
|
61
|
+
:thread_id,
|
|
62
|
+
keyword_init: true
|
|
63
|
+
) do
|
|
64
|
+
def to_h
|
|
65
|
+
super.transform_keys(&:to_s)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Turns tracing on for the life of the process. Idempotent (later
|
|
71
|
+
# calls are no-ops) — same contract as QueryWatcher.enable!. Reads
|
|
72
|
+
# unset keyword args from Scryer.configuration.apm so
|
|
73
|
+
# `Scryer.configure { |c| c.apm.foo = ... }; Scryer::APM.enable!`
|
|
74
|
+
# works without repeating every option.
|
|
75
|
+
def enable!(
|
|
76
|
+
provider: nil,
|
|
77
|
+
instrumentation: nil,
|
|
78
|
+
include: nil,
|
|
79
|
+
exclude: nil,
|
|
80
|
+
sampling_rate: nil,
|
|
81
|
+
capture_exception_messages: nil,
|
|
82
|
+
logger: nil
|
|
83
|
+
)
|
|
84
|
+
return if @enabled
|
|
85
|
+
|
|
86
|
+
cfg = Scryer.configuration.apm
|
|
87
|
+
@provider = provider || cfg.provider || :new_relic
|
|
88
|
+
@instrumentation = instrumentation || cfg.instrumentation || :selective
|
|
89
|
+
@include = Array(include || cfg.include).map(&:to_s)
|
|
90
|
+
@exclude = Array(exclude || cfg.exclude).map(&:to_s) + DEFAULT_EXCLUDE
|
|
91
|
+
@sampling_rate = (sampling_rate || cfg.sampling_rate || 1.0).to_f.clamp(0.0, 1.0)
|
|
92
|
+
@capture_exception_messages = capture_exception_messages.nil? ? !!cfg.capture_exception_messages : capture_exception_messages
|
|
93
|
+
@logger = logger || default_logger
|
|
94
|
+
@instrumented = {} # {[class_name, method_name] => true} — dedupe guard
|
|
95
|
+
@enabled = true
|
|
96
|
+
|
|
97
|
+
case @instrumentation
|
|
98
|
+
when :off
|
|
99
|
+
# Nothing to install — enabled? is true (so .trace/.instrument
|
|
100
|
+
# calls don't raise) but no method is ever wrapped, so overhead
|
|
101
|
+
# is the cost of one `if` per call site that checks this mode,
|
|
102
|
+
# nothing more.
|
|
103
|
+
when :selective
|
|
104
|
+
@include.each { |name| instrument_by_name(name) }
|
|
105
|
+
when :discovery, :deep_trace
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"Scryer::APM instrumentation: #{@instrumentation.inspect} is not implemented yet " \
|
|
108
|
+
"(only :off and :selective are). See README's \"Runtime method tracing\" section."
|
|
109
|
+
else
|
|
110
|
+
raise ArgumentError, "Scryer::APM instrumentation: unknown mode #{@instrumentation.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
warn_if_provider_unavailable
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def enabled?
|
|
117
|
+
!!@enabled
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Test-only: undoes .enable! bookkeeping. Does NOT un-prepend already
|
|
121
|
+
#-installed modules (Ruby has no supported way to do that) — tests
|
|
122
|
+
# that need a truly clean slate use a fresh, disposable class per
|
|
123
|
+
# example instead of instrumenting a shared one twice.
|
|
124
|
+
def disable!
|
|
125
|
+
@enabled = false
|
|
126
|
+
@instrumented = {}
|
|
127
|
+
@on_span = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def instrumentation_mode
|
|
131
|
+
@instrumentation
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Opens a new trace scope — call this once per unit of work (an HTTP
|
|
135
|
+
# request, a Sidekiq job, a rake task). Every span created by an
|
|
136
|
+
# instrumented method call inside the block shares this trace_id and
|
|
137
|
+
# nests under whatever span is currently open on this thread, via a
|
|
138
|
+
# thread-local stack (so nested `.trace` calls — a job that itself
|
|
139
|
+
# calls another instrumented method — behave correctly: the inner
|
|
140
|
+
# scope's spans still get the outer trace_id if one is already open,
|
|
141
|
+
# rather than starting a disconnected new trace).
|
|
142
|
+
def trace(trace_id: nil)
|
|
143
|
+
return yield unless enabled? && @instrumentation != :off
|
|
144
|
+
|
|
145
|
+
already_in_trace = Thread.current[TRACE_ID_KEY]
|
|
146
|
+
Thread.current[TRACE_ID_KEY] ||= trace_id || SecureRandom.hex(16)
|
|
147
|
+
Thread.current[SAMPLED_KEY] = rand < @sampling_rate if Thread.current[SAMPLED_KEY].nil?
|
|
148
|
+
Thread.current[SPAN_STACK_KEY] ||= []
|
|
149
|
+
yield
|
|
150
|
+
ensure
|
|
151
|
+
unless already_in_trace
|
|
152
|
+
Thread.current[TRACE_ID_KEY] = nil
|
|
153
|
+
Thread.current[SAMPLED_KEY] = nil
|
|
154
|
+
Thread.current[SPAN_STACK_KEY] = nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def current_trace_id
|
|
159
|
+
Thread.current[TRACE_ID_KEY]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sampled?
|
|
163
|
+
!!Thread.current[SAMPLED_KEY]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Resolves a "ClassName" or "Module::ClassName" string to a constant
|
|
167
|
+
# and instruments every method defined directly on it (public,
|
|
168
|
+
# protected, and private — visibility is preserved, see
|
|
169
|
+
# build_instrumentation_module). Silently skips (with a logged
|
|
170
|
+
# warning, not an exception — a typo in `include` shouldn't crash
|
|
171
|
+
# boot) names that don't resolve or that match `exclude`.
|
|
172
|
+
def instrument_by_name(name)
|
|
173
|
+
return if excluded?(name)
|
|
174
|
+
|
|
175
|
+
klass = Object.const_get(name)
|
|
176
|
+
methods = klass.instance_methods(false) + klass.private_instance_methods(false) +
|
|
177
|
+
klass.protected_instance_methods(false)
|
|
178
|
+
methods.uniq.each { |m| instrument_method(klass, m) }
|
|
179
|
+
rescue NameError => e
|
|
180
|
+
@logger.warn("[Scryer::APM] include: #{name.inspect} did not resolve to a constant (#{e.message}) — skipped")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# One bad method must never take out the rest of `include` — this is
|
|
184
|
+
# the boundary between "a typo/edge case in config" and "the app
|
|
185
|
+
# fails to boot", so it catches broadly (StandardError, not just the
|
|
186
|
+
# NameError instrument_by_name's own constant-resolution guards for)
|
|
187
|
+
# and skips just this one method, logged, rather than propagating.
|
|
188
|
+
def instrument_method(klass, method_name)
|
|
189
|
+
key = [klass.name, method_name]
|
|
190
|
+
return if @instrumented[key]
|
|
191
|
+
|
|
192
|
+
original = klass.instance_method(method_name)
|
|
193
|
+
location = original.source_location
|
|
194
|
+
visibility = method_visibility(klass, method_name)
|
|
195
|
+
mod = build_instrumentation_module(klass, method_name, location, visibility)
|
|
196
|
+
klass.prepend(mod)
|
|
197
|
+
@instrumented[key] = true
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
@logger.warn("[Scryer::APM] failed to instrument #{klass}##{method_name} (#{e.class}: #{e.message}) — skipped")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def instrumented?(klass, method_name)
|
|
203
|
+
!!@instrumented[[klass.name, method_name]]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
TRACE_ID_KEY = :scryer_apm_trace_id
|
|
209
|
+
SAMPLED_KEY = :scryer_apm_sampled
|
|
210
|
+
SPAN_STACK_KEY = :scryer_apm_span_stack
|
|
211
|
+
|
|
212
|
+
# Never instrumented even if named explicitly in `include` — Scryer's
|
|
213
|
+
# own code (would recurse into itself the moment .enable! ran), and
|
|
214
|
+
# the framework namespaces most likely to be typo'd into `include`
|
|
215
|
+
# by mistake (each is *also* excludable/addable by the host app via
|
|
216
|
+
# its own `exclude`/`include`, this is just a safe floor).
|
|
217
|
+
DEFAULT_EXCLUDE = %w[Scryer:: ActiveRecord:: ActionController:: ActionDispatch:: Rails::].freeze
|
|
218
|
+
|
|
219
|
+
def excluded?(name)
|
|
220
|
+
@exclude.any? { |pattern| name.start_with?(pattern) || name == pattern.sub(/::\z/, "") }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def method_visibility(klass, method_name)
|
|
224
|
+
return :private if klass.private_method_defined?(method_name)
|
|
225
|
+
return :protected if klass.protected_method_defined?(method_name)
|
|
226
|
+
|
|
227
|
+
:public
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# One freshly-built anonymous module per (class, method) pair,
|
|
231
|
+
# `prepend`ed onto the class — the same mechanism QueryWatcher uses
|
|
232
|
+
# on ActiveRecord::QueryMethods. `*args, **kwargs, &block` + `super`
|
|
233
|
+
# forwards positional args, keyword args, and blocks correctly on
|
|
234
|
+
# every Ruby version this gem supports (>= 2.7); the wrapped method's
|
|
235
|
+
# return value and any exception it raises both pass through
|
|
236
|
+
# unchanged. `send(:private, ...)`/`protected` after `define_method`
|
|
237
|
+
# restores the original visibility, since `define_method` always
|
|
238
|
+
# defines a public method by default.
|
|
239
|
+
def build_instrumentation_module(klass, method_name, location, visibility)
|
|
240
|
+
file, line = location
|
|
241
|
+
Module.new do
|
|
242
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
243
|
+
unless Scryer::APM.enabled? && Scryer::APM.instrumentation_mode == :selective && Scryer::APM.sampled?
|
|
244
|
+
next super(*args, **kwargs, &block)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
Scryer::APM.send(:call_instrumented, self.class, method_name, file, line) do
|
|
248
|
+
super(*args, **kwargs, &block)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
send(visibility, method_name) unless visibility == :public
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The actual per-call work: build the Span, open a New Relic segment
|
|
256
|
+
# if one's available, run the real method, close everything out
|
|
257
|
+
# (success or exception) and push/pop the thread-local span stack so
|
|
258
|
+
# nested instrumented calls get the right parent_span_id.
|
|
259
|
+
def call_instrumented(klass, method_name, file, line)
|
|
260
|
+
stack = (Thread.current[SPAN_STACK_KEY] ||= [])
|
|
261
|
+
span = Span.new(
|
|
262
|
+
trace_id: current_trace_id || (Thread.current[TRACE_ID_KEY] = SecureRandom.hex(16)),
|
|
263
|
+
span_id: SecureRandom.hex(8),
|
|
264
|
+
parent_span_id: stack.last&.span_id,
|
|
265
|
+
class_name: klass.name,
|
|
266
|
+
method_name: method_name.to_s,
|
|
267
|
+
file: file,
|
|
268
|
+
line: line,
|
|
269
|
+
started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
270
|
+
wall_time: Time.now,
|
|
271
|
+
thread_id: Thread.current.object_id,
|
|
272
|
+
status: :ok
|
|
273
|
+
)
|
|
274
|
+
stack.push(span)
|
|
275
|
+
provider_segment = start_provider_segment(klass, method_name)
|
|
276
|
+
|
|
277
|
+
begin
|
|
278
|
+
yield
|
|
279
|
+
rescue Exception => e # rubocop-equivalent: intentional — must record then re-raise every exception class, not just StandardError
|
|
280
|
+
span.status = :error
|
|
281
|
+
span.exception_class = e.class.name
|
|
282
|
+
span.exception_message = redact_exception_message(e.message) if @capture_exception_messages
|
|
283
|
+
raise
|
|
284
|
+
ensure
|
|
285
|
+
span.duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - span.started_at) * 1000).round(3)
|
|
286
|
+
finish_provider_segment(provider_segment, span)
|
|
287
|
+
stack.pop
|
|
288
|
+
@on_span&.call(span)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Dispatches to whichever provider is configured — only one provider
|
|
293
|
+
# is active per process (set once at .enable! time), so this is a
|
|
294
|
+
# simple case, not a fan-out to every provider at once.
|
|
295
|
+
def start_provider_segment(klass, method_name)
|
|
296
|
+
case @provider
|
|
297
|
+
when :new_relic then start_new_relic_segment(klass, method_name)
|
|
298
|
+
when :opentelemetry then start_open_telemetry_span(klass, method_name)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def finish_provider_segment(segment, span)
|
|
303
|
+
case @provider
|
|
304
|
+
when :new_relic then finish_new_relic_segment(segment, span)
|
|
305
|
+
when :opentelemetry then finish_open_telemetry_span(segment, span)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# `NewRelic::Agent::Tracer.start_segment`/`Segment#finish` is the
|
|
310
|
+
# agent's own public, documented API for creating a custom segment
|
|
311
|
+
# that nests under whatever transaction/segment is already open —
|
|
312
|
+
# this is how the agent's own instrumentation (and `add_method_tracer`
|
|
313
|
+
# under the hood) creates segments, so it's the supported integration
|
|
314
|
+
# path, not an undocumented mechanism. Soft-detected: if newrelic_rpm
|
|
315
|
+
# isn't loaded, or isn't inside an active transaction, this is a
|
|
316
|
+
# no-op and the instrumented method still runs normally — APM
|
|
317
|
+
# visibility is never allowed to break the app it's watching.
|
|
318
|
+
def start_new_relic_segment(klass, method_name)
|
|
319
|
+
return nil unless @provider == :new_relic && defined?(NewRelic::Agent::Tracer)
|
|
320
|
+
|
|
321
|
+
NewRelic::Agent::Tracer.start_segment(name: "Custom/#{klass.name}/#{method_name}")
|
|
322
|
+
rescue StandardError => e
|
|
323
|
+
@logger.warn("[Scryer::APM] New Relic segment start failed: #{e.message}") if @logger
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def finish_new_relic_segment(segment, span)
|
|
328
|
+
return unless segment
|
|
329
|
+
|
|
330
|
+
segment.notice_error(build_reportable_exception(span)) if span.status == :error && span.exception_class
|
|
331
|
+
segment.finish
|
|
332
|
+
rescue StandardError => e
|
|
333
|
+
@logger&.warn("[Scryer::APM] New Relic segment finish failed: #{e.message}")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# `OpenTelemetry.tracer_provider.tracer(...).start_span` + explicit
|
|
337
|
+
# `OpenTelemetry::Context.attach`/`.detach` is the SDK's own public,
|
|
338
|
+
# documented manual-span API (used whenever the block form,
|
|
339
|
+
# `Tracer#in_span`, doesn't fit — exactly this case, since
|
|
340
|
+
# call_instrumented's own begin/rescue/ensure already owns the
|
|
341
|
+
# start/finish lifecycle). `Context.attach` makes this span the
|
|
342
|
+
# "current" one so the *next* nested instrumented call's
|
|
343
|
+
# `tracer.start_span` — which defaults `with_parent:` to
|
|
344
|
+
# `Context.current` — picks it up as its parent automatically; no
|
|
345
|
+
# manual parent bookkeeping needed beyond attach/detach being
|
|
346
|
+
# correctly paired, same shape as the thread-local span stack this
|
|
347
|
+
# module already keeps for its own Span struct. Soft-detected: if
|
|
348
|
+
# the opentelemetry-sdk gem isn't loaded, this is a no-op and the
|
|
349
|
+
# instrumented method still runs normally, same contract as the New
|
|
350
|
+
# Relic path.
|
|
351
|
+
def start_open_telemetry_span(klass, method_name)
|
|
352
|
+
return nil unless @provider == :opentelemetry && defined?(OpenTelemetry::Trace)
|
|
353
|
+
|
|
354
|
+
tracer = OpenTelemetry.tracer_provider.tracer("scryer", Scryer::VERSION)
|
|
355
|
+
otel_span = tracer.start_span("#{klass.name}##{method_name}", kind: :internal)
|
|
356
|
+
token = OpenTelemetry::Context.attach(OpenTelemetry::Trace.context_with_span(otel_span))
|
|
357
|
+
[otel_span, token]
|
|
358
|
+
rescue StandardError => e
|
|
359
|
+
@logger.warn("[Scryer::APM] OpenTelemetry span start failed: #{e.message}") if @logger
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def finish_open_telemetry_span(segment, span)
|
|
364
|
+
return unless segment
|
|
365
|
+
|
|
366
|
+
otel_span, token = segment
|
|
367
|
+
if span.status == :error && span.exception_class
|
|
368
|
+
otel_span.record_exception(build_reportable_exception(span))
|
|
369
|
+
otel_span.status = OpenTelemetry::Trace::Status.error("#{span.exception_class}: #{span.exception_message || 'error'}")
|
|
370
|
+
end
|
|
371
|
+
otel_span.finish
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
@logger&.warn("[Scryer::APM] OpenTelemetry span finish failed: #{e.message}")
|
|
374
|
+
ensure
|
|
375
|
+
OpenTelemetry::Context.detach(token) if token
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# NewRelic::Agent::Segment#notice_error wants a real exception object,
|
|
379
|
+
# not just a class name string — this reconstructs a minimal one
|
|
380
|
+
# carrying only what Span itself already decided to keep (never the
|
|
381
|
+
# original exception object, which could hold references to whatever
|
|
382
|
+
# arguments/state the traced method closed over).
|
|
383
|
+
def build_reportable_exception(span)
|
|
384
|
+
klass = Object.const_get(span.exception_class)
|
|
385
|
+
klass.exception(span.exception_message || span.exception_class)
|
|
386
|
+
rescue StandardError
|
|
387
|
+
StandardError.new(span.exception_class)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Best-effort only, not exhaustive (see README limitations): masks
|
|
391
|
+
# substrings that look like tokens/secrets (long hex/base64-ish
|
|
392
|
+
# runs, email addresses) before an exception message is ever kept.
|
|
393
|
+
# Off entirely unless capture_exception_messages: true — the safer
|
|
394
|
+
# default is to keep the exception *class* only.
|
|
395
|
+
def redact_exception_message(message)
|
|
396
|
+
return nil unless message
|
|
397
|
+
|
|
398
|
+
message
|
|
399
|
+
.gsub(/[A-Za-z0-9_\-]{24,}/, "[REDACTED]")
|
|
400
|
+
.gsub(/[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}/, "[REDACTED_EMAIL]")
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def warn_if_provider_unavailable
|
|
404
|
+
return unless @instrumentation == :selective
|
|
405
|
+
|
|
406
|
+
case @provider
|
|
407
|
+
when :new_relic
|
|
408
|
+
return if defined?(NewRelic::Agent::Tracer)
|
|
409
|
+
|
|
410
|
+
@logger.warn(
|
|
411
|
+
"[Scryer::APM] provider: :new_relic but NewRelic::Agent isn't loaded — spans will be " \
|
|
412
|
+
"traced (available via Scryer::APM.on_span) but not exported anywhere."
|
|
413
|
+
)
|
|
414
|
+
when :opentelemetry
|
|
415
|
+
return if defined?(OpenTelemetry::Trace)
|
|
416
|
+
|
|
417
|
+
@logger.warn(
|
|
418
|
+
"[Scryer::APM] provider: :opentelemetry but the opentelemetry-sdk gem isn't loaded — " \
|
|
419
|
+
"spans will be traced (available via Scryer::APM.on_span) but not exported anywhere."
|
|
420
|
+
)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def default_logger
|
|
425
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
426
|
+
Rails.logger
|
|
427
|
+
else
|
|
428
|
+
require "logger"
|
|
429
|
+
Logger.new($stdout)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
public
|
|
434
|
+
|
|
435
|
+
# Registers a callback invoked with every completed Span, regardless
|
|
436
|
+
# of provider — the hook the test suite uses to assert on spans
|
|
437
|
+
# directly, and available to any host app that wants its own
|
|
438
|
+
# sink (a log line, a metrics counter) alongside/instead of New Relic.
|
|
439
|
+
def on_span(&block)
|
|
440
|
+
@on_span = block
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# `use Scryer::APM::Middleware` opens one trace per request, same
|
|
445
|
+
# per-request-scoping rationale as Scryer::QueryWatcher::Middleware.
|
|
446
|
+
class Middleware
|
|
447
|
+
def initialize(app)
|
|
448
|
+
@app = app
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def call(env)
|
|
452
|
+
result = nil
|
|
453
|
+
Scryer::APM.trace { result = @app.call(env) }
|
|
454
|
+
result
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
data/lib/scryer/railtie.rb
CHANGED
|
@@ -1,13 +1,72 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
module Scryer
|
|
3
|
-
#
|
|
4
|
-
# instrumentation —
|
|
5
|
-
#
|
|
6
|
-
# requiring this gem inside a Rails app never raises, and to load the
|
|
3
|
+
# The static scan runs on-demand via a rake task, not as request-cycle
|
|
4
|
+
# instrumentation — that part of the Railtie's job is just making sure
|
|
5
|
+
# requiring this gem inside a Rails app never raises, and loading the
|
|
7
6
|
# rake tasks into the host app's Rails console/`rake -T` listing.
|
|
7
|
+
#
|
|
8
|
+
# Scryer::APM (runtime method tracing) is the one exception: it *is*
|
|
9
|
+
# request-cycle instrumentation, and unlike QueryWatcher/AuthorizationWatcher
|
|
10
|
+
# (which the README has host apps wire up by hand — see
|
|
11
|
+
# docs/rails-integration.md) this one auto-wires itself when
|
|
12
|
+
# `Scryer.configuration.apm.enabled` is true, so setting that flag (plus
|
|
13
|
+
# `c.apm.include`) in an initializer is genuinely sufficient — no separate
|
|
14
|
+
# `require "scryer/apm"` / `Scryer::APM.enable!` / `middleware.use` calls
|
|
15
|
+
# needed. `after: :load_config_initializers` guarantees this runs after
|
|
16
|
+
# every file in config/initializers/ (including the host app's own
|
|
17
|
+
# scryer.rb) has already set that flag, regardless of alphabetical
|
|
18
|
+
# filename ordering.
|
|
8
19
|
class Railtie < Rails::Railtie
|
|
9
20
|
rake_tasks do
|
|
10
21
|
load File.expand_path("../tasks/scryer.rake", __dir__)
|
|
11
22
|
end
|
|
23
|
+
|
|
24
|
+
initializer "scryer.apm", after: :load_config_initializers do |app|
|
|
25
|
+
Scryer::Railtie.maybe_enable_apm(app)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Plain Ruby, no Rails::Railtie API surface — pulled out of the
|
|
29
|
+
# `initializer` block above specifically so it's directly unit-testable
|
|
30
|
+
# (test/railtie_apm_test.rb) with a fake `app` (anything responding to
|
|
31
|
+
# `middleware.use`), without needing a real Rails app to boot. What this
|
|
32
|
+
# test *cannot* verify: that `after: :load_config_initializers` above
|
|
33
|
+
# actually fires at the right point in a real Rails boot sequence —
|
|
34
|
+
# that would need a real Rails app, which this gem's test suite doesn't
|
|
35
|
+
# depend on (see gemspec: zero runtime deps, Rails-optional).
|
|
36
|
+
#
|
|
37
|
+
# This method runs unattended during every boot of every host app that
|
|
38
|
+
# sets `c.apm.enabled = true` — a misconfigured `include` entry, an
|
|
39
|
+
# unimplemented `instrumentation` mode set by mistake, a `require`
|
|
40
|
+
# failure, anything — must degrade to "APM is off, logged once" rather
|
|
41
|
+
# than fail app boot. That's a materially different bar than the rest
|
|
42
|
+
# of Scryer::APM's public API (Scryer::APM.enable! called directly, in
|
|
43
|
+
# a console or test, DOES still raise for an unimplemented mode — fast,
|
|
44
|
+
# loud feedback is correct there, where a developer is watching).
|
|
45
|
+
# `rescue Exception` (not just StandardError) is deliberate here for
|
|
46
|
+
# the same reason: a boot-time LoadError from a packaging issue is a
|
|
47
|
+
# ScriptError, not a StandardError, and must be caught by this specific
|
|
48
|
+
# boundary too — re-raising SystemExit/NoMemoryError/SignalException
|
|
49
|
+
# keeps this from swallowing the handful of cases where propagating is
|
|
50
|
+
# still correct even at boot.
|
|
51
|
+
def self.maybe_enable_apm(app)
|
|
52
|
+
return unless Scryer.configuration.apm.enabled
|
|
53
|
+
|
|
54
|
+
require "scryer/apm"
|
|
55
|
+
Scryer::APM.enable!
|
|
56
|
+
app.middleware.use Scryer::APM::Middleware
|
|
57
|
+
rescue SystemExit, NoMemoryError, SignalException
|
|
58
|
+
raise
|
|
59
|
+
rescue Exception => e # rubocop-equivalent: intentional, see comment above
|
|
60
|
+
logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || begin
|
|
61
|
+
require "logger"
|
|
62
|
+
Logger.new($stderr)
|
|
63
|
+
end
|
|
64
|
+
logger.error(
|
|
65
|
+
"[Scryer::APM] enabling APM via c.apm.enabled failed (#{e.class}: #{e.message}) — " \
|
|
66
|
+
"APM is disabled for this process; the rest of the app boots normally. " \
|
|
67
|
+
"#{e.backtrace&.first(5)&.join("\n")}"
|
|
68
|
+
)
|
|
69
|
+
Scryer::APM.disable! if defined?(Scryer::APM)
|
|
70
|
+
end
|
|
12
71
|
end
|
|
13
72
|
end
|
data/lib/scryer/version.rb
CHANGED
data/lib/scryer.rb
CHANGED
|
@@ -55,6 +55,50 @@ module Scryer
|
|
|
55
55
|
# configured default) if it's too noisy or too slow for a given project.
|
|
56
56
|
attr_accessor :project_name, :dirs, :branch, :ai_client, :skip_rules, :detect_duplicates
|
|
57
57
|
|
|
58
|
+
# Runtime method-level tracing config (see Scryer::APM) — a separate
|
|
59
|
+
# nested config object, not flat attrs on Configuration itself, so
|
|
60
|
+
# `Scryer.configure { |c| c.apm.include = [...] }` reads the same way
|
|
61
|
+
# section 7 of this feature's own design doc illustrates it, and so
|
|
62
|
+
# APM's many options don't crowd the static-scan options above into a
|
|
63
|
+
# single flat namespace.
|
|
64
|
+
class APMConfiguration
|
|
65
|
+
# `provider` — :new_relic (soft-detected via
|
|
66
|
+
# `defined?(NewRelic::Agent::Tracer)`) and :opentelemetry (soft-detected
|
|
67
|
+
# via `defined?(OpenTelemetry::Trace)`) are implemented; spans just
|
|
68
|
+
# aren't exported anywhere if the configured provider's SDK isn't
|
|
69
|
+
# loaded. `instrumentation` — only
|
|
70
|
+
# :off and :selective exist; :discovery/:deep_trace raise from
|
|
71
|
+
# Scryer::APM.enable! (not implemented yet, see README). `include`/
|
|
72
|
+
# `exclude` — arrays of "ClassName"/"Module::ClassName" strings;
|
|
73
|
+
# `exclude` always additionally contains Scryer's own namespace plus
|
|
74
|
+
# a small framework-internals floor regardless of what's set here
|
|
75
|
+
# (see Scryer::APM::DEFAULT_EXCLUDE). `sampling_rate` — 0.0..1.0,
|
|
76
|
+
# applied once per Scryer::APM.trace scope (a whole request/job is
|
|
77
|
+
# either sampled or not, not decided span-by-span).
|
|
78
|
+
# `capture_exception_messages` — false by default: only the
|
|
79
|
+
# exception *class* is ever recorded unless explicitly opted in,
|
|
80
|
+
# and even then the message passes through a best-effort redaction
|
|
81
|
+
# pass (see Scryer::APM#redact_exception_message).
|
|
82
|
+
attr_accessor :enabled, :provider, :instrumentation, :include, :exclude, :sampling_rate,
|
|
83
|
+
:capture_exception_messages
|
|
84
|
+
|
|
85
|
+
def initialize
|
|
86
|
+
@enabled = false
|
|
87
|
+
@provider = :new_relic
|
|
88
|
+
@instrumentation = :selective
|
|
89
|
+
@include = []
|
|
90
|
+
@exclude = []
|
|
91
|
+
@sampling_rate = 1.0
|
|
92
|
+
@capture_exception_messages = false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
attr_writer :apm
|
|
97
|
+
|
|
98
|
+
def apm
|
|
99
|
+
@apm ||= APMConfiguration.new
|
|
100
|
+
end
|
|
101
|
+
|
|
58
102
|
def initialize
|
|
59
103
|
@dirs = Scryer::Scanner::DEFAULT_GLOB_DIRS
|
|
60
104
|
@skip_rules = []
|
|
@@ -92,5 +136,9 @@ end
|
|
|
92
136
|
# isn't required eagerly here to avoid loading active_support/notifications
|
|
93
137
|
# machinery for the (default) case where nobody asked for it. Require it
|
|
94
138
|
# yourself where you call .enable! (see README's "Runtime query watcher").
|
|
139
|
+
#
|
|
140
|
+
# Scryer::APM (runtime method-level tracing, see lib/scryer/apm.rb) is the
|
|
141
|
+
# same story — opt-in, requires it yourself where you call .enable! (see
|
|
142
|
+
# README's "Runtime method tracing").
|
|
95
143
|
|
|
96
144
|
require "scryer/railtie" if defined?(Rails::Railtie)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: scryer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ram Laxman Yadav
|
|
@@ -76,6 +76,7 @@ files:
|
|
|
76
76
|
- lib/scryer.rb
|
|
77
77
|
- lib/scryer/ai_client.rb
|
|
78
78
|
- lib/scryer/ai_fix_suggester.rb
|
|
79
|
+
- lib/scryer/apm.rb
|
|
79
80
|
- lib/scryer/ast.rb
|
|
80
81
|
- lib/scryer/authorization_watcher.rb
|
|
81
82
|
- lib/scryer/baseline.rb
|