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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2d73db1916cc56075446233dfaa8e88d449bd77218e85fa41fe96e11d56df14
4
- data.tar.gz: b4a5c41ff1f7132c4ba4807177ee31629614376b2cb0228e24231f44ab3c25e0
3
+ metadata.gz: 3d7873062d73beb49e505138b100a9507d676b2d5a13838ba382fedb9ac31a2e
4
+ data.tar.gz: 6f9168a62835d3aba9a583720fcfea45c5271ddb24d16773023a298566522ad0
5
5
  SHA512:
6
- metadata.gz: 6f9cac00b59b7f44d5939ec66bd4a63280acf60c2b011f7a0f5977c323a28595a85bf9f1a6a622dc9e6c55704bd664c6df85428b189b5e74edc4990785c1af76
7
- data.tar.gz: f56d186dfcae4d6344a7d03a8e43dcc2508f68e4276d7df3c9bffaa46fe8cf1d2591edd078ccc8627258d45a1d6649d5ae391ee225e7a88886afff0f3b912f10
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
- ## [Unreleased]
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
- the `scryer:install` generator, and testing Scryer's own results in your app's test suite.
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
 
@@ -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
@@ -1,13 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
  module Scryer
3
- # This gem runs on-demand via a rake task, not as request-cycle
4
- # instrumentation — so the Railtie doesn't need to hook into the
5
- # middleware stack or boot process at all. Its only job is to make sure
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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Scryer
3
- VERSION = "1.2.2"
3
+ VERSION = "1.3.0"
4
4
  end
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.2.2
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