phlex-reactive 0.11.4 → 0.11.6

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.
@@ -173,6 +173,18 @@ module Phlex
173
173
  false
174
174
  end
175
175
 
176
+ # Turnkey APM integration (issue #207). Set to a Symbol (:appsignal,
177
+ # :sentry, :datadog), a custom adapter object (responding to
178
+ # record_action/record_error), or nil (the default — off). A Symbol
179
+ # resolves to a built-in adapter LAZILY, at engine attach time, so load
180
+ # order doesn't matter; a set-but-undetectable SDK logs ONE warning at boot
181
+ # and no-ops (the pgbus optionality invariant applied to APMs — no vendor
182
+ # SDK is ever a hard dependency). When set and available, each reactive
183
+ # action is named `Component#action` in the APM (not one blurry
184
+ # ActionsController#create), and an action-body error is reported to the
185
+ # tracker with component/action tags. Default nil.
186
+ attr_accessor :apm
187
+
176
188
  # Client debug mode (issue #108): the "devtools-lite" lens on the reactive
177
189
  # round trip. When true, reactive_attrs (and so reactive_root) stamps
178
190
  # data-reactive-debug="true" on the root, and the generic controller
@@ -406,6 +418,72 @@ module Phlex
406
418
  @around_actions = []
407
419
  end
408
420
 
421
+ # Register a block called when a reactive action body raises a
422
+ # previously-uncaught error (issue #207) — the DIY escape hatch for a tracker
423
+ # the gem ships no adapter for, usable WITHOUT choosing an `apm` Symbol. The
424
+ # block receives (error, context) where context is the name-only event payload
425
+ # ({ component:, action:, outcome: :error }). It runs INSIDE the endpoint's
426
+ # error handling, just before the exception is re-raised — so Rails' own error
427
+ # reporting still fires afterward. Register in an initializer.
428
+ #
429
+ # Phlex::Reactive.on_action_error do |error, ctx|
430
+ # Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
431
+ # end
432
+ def on_action_error(&block)
433
+ raise ::ArgumentError, "Phlex::Reactive.on_action_error requires a block" unless block
434
+
435
+ on_action_error_hooks << block
436
+ end
437
+
438
+ # The registered on_action_error hooks, oldest first.
439
+ def on_action_error_hooks
440
+ @on_action_error_hooks ||= []
441
+ end
442
+
443
+ # Drop all registered on_action_error hooks. Test isolation; never in production.
444
+ def reset_on_action_error!
445
+ @on_action_error_hooks = []
446
+ end
447
+
448
+ # The name-only keys forwarded to error reporters (issue #207). Deliberately
449
+ # NOT the whole event hash: ActiveSupport::Notifications mutates the SAME hash
450
+ # during error unwinding, adding :exception / :exception_object — a reporter
451
+ # that RETAINS the hash would later observe those, breaking the name-only
452
+ # contract. report_error forwards a fresh slice of just these keys.
453
+ ERROR_CONTEXT_KEYS = %i[component action outcome].freeze
454
+
455
+ # Report a previously-uncaught action-body error to the resolved APM adapter
456
+ # AND every registered on_action_error hook (issue #207). Called by the
457
+ # endpoint's error seam just before it re-raises. Each reporter is wrapped in
458
+ # its own rescue so a broken reporter can NEVER turn one 500 into a different
459
+ # 500 (or swallow the original — the endpoint re-raises regardless). `context`
460
+ # is the mutable event payload; we forward a NAME-ONLY SNAPSHOT (a fresh Hash
461
+ # of ERROR_CONTEXT_KEYS) so a reporter that retains it never picks up the
462
+ # :exception keys ASN adds to the live hash afterward. Best-effort and
463
+ # side-effect only: the return value is ignored.
464
+ def report_error(error, context)
465
+ snapshot = context.slice(*ERROR_CONTEXT_KEYS)
466
+ adapter = @resolved_apm_adapter
467
+ # Each reporter runs through safely_report on its OWN — one broken reporter
468
+ # (a raising adapter or hook) never prevents the others, and never turns one
469
+ # 500 into a different 500. safely_report takes the reporter as a block arg,
470
+ # so there's no nested-block ambiguity.
471
+ safely_report { adapter.record_error(error, snapshot) } if adapter
472
+ on_action_error_hooks.each { report_hook(it, error, snapshot) }
473
+ nil
474
+ end
475
+
476
+ # Run one on_action_error hook through safely_report. Extracted so the
477
+ # per-hook isolation reads as one call (no nested reporter block in the loop).
478
+ def report_hook(hook, error, context)
479
+ safely_report { hook.call(error, context) }
480
+ end
481
+
482
+ # The APM adapter resolved at engine attach time (issue #207), or nil. Held so
483
+ # report_error can reach record_error without re-running detection per request.
484
+ # Set by APM.attach!; nil when no APM is configured or the SDK was absent.
485
+ attr_accessor :resolved_apm_adapter
486
+
409
487
  def verifier
410
488
  @verifier ||= default_verifier
411
489
  end
@@ -918,6 +996,16 @@ module Phlex
918
996
  ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
919
997
  end
920
998
 
999
+ # Run a reporter (APM adapter or on_action_error hook) so a raise inside it
1000
+ # NEVER escapes report_error (issue #207) — the endpoint must re-raise the
1001
+ # ORIGINAL action-body error, not a reporter's failure. A broken reporter is
1002
+ # warn-logged so it's diagnosable, then swallowed.
1003
+ def safely_report
1004
+ yield
1005
+ rescue => e # rubocop:disable Style/RescueStandardError
1006
+ default_logger&.warn("[phlex-reactive] apm/on_action_error reporter raised: #{e.class}: #{e.message}")
1007
+ end
1008
+
921
1009
  # Walk the upgrader chain from `version` up to TOKEN_VERSION, applying each
922
1010
  # registered upgrader in turn (issue #111). A gap in the chain (a version
923
1011
  # bumped with no shape change, so no upgrader registered) is a no-op step —
@@ -961,7 +1049,7 @@ lib = File.expand_path("..", __dir__)
961
1049
  loader.push_dir(lib)
962
1050
  # js.rb defines JS and component/dsl.rb defines Component::DSL (acronyms), not
963
1051
  # the default-inflected `Js`/`Dsl`. mcp.rb defines MCP (issue #168).
964
- loader.inflector.inflect("js" => "JS", "dsl" => "DSL", "mcp" => "MCP")
1052
+ loader.inflector.inflect("js" => "JS", "dsl" => "DSL", "mcp" => "MCP", "apm" => "APM")
965
1053
  # The gem-name shim (`require "phlex-reactive"`) is a plain require, not a
966
1054
  # managed file.
967
1055
  loader.ignore("#{lib}/phlex-reactive.rb")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.4
4
+ version: 0.11.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -143,6 +143,12 @@ files:
143
143
  - lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb
144
144
  - lib/phlex-reactive.rb
145
145
  - lib/phlex/reactive.rb
146
+ - lib/phlex/reactive/apm.rb
147
+ - lib/phlex/reactive/apm/adapter.rb
148
+ - lib/phlex/reactive/apm/appsignal.rb
149
+ - lib/phlex/reactive/apm/datadog.rb
150
+ - lib/phlex/reactive/apm/sentry.rb
151
+ - lib/phlex/reactive/apm/subscriber.rb
146
152
  - lib/phlex/reactive/authorization.rb
147
153
  - lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md
148
154
  - lib/phlex/reactive/client_bindings.rb