easyop 0.1.3 → 0.1.5

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: 4c77d9aa5925ea30b3de85fc74618f355ee3498223f79d6184cda21e95574b8d
4
- data.tar.gz: 8bd775d8fa095a50b46aafeedec78f2046d54005b6316a1f22ae02b5657c361c
3
+ metadata.gz: c649ea27731299fcf70421b41e0a19cca45612aa621310bf1bb87a70f5100da6
4
+ data.tar.gz: 7589af6ce926df84ccafb1df8b705430aaabc82b0929a0b9d8ed9bb4b7db818d
5
5
  SHA512:
6
- metadata.gz: a01be26e27050c1569f85ee610043ca42268c75054355c4faf230b65599354d9a824b07955f4f13cda25672681d1c934bbeaec15d89be2cfd8ad55681dd29511
7
- data.tar.gz: 41d8d5bf9ca01e02146764678c343eac787c2d5445670caa9e8c1a0f0b60ff26b57906f2fb4abd16b7e59d05b05fe65a1400cff4458934df0110bf1af10f5d3a
6
+ metadata.gz: fc1a7e1c0427da2abb6588c61ece675431708b3b2a2bb623e62356ebfb6edf69025f6c13004ef6967d62f9381cb6c003793958f1c98326c34ba6266a9a56f774
7
+ data.tar.gz: d40f2a49035f449f95bfcefaae66dacc6c4d2e015d03d66c88f5f74e1680f8d7713d3f079ece4011d77ea238fc6181cf7072fbfe651a4be9be0876e5c8839eb2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.5] — 2026-04-14
11
+
12
+ ### Added
13
+
14
+ - **`scrub_params` DSL for `Easyop::Plugins::Recording`** — declare additional params keys/patterns to scrub from `params_data` on a per-class basis. Accepts `Symbol`, `String`, or `Regexp`. Additive with `SCRUBBED_KEYS` and never replaces the built-in list. Inherited by subclasses; any level of the hierarchy can override.
15
+
16
+ ```ruby
17
+ class ApplicationOperation < ...
18
+ scrub_params :api_token, /access.?key/i
19
+ end
20
+
21
+ class Orders::CreateOrder < ApplicationOperation
22
+ scrub_params :card_number # stacks on top of parent's scrub list
23
+ end
24
+ ```
25
+
26
+ - **`scrub_keys:` option on `plugin Easyop::Plugins::Recording`** — supply a list of extra keys/patterns to scrub at plugin install time. Inherited by all classes that share the same `plugin` declaration.
27
+
28
+ ```ruby
29
+ plugin Easyop::Plugins::Recording,
30
+ model: OperationLog,
31
+ scrub_keys: [:stripe_token, /secret/i]
32
+ ```
33
+
34
+ - **`Easyop::Configuration#recording_scrub_keys`** — new global config key. Set once at boot and every recorded operation will scrub these keys in addition to `SCRUBBED_KEYS` and any class-level declarations. Accepts `Symbol`, `String`, or `Regexp`.
35
+
36
+ ```ruby
37
+ Easyop.configure do |c|
38
+ c.recording_scrub_keys = [:api_token, /token/i]
39
+ end
40
+ ```
41
+
42
+ **Scrub precedence (all layers are additive):**
43
+ 1. `SCRUBBED_KEYS` — always applied (built-in list)
44
+ 2. `Easyop.config.recording_scrub_keys` — global config
45
+ 3. `scrub_keys:` plugin option + `scrub_params` DSL — class hierarchy
46
+
47
+ ## [0.1.4] — 2026-04-14
48
+
49
+ ### Added
50
+
51
+ - **Flow-tracing forwarding in `Easyop::Flow`** — `CallBehavior#call` now automatically sets the `__recording_parent_*` ctx keys before running steps, so every child operation's log entry carries the flow class as its `parent_operation_name` and `parent_reference_id`. This works even when Recording is not installed on the flow class itself (bare `include Easyop::Flow`). When Recording IS installed (recommended: inherit from ApplicationOperation and add `transactional false`), the flow appears in the log as the root entry and RunWrapper handles the ctx setup — no double-setup occurs.
52
+
53
+ ```ruby
54
+ # Bare flow — Recording on steps only; flow itself is not recorded but
55
+ # steps correctly show parent_operation_name: "Flows::Checkout"
56
+ class Flows::Checkout
57
+ include Easyop::Flow
58
+ flow Orders::CreateOrder, Orders::ProcessPayment
59
+ end
60
+
61
+ # Recommended — flow is recorded as root, steps as children:
62
+ class Flows::Checkout < ApplicationOperation
63
+ include Easyop::Flow
64
+ transactional false # steps manage their own transactions
65
+ flow Orders::CreateOrder, Orders::ProcessPayment
66
+ end
67
+ ```
68
+
69
+ Result in operation_logs:
70
+ ```
71
+ Flows::Checkout root=aaa ref=bbb parent=nil
72
+ Orders::CreateOrder root=aaa ref=ccc parent=Flows::Checkout/bbb
73
+ Orders::ProcessPayment root=aaa ref=ddd parent=Flows::Checkout/bbb
74
+ ```
75
+
76
+ - **`record_result` DSL for `Easyop::Plugins::Recording`** — selectively persist ctx output data into a new optional `result_data :text` column (stored as JSON). Supports three forms:
77
+
78
+ ```ruby
79
+ # Attrs form — one or more ctx keys
80
+ record_result attrs: :invoice_id
81
+ record_result attrs: [:invoice_id, :total]
82
+
83
+ # Block form — custom extraction
84
+ record_result { |ctx| { total: ctx.total, items: ctx.items.count } }
85
+
86
+ # Symbol form — delegates to a private instance method
87
+ record_result :build_result
88
+ ```
89
+
90
+ Plugin-level default (inherited by all subclasses):
91
+ ```ruby
92
+ plugin Easyop::Plugins::Recording, model: OperationLog, record_result: { attrs: :metadata }
93
+ ```
94
+
95
+ Class-level `record_result` overrides the plugin-level default. Missing ctx keys produce `nil` (no error). ActiveRecord objects are serialized as `{ id:, class: }`. Serialization errors are swallowed. The `result_data` column is silently skipped when absent from the model table — fully backward-compatible.
96
+
97
+ ### Fixed
98
+
99
+ - **`Easyop::Schema` type-mismatch warning suppressed when `$VERBOSE` is `nil`** — `warn` is a no-op in Ruby when `$VERBOSE` is `nil` (e.g. when running under certain test setups or with `-W0`). Changed to `$stderr.puts` so the `[EasyOp]` type-mismatch message always reaches stderr regardless of Ruby's verbosity flag.
100
+
101
+ - **`Easyop::Schema` spec `strict_types` contamination across examples** — Setting `strict_types = true` inside an example without a corresponding teardown left the global config dirty for later examples. Added `after(:each) { Easyop.reset_config! }` at the top-level `RSpec.describe` so every example begins with a clean configuration.
102
+
103
+ - **`Easyop::Plugins::Instrumentation` flaky duration assertion** — The `:duration` payload is computed as `(elapsed_ms).round(2)`, which rounds to `0.0` for operations completing in under 0.005 ms. Relaxed the spec assertion from `be > 0` to `be >= 0` to reflect that a rounded-to-zero duration is a valid measurement, not an error.
104
+
10
105
  ## [0.1.3] — 2026-04-14
11
106
 
12
107
  ### Added
@@ -200,5 +295,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
200
295
  - `examples/easyop_test_app/` — full Rails 8 blog application demonstrating all features in real-world code
201
296
  - `examples/usage.rb` — 13 runnable plain-Ruby examples
202
297
 
203
- [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.2...HEAD
298
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.5...HEAD
299
+ [0.1.5]: https://github.com/pniemczyk/easyop/compare/v0.1.4...v0.1.5
300
+ [0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
204
301
  [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -428,6 +428,39 @@ class FullCheckout
428
428
  end
429
429
  ```
430
430
 
431
+ ### Recording plugin integration — full call-tree tracing
432
+
433
+ When step operations have the Recording plugin installed, `Easyop::Flow` automatically forwards the parent-tracing ctx so every step's log entry shows the flow as its `parent_operation_name`. All steps and the flow share the same `root_reference_id`.
434
+
435
+ **Bare flow** (Recording only on steps — flow is NOT recorded itself, but steps carry correct parent info):
436
+
437
+ ```ruby
438
+ class ProcessCheckout
439
+ include Easyop::Flow
440
+ flow ValidateCart, ChargePayment, CreateOrder
441
+ end
442
+ ```
443
+
444
+ **Recommended** — inherit from your recorded base class so the **flow itself appears in operation_logs** as the tree root. Add `transactional false` so step-level transactions aren't shadowed by an outer one:
445
+
446
+ ```ruby
447
+ class ProcessCheckout < ApplicationOperation
448
+ include Easyop::Flow
449
+ transactional false # EasyOp handles rollback; each step owns its transaction
450
+
451
+ flow ValidateCart, ChargePayment, CreateOrder
452
+ end
453
+ ```
454
+
455
+ Result in `operation_logs`:
456
+
457
+ ```
458
+ ProcessCheckout root=aaa ref=bbb parent=nil
459
+ ValidateCart root=aaa ref=ccc parent=ProcessCheckout/bbb
460
+ ChargePayment root=aaa ref=ddd parent=ProcessCheckout/bbb
461
+ CreateOrder root=aaa ref=eee parent=ProcessCheckout/bbb
462
+ ```
463
+
431
464
  ---
432
465
 
433
466
  ## `prepare` — Pre-registered Callbacks
@@ -613,6 +646,8 @@ end
613
646
  |---|---|---|
614
647
  | `model:` | required | ActiveRecord class to write logs into |
615
648
  | `record_params:` | `true` | Set `false` to skip serializing ctx params |
649
+ | `record_result:` | `nil` | Plugin-level default for result capture (Hash/Proc/Symbol — see below) |
650
+ | `scrub_keys:` | `[]` | Extra keys/patterns to scrub from `params_data` (Symbol, String, Regexp) — additive with built-in list |
616
651
 
617
652
  **Required model columns:**
618
653
 
@@ -627,7 +662,111 @@ create_table :operation_logs do |t|
627
662
  end
628
663
  ```
629
664
 
630
- The plugin automatically scrubs these keys from `params_data` before persisting: `:password`, `:password_confirmation`, `:token`, `:secret`, `:api_key`. ActiveRecord objects are serialized as `{ id:, class: }` rather than their full representation.
665
+ **Optional flow-tracing columns:**
666
+
667
+ Add these columns to reconstruct the full call tree when nested flows run. They are populated automatically when present — missing columns are silently skipped (backward-compatible):
668
+
669
+ ```ruby
670
+ add_column :operation_logs, :root_reference_id, :string
671
+ add_column :operation_logs, :reference_id, :string
672
+ add_column :operation_logs, :parent_operation_name, :string
673
+ add_column :operation_logs, :parent_reference_id, :string
674
+
675
+ add_index :operation_logs, :root_reference_id
676
+ add_index :operation_logs, :reference_id, unique: true
677
+ add_index :operation_logs, :parent_reference_id
678
+ ```
679
+
680
+ All operations triggered by a single top-level call share the same `root_reference_id`. The `parent_operation_name` and `parent_reference_id` columns link each operation to its direct caller. `Easyop::Flow` automatically forwards these ctx keys to child steps — see the [Flow section](#flow--composing-operations) for how to make the flow itself appear as the tree root. Example (flow with nested steps):
681
+
682
+ ```
683
+ FullCheckout root=aaa ref=bbb parent=nil
684
+ AuthAndValidate root=aaa ref=ccc parent=FullCheckout/bbb
685
+ AuthUser root=aaa ref=ddd parent=AuthAndValidate/ccc
686
+ ProcessPayment root=aaa ref=eee parent=FullCheckout/bbb
687
+ ```
688
+
689
+ Useful model helpers:
690
+
691
+ ```ruby
692
+ scope :for_tree, ->(id) { where(root_reference_id: id).order(:performed_at) }
693
+ def root?; parent_reference_id.nil?; end
694
+
695
+ # Fetch the entire execution tree for one top-level call:
696
+ root_log = OperationLog.find_by(operation_name: "FullCheckout", parent_reference_id: nil)
697
+ OperationLog.for_tree(root_log.root_reference_id)
698
+ ```
699
+
700
+ **Scrubbing params** — all layers are additive (none replaces the built-in list):
701
+
702
+ 1. **Built-in `SCRUBBED_KEYS`** — always applied: `:password`, `:password_confirmation`, `:token`, `:secret`, `:api_key`
703
+ 2. **Global config** — applied to every recorded operation:
704
+ ```ruby
705
+ Easyop.configure { |c| c.recording_scrub_keys = [:api_token, /token/i] }
706
+ ```
707
+ 3. **Plugin `scrub_keys:` option** — applied to all subclasses that share the plugin install:
708
+ ```ruby
709
+ plugin Easyop::Plugins::Recording, model: OperationLog, scrub_keys: [:stripe_secret]
710
+ ```
711
+ 4. **`scrub_params` DSL** — per-class, inheritable, and stackable at any level of the hierarchy:
712
+ ```ruby
713
+ class ApplicationOperation < ...
714
+ scrub_params :internal_token, /access.?key/i
715
+ end
716
+ class Payments::ChargeCard < ApplicationOperation
717
+ scrub_params :card_number # stacks on top of parent's list
718
+ end
719
+ ```
720
+
721
+ Internal tracing keys (`__recording_*`) are always excluded. ActiveRecord objects are serialized as `{ id:, class: }` rather than their full representation.
722
+
723
+ **`record_result` DSL — capture output data:**
724
+
725
+ Add an optional `result_data :text` column to persist selected ctx values after the operation runs:
726
+
727
+ ```ruby
728
+ add_column :operation_logs, :result_data, :text # stored as JSON
729
+ ```
730
+
731
+ Then declare what to record using the `record_result` DSL (three forms):
732
+
733
+ ```ruby
734
+ # Attrs form — one or more ctx keys
735
+ class PlaceOrder < ApplicationOperation
736
+ record_result attrs: :order_id
737
+ end
738
+
739
+ class ProcessPayment < ApplicationOperation
740
+ record_result attrs: [:charge_id, :amount_cents]
741
+ end
742
+
743
+ # Block form — custom extraction
744
+ class GenerateReport < ApplicationOperation
745
+ record_result { |ctx| { rows: ctx.rows.count, format: ctx.format } }
746
+ end
747
+
748
+ # Symbol form — delegates to a private instance method
749
+ class BuildInvoice < ApplicationOperation
750
+ record_result :build_result
751
+
752
+ private
753
+
754
+ def build_result
755
+ { invoice_id: ctx.invoice.id, total: ctx.total }
756
+ end
757
+ end
758
+ ```
759
+
760
+ Set a plugin-level default inherited by all subclasses:
761
+
762
+ ```ruby
763
+ plugin Easyop::Plugins::Recording, model: OperationLog,
764
+ record_result: { attrs: :metadata }
765
+ # or: record_result: ->(ctx) { { id: ctx.record_id } }
766
+ # or: record_result: :build_result
767
+ ```
768
+
769
+ Class-level `record_result` overrides the plugin-level default. Missing ctx keys produce `nil` — no error. The `result_data` column is silently skipped when absent from the model table — fully backward-compatible.
631
770
 
632
771
  **Opt out per class:**
633
772
 
@@ -1342,7 +1481,7 @@ Edit `lib/easyop/version.rb` and increment the version string following [Semanti
1342
1481
  ```ruby
1343
1482
  # lib/easyop/version.rb
1344
1483
  module Easyop
1345
- VERSION = "0.1.4" # was 0.1.3
1484
+ VERSION = "0.1.5" # was 0.1.4
1346
1485
  end
1347
1486
  ```
1348
1487
 
@@ -1353,45 +1492,45 @@ In `CHANGELOG.md`, move everything under `[Unreleased]` into a new versioned sec
1353
1492
  ```markdown
1354
1493
  ## [Unreleased]
1355
1494
 
1356
- ## [0.1.4] — YYYY-MM-DD # ← new section
1495
+ ## [0.1.5] — YYYY-MM-DD # ← new section
1357
1496
 
1358
1497
  ### Added
1359
1498
  - …
1360
1499
 
1361
- ## [0.1.3] — 2026-04-14
1500
+ ## [0.1.4] — 2026-04-14
1362
1501
  ```
1363
1502
 
1364
1503
  Add a comparison link at the bottom of the file:
1365
1504
 
1366
1505
  ```markdown
1367
- [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.4...HEAD
1506
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.5...HEAD
1507
+ [0.1.5]: https://github.com/pniemczyk/easyop/compare/v0.1.4...v0.1.5
1368
1508
  [0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
1369
- [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
1370
1509
  ```
1371
1510
 
1372
1511
  ### 3. Commit the release changes
1373
1512
 
1374
1513
  ```bash
1375
1514
  git add lib/easyop/version.rb CHANGELOG.md
1376
- git commit -m "Release v0.1.4"
1515
+ git commit -m "Release v0.1.5"
1377
1516
  ```
1378
1517
 
1379
1518
  ### 4. Tag the commit
1380
1519
 
1381
1520
  ```bash
1382
- git tag -a v0.1.4 -m "Release v0.1.4"
1521
+ git tag -a v0.1.5 -m "Release v0.1.5"
1383
1522
  ```
1384
1523
 
1385
1524
  ### 5. Push the commit and tag
1386
1525
 
1387
1526
  ```bash
1388
1527
  git push origin master
1389
- git push origin v0.1.4
1528
+ git push origin v0.1.5
1390
1529
  ```
1391
1530
 
1392
1531
  ### 6. Build and push the gem (optional)
1393
1532
 
1394
1533
  ```bash
1395
1534
  gem build easyop.gemspec
1396
- gem push easyop-0.1.4.gem
1535
+ gem push easyop-0.1.5.gem
1397
1536
  ```
@@ -15,10 +15,19 @@ module Easyop
15
15
  # Easyop.configure { |c| c.event_bus = :active_support }
16
16
  attr_accessor :event_bus
17
17
 
18
+ # Extra keys to scrub from params_data across all recorded operations.
19
+ # Appended to Recording::SCRUBBED_KEYS — never replaces the built-in list.
20
+ # Accepts Symbol, String, or Regexp (matched against the stringified key name).
21
+ #
22
+ # @example
23
+ # Easyop.configure { |c| c.recording_scrub_keys = [:api_token, /token/i] }
24
+ attr_accessor :recording_scrub_keys
25
+
18
26
  def initialize
19
- @type_adapter = :native
20
- @strict_types = false
21
- @event_bus = nil # nil = Memory bus (see Easyop::Events::Registry)
27
+ @type_adapter = :native
28
+ @strict_types = false
29
+ @event_bus = nil # nil = Memory bus (see Easyop::Events::Registry)
30
+ @recording_scrub_keys = []
22
31
  end
23
32
  end
24
33
 
data/lib/easyop/flow.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'securerandom'
2
+
1
3
  module Easyop
2
4
  # Compose a sequence of operations that share a single ctx.
3
5
  #
@@ -11,6 +13,27 @@ module Easyop
11
13
  # flow ValidateCart, ChargeCard, CreateOrder, NotifyUser
12
14
  # end
13
15
  #
16
+ # ## Recording plugin integration (flow tracing)
17
+ #
18
+ # When steps have the Recording plugin installed, `CallBehavior#call` forwards
19
+ # the parent ctx keys so every step log entry shows the flow as its parent:
20
+ #
21
+ # # Bare flow — flow itself is not recorded but steps see it as parent:
22
+ # class ProcessOrder
23
+ # include Easyop::Flow
24
+ # flow ValidateCart, ChargeCard
25
+ # end
26
+ #
27
+ # For full tree reconstruction (flow appears in operation_logs as the root
28
+ # entry) inherit from your recorded base class and opt out of the transaction
29
+ # so step-level transactions are not shadowed by an outer one:
30
+ #
31
+ # class ProcessOrder < ApplicationOperation
32
+ # include Easyop::Flow
33
+ # transactional false # EasyOp handles rollback; steps own their transactions
34
+ # flow ValidateCart, ChargeCard
35
+ # end
36
+ #
14
37
  # result = ProcessOrder.call(user: user, cart: cart)
15
38
  # result.on_success { |ctx| redirect_to order_path(ctx.order) }
16
39
  # result.on_failure { |ctx| flash[:alert] = ctx.error }
@@ -31,6 +54,25 @@ module Easyop
31
54
  # otherwise place Operation earlier in the ancestor chain than Flow itself).
32
55
  module CallBehavior
33
56
  def call
57
+ # ── Flow-tracing forwarding for the Recording plugin ──────────────────
58
+ # When Recording is NOT installed on this flow class (i.e. the flow does
59
+ # not inherit from a base operation that has Recording), set the
60
+ # __recording_parent_* ctx keys manually so every step operation knows
61
+ # this flow is its parent. When Recording IS installed on the flow (its
62
+ # RunWrapper runs before `call` is reached), it has already set up the
63
+ # parent context correctly — we detect that via _recording_enabled? and
64
+ # skip to avoid a conflict. If Recording is not used at all, these ctx
65
+ # keys are unused and ignored.
66
+ _flow_tracing = self.class.name &&
67
+ !self.class.respond_to?(:_recording_enabled?)
68
+ if _flow_tracing
69
+ ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
70
+ _prev_parent_name = ctx[:__recording_parent_operation_name]
71
+ _prev_parent_id = ctx[:__recording_parent_reference_id]
72
+ ctx[:__recording_parent_operation_name] = self.class.name
73
+ ctx[:__recording_parent_reference_id] = SecureRandom.uuid
74
+ end
75
+
34
76
  pending_guard = nil
35
77
 
36
78
  self.class._flow_steps.each do |step|
@@ -56,6 +98,12 @@ module Easyop
56
98
  rescue Ctx::Failure
57
99
  ctx.rollback!
58
100
  raise
101
+ ensure
102
+ # Restore parent context so any caller above this flow sees the right parent.
103
+ if _flow_tracing
104
+ ctx[:__recording_parent_operation_name] = _prev_parent_name
105
+ ctx[:__recording_parent_reference_id] = _prev_parent_id
106
+ end
59
107
  end
60
108
  end
61
109
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Easyop
4
6
  module Plugins
5
7
  # Records operation executions to a database model.
@@ -18,6 +20,15 @@ module Easyop
18
20
  # duration_ms :float
19
21
  # performed_at :datetime, null: false
20
22
  #
23
+ # Optional flow-tracing columns:
24
+ # root_reference_id :string # shared across entire execution tree
25
+ # reference_id :string # unique to this operation execution
26
+ # parent_operation_name :string # class name of the direct parent
27
+ # parent_reference_id :string # reference_id of the direct parent
28
+ #
29
+ # Optional result column:
30
+ # result_data :text # stored as JSON — selected ctx keys after call
31
+ #
21
32
  # Opt out per operation class:
22
33
  # class MyOp < ApplicationOperation
23
34
  # recording false
@@ -26,15 +37,27 @@ module Easyop
26
37
  # Options:
27
38
  # model: (required) ActiveRecord class
28
39
  # record_params: true pass false to skip params serialization
40
+ # record_result: nil configure result capture at plugin level (Hash/Proc/Symbol)
41
+ # scrub_keys: [] additional keys/patterns to scrub from params_data
42
+ # (Symbol, String, or Regexp — additive with SCRUBBED_KEYS)
29
43
  module Recording
30
- # Sensitive keys scrubbed from params_data before persisting.
44
+ # Sensitive keys always scrubbed from params_data before persisting.
31
45
  SCRUBBED_KEYS = %i[password password_confirmation token secret api_key].freeze
32
46
 
33
- def self.install(base, model:, record_params: true, **_options)
47
+ # Internal ctx keys used for flow tracing — excluded from params_data.
48
+ INTERNAL_CTX_KEYS = %i[
49
+ __recording_root_reference_id
50
+ __recording_parent_operation_name
51
+ __recording_parent_reference_id
52
+ ].freeze
53
+
54
+ def self.install(base, model:, record_params: true, record_result: nil, scrub_keys: [], **_options)
34
55
  base.extend(ClassMethods)
35
56
  base.prepend(RunWrapper)
36
- base.instance_variable_set(:@_recording_model, model)
37
- base.instance_variable_set(:@_recording_record_params, record_params)
57
+ base.instance_variable_set(:@_recording_model, model)
58
+ base.instance_variable_set(:@_recording_record_params, record_params)
59
+ base.instance_variable_set(:@_recording_record_result, record_result)
60
+ base.instance_variable_set(:@_recording_scrub_keys, Array(scrub_keys))
38
61
  end
39
62
 
40
63
  module ClassMethods
@@ -62,6 +85,59 @@ module Easyop
62
85
  true
63
86
  end
64
87
  end
88
+
89
+ # DSL for capturing result data after the operation runs.
90
+ # Three forms:
91
+ # record_result attrs: :key # one or more ctx keys
92
+ # record_result { |ctx| { k: ctx.k } } # block
93
+ # record_result :build_result # private instance method name
94
+ def record_result(value = nil, attrs: nil, &block)
95
+ @_recording_record_result = if block
96
+ block
97
+ elsif attrs
98
+ { attrs: attrs }
99
+ elsif value.is_a?(Symbol)
100
+ value
101
+ else
102
+ value
103
+ end
104
+ end
105
+
106
+ def _recording_record_result_config
107
+ if instance_variable_defined?(:@_recording_record_result)
108
+ @_recording_record_result
109
+ elsif superclass.respond_to?(:_recording_record_result_config)
110
+ superclass._recording_record_result_config
111
+ else
112
+ nil
113
+ end
114
+ end
115
+
116
+ # DSL to declare additional keys/patterns to scrub from params_data.
117
+ # Accepts Symbol, String, or Regexp. Additive with SCRUBBED_KEYS and
118
+ # any scrub_keys declared on parent classes or at the plugin install level.
119
+ #
120
+ # @example
121
+ # class ApplicationOperation < ...
122
+ # scrub_params :api_token, /access.?key/i
123
+ # end
124
+ def scrub_params(*keys)
125
+ @_recording_scrub_keys = _own_recording_scrub_keys + keys
126
+ end
127
+
128
+ # Returns the merged scrub list: parent class keys + this class's own keys.
129
+ # Does NOT include SCRUBBED_KEYS or the global config list — those are
130
+ # merged at persist time so they stay hot-reloadable.
131
+ def _recording_scrub_keys
132
+ parent = superclass.respond_to?(:_recording_scrub_keys) ? superclass._recording_scrub_keys : []
133
+ parent + _own_recording_scrub_keys
134
+ end
135
+
136
+ private
137
+
138
+ def _own_recording_scrub_keys
139
+ instance_variable_defined?(:@_recording_scrub_keys) ? @_recording_scrub_keys : []
140
+ end
65
141
  end
66
142
 
67
143
  module RunWrapper
@@ -70,6 +146,23 @@ module Easyop
70
146
  return super unless (model = self.class._recording_model)
71
147
  return super unless self.class.name # skip anonymous classes
72
148
 
149
+ # -- Flow tracing --
150
+ # Each operation gets its own reference_id. The root_reference_id is
151
+ # shared across the entire execution tree via ctx (set once, inherited).
152
+ reference_id = SecureRandom.uuid
153
+ root_reference_id = ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
154
+
155
+ # Read current parent context — these become THIS operation's parent fields.
156
+ parent_operation_name = ctx[:__recording_parent_operation_name]
157
+ parent_reference_id = ctx[:__recording_parent_reference_id]
158
+
159
+ # Set THIS operation as the parent for any children that run inside super.
160
+ # Save the previous values so we can restore them after (for siblings).
161
+ prev_parent_name = parent_operation_name
162
+ prev_parent_id = parent_reference_id
163
+ ctx[:__recording_parent_operation_name] = self.class.name
164
+ ctx[:__recording_parent_reference_id] = reference_id
165
+
73
166
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
167
  super
75
168
  ensure
@@ -78,22 +171,37 @@ module Easyop
78
171
  # branch the tap block would be skipped and failures inside flows would
79
172
  # never be persisted.
80
173
  if start
174
+ # Restore parent context so sibling steps see the correct parent.
175
+ ctx[:__recording_parent_operation_name] = prev_parent_name
176
+ ctx[:__recording_parent_reference_id] = prev_parent_id
177
+
81
178
  ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
82
- _recording_persist!(ctx, model, ms)
179
+ _recording_persist!(ctx, model, ms,
180
+ root_reference_id: root_reference_id,
181
+ reference_id: reference_id,
182
+ parent_operation_name: parent_operation_name,
183
+ parent_reference_id: parent_reference_id)
83
184
  end
84
185
  end
85
186
 
86
187
  private
87
188
 
88
- def _recording_persist!(ctx, model, duration_ms)
189
+ def _recording_persist!(ctx, model, duration_ms,
190
+ root_reference_id: nil, reference_id: nil,
191
+ parent_operation_name: nil, parent_reference_id: nil)
89
192
  attrs = {
90
- operation_name: self.class.name,
91
- success: ctx.success?,
92
- error_message: ctx.error,
93
- performed_at: Time.current,
94
- duration_ms: duration_ms
193
+ operation_name: self.class.name,
194
+ success: ctx.success?,
195
+ error_message: ctx.error,
196
+ performed_at: Time.current,
197
+ duration_ms: duration_ms,
198
+ root_reference_id: root_reference_id,
199
+ reference_id: reference_id,
200
+ parent_operation_name: parent_operation_name,
201
+ parent_reference_id: parent_reference_id
95
202
  }
96
203
  attrs[:params_data] = _recording_safe_params(ctx) if self.class._recording_record_params?
204
+ attrs[:result_data] = _recording_safe_result(ctx) if self.class._recording_record_result_config
97
205
 
98
206
  # Only write columns the model actually has
99
207
  safe = attrs.select { |k, _| model.column_names.include?(k.to_s) }
@@ -103,14 +211,57 @@ module Easyop
103
211
  end
104
212
 
105
213
  def _recording_safe_params(ctx)
214
+ # Merge all scrub layers (additive, never replaces built-ins):
215
+ # 1. SCRUBBED_KEYS — built-in sensitive keys, always applied
216
+ # 2. Easyop.config — global extra keys set in an initializer
217
+ # 3. self.class — plugin install scrub_keys: + class scrub_params DSL
218
+ extra = Easyop.config.recording_scrub_keys.to_a + self.class._recording_scrub_keys
106
219
  ctx.to_h
107
- .except(*SCRUBBED_KEYS)
220
+ .except(*INTERNAL_CTX_KEYS)
221
+ .reject { |k, _| _recording_scrub_key?(k, extra) }
108
222
  .transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
109
223
  .to_json
110
224
  rescue
111
225
  nil
112
226
  end
113
227
 
228
+ # Returns true when +key+ matches any entry in +scrub_list+.
229
+ # Regexp entries match against the stringified key name (case-sensitive
230
+ # unless the Regexp itself uses /i). Symbol/String entries match by value.
231
+ def _recording_scrub_key?(key, extra_list)
232
+ return true if SCRUBBED_KEYS.include?(key.to_sym)
233
+
234
+ extra_list.any? do |pattern|
235
+ case pattern
236
+ when Regexp then pattern.match?(key.to_s)
237
+ when Symbol then pattern == key.to_sym
238
+ else pattern.to_s == key.to_s
239
+ end
240
+ end
241
+ end
242
+
243
+ def _recording_safe_result(ctx)
244
+ config = self.class._recording_record_result_config
245
+ return nil unless config
246
+
247
+ raw = case config
248
+ when Hash
249
+ keys = Array(config[:attrs])
250
+ keys.each_with_object({}) { |k, h| h[k] = ctx[k] }
251
+ when Proc
252
+ config.call(ctx)
253
+ when Symbol
254
+ send(config)
255
+ end
256
+
257
+ return nil unless raw.is_a?(Hash)
258
+
259
+ raw.transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
260
+ .to_json
261
+ rescue
262
+ nil
263
+ end
264
+
114
265
  def _recording_warn(err)
115
266
  return unless defined?(Rails) && Rails.respond_to?(:logger)
116
267
  Rails.logger.warn "[EasyOp::Recording] Failed to record #{self.class.name}: #{err.message}"
data/lib/easyop/schema.rb CHANGED
@@ -161,7 +161,7 @@ module Easyop
161
161
  if Easyop.config.strict_types
162
162
  ctx.fail!(error: msg, errors: ctx.errors.merge(field.name => msg))
163
163
  else
164
- warn "[Easyop] #{msg}"
164
+ $stderr.puts "[Easyop] #{msg}"
165
165
  end
166
166
  end
167
167
  end
@@ -1,3 +1,3 @@
1
1
  module Easyop
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.5"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easyop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Niemczyk