optiaudit-rails 0.1.1 → 0.2.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: 2da47ba93a09717845045e65800eebf3f280a2261402a2bb7f7bf7a2db72a7a2
4
- data.tar.gz: a5c6b63dfdba71cfaf7843f3ba0daedffbd1e5d76834cb4a74bb4174ace02ac7
3
+ metadata.gz: 8ab9fc2af772216e10bb2376e7eb5c7dab31393f41500bf9375564b94c848128
4
+ data.tar.gz: ad4ba3df9b4b5b86cba423aaf50d2f9c87f832f78dde26873ecb3a4c2939f5f5
5
5
  SHA512:
6
- metadata.gz: 6578b9531cbdefe3dcb5c05268d6ebaf9c6bc45c6d7773df090cd2822f325d910f76edad3b5b367752845439b1cb924a3c5f6a0546c8ec41076c7cccb2e1709b
7
- data.tar.gz: eae50f9a7632ca5a6087292e9b3d2e3c99faffa37619a3e1d86dff713c027ae22243b71de72d6ba18e05689ded14c5ca380ddfe86e85c8433ce72960628a37b3
6
+ metadata.gz: 454b2763272e3483aab0f1fb6f8c5dddf647e60dedda80929df8cdb30c0801810b52b146eed5bfea5794f337428e6429443d91c847470002cdfbca295a5e104f
7
+ data.tar.gz: e2f094abd10c8e528f7fcd316c1f9f208c6e53143c6316b57b223774e2cc507bddfa3e1a33211e05216947d290655fb90cf3506939ed53a39011f9b6bd588987
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-23
11
+
12
+ ### Added
13
+
14
+ - **Registro central de acciones auditadas** en `config/optiaudit.rb`, que la gema carga sola al arrancar:
15
+ `Optiaudit.audit { page_view "o/pax/itineraries#index", module: :itineraries, formats: :html, if: ... }` y
16
+ `event :create, "controller#action", module: ...`. Auditar una pantalla nueva es una línea en ese archivo, sin tocar
17
+ controllers. Las macros siguen funcionando.
18
+ - **Validación al arrancar**: cada entrada se compara con las rutas de la app, cuando Rails termina de dibujarlas
19
+ (un bloque `routes.append`, que corre en `RouteSet#finalize!`): sin reordenar initializers ni cargar las rutas dos
20
+ veces, y con los initializers de la app ya aplicados. En development y test una entrada sin
21
+ ruta detiene el arranque (`Optiaudit::InvalidRegistry`); en production se registra un warning y la app arranca.
22
+ `Optiaudit::Testing.verify_registry!` para tenerlo como test.
23
+ - Opción `formats:` (por ejemplo, solo `:html`).
24
+
25
+ ### Performance
26
+
27
+ Un único `after_action` global busca la acción en un hash de dos niveles congelado. Para acciones que no están en el
28
+ registro suma entre 0,6 y 3,7 µs por request (300 entradas, Ruby 2.7.5 + Rails 5.2.8, benchmark intercalado con y sin
29
+ el hook, GC desactivado). La búsqueda en sí son ~117 ns.
30
+
10
31
  ## [0.1.1] - 2026-10-22
11
32
 
12
33
  Correcciones de la verificación del README contra OptiAudit real, con la 0.1.0 publicada.
@@ -37,6 +58,7 @@ Primera versión: el equivalente para Rails de `@theoptimalpartner/nestjs-optiau
37
58
  - **`Optiaudit::Testing`** para que las apps verifiquen sus eventos en tests sin HTTP.
38
59
  - Compatibilidad probada en CI: Ruby 2.7 + Rails 5.2 + Sidekiq 6.3, Ruby 3.0 + Rails 6.1 + Sidekiq 6.5 y Ruby 3.2 + Rails 7.0 + Sidekiq 7.2.
39
60
 
40
- [Unreleased]: https://bitbucket.org/RoutingUC/optiaudit-rails/branches/compare/main%0Dv0.1.1
61
+ [Unreleased]: https://bitbucket.org/RoutingUC/optiaudit-rails/branches/compare/main%0Dv0.2.0
62
+ [0.2.0]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.2.0/
41
63
  [0.1.1]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.1.1/
42
64
  [0.1.0]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.1.0/
data/README.md CHANGED
@@ -9,6 +9,7 @@ Integrating an app is adding the gem, two environment variables and one line per
9
9
  ## Features
10
10
 
11
11
  - **Zero-config**: Reads `OPTIAUDIT_URL` and `OPTIAUDIT_WRITE_KEY` from the environment
12
+ - **One list of audited actions**: `config/optiaudit.rb` declares what the app audits, without touching controllers, checked against the routes at boot
12
13
  - **Controller macros**: `optiaudit_page_view` and `optiaudit_event`, the equivalent of `@TrackPageView()` and `@TrackEvent()`
13
14
  - **Auto context extraction**: actor from `current_user`, client IP, user agent, path, request id and a hashed session id
14
15
  - **Never breaks the request**: delivery runs in Sidekiq or a background thread, and every error is logged as a warning
@@ -56,7 +57,44 @@ If the app runs Sidekiq, events go through the `optiaudit` queue. Add it to the
56
57
 
57
58
  Apps without Sidekiq need nothing: the gem uses a background thread (see [Delivery](#delivery)).
58
59
 
59
- ### 3. Use the Macros
60
+ ### 3. Declare what to audit
61
+
62
+ List the audited actions in `config/optiaudit.rb`. The gem loads it at boot: auditing a new screen is
63
+ one line in this file, and no controller changes.
64
+
65
+ ```ruby
66
+ # config/optiaudit.rb
67
+ Optiaudit.audit do
68
+ page_view "routes#index", module: :routes, formats: :html
69
+ page_view "routes#show", module: :routes, formats: :html
70
+ event :update, "routes#update", module: :routes
71
+ event :delete, "routes#destroy", module: :routes,
72
+ metadata: ->(controller) { { action: "delete_route", route_id: controller.params[:id] } }
73
+ end
74
+ ```
75
+
76
+ - **Key:** `"<controller_path>#<action>"` of the controller that serves the request (`Admin::RoutesController`
77
+ is `"admin/routes"`). A parent controller does not cover its subclasses: an action served by two
78
+ controllers needs two lines.
79
+ - **Options:** `module:` (required), `formats:` (one or more, e.g. `:html`; default all), `if:` / `unless:`
80
+ (a controller method name or a lambda that receives the controller) and `metadata:` (a hash or a lambda
81
+ that receives the controller).
82
+ - **Checked at boot:** an entry without a route (a renamed controller, a typo) stops the boot in
83
+ development and test, so it never reaches production unnoticed. In production it is logged
84
+ (`<key> has no route: that action is not being audited`) and the app boots.
85
+ - The same rules as the macros apply: only 2xx and 3xx, only with an actor, never raises.
86
+ - **Cost:** one global `after_action` looks the action up in a frozen hash. For actions not in the list
87
+ it adds about 1–4 µs per request (measured with 300 entries on Ruby 2.7 / Rails 5.2).
88
+ - **Keep metadata in memory:** lambdas run inside the request; use params and instance variables the
89
+ action already loaded, never database queries.
90
+
91
+ Changes to this file go through code review and a deploy, so the audit trail of what is audited is the
92
+ repository history.
93
+
94
+ ### Or use the Macros
95
+
96
+ The registry is the recommended way. The macros remain for apps that prefer to declare tracking in
97
+ the controller:
60
98
 
61
99
  ```ruby
62
100
  class RoutesController < ApplicationController
@@ -258,6 +296,9 @@ it "audits the update" do
258
296
  "metadata" => include("module" => "routes")
259
297
  )
260
298
  end
299
+
300
+ # Fails when an entry of config/optiaudit.rb has no route
301
+ it("audits only routed actions") { Optiaudit::Testing.verify_registry! }
261
302
  ```
262
303
 
263
304
  ## Track Params
@@ -299,6 +340,8 @@ the gem truncates or omits fields instead of sending them as they are.
299
340
  | HTTP 4xx (other) | Logs warning, doesn't raise, no retry |
300
341
  | HTTP 207 on a batch | Logs warning with the rejected events |
301
342
  | Redis down when enqueueing | Logs warning, doesn't raise |
343
+ | `config/optiaudit.rb` entry without a route | Raises at boot in development and test; logs a warning in production |
344
+ | `if:` / `metadata:` lambda raises | Event not sent, logs warning, doesn't raise |
302
345
 
303
346
  ## Contributing
304
347
 
@@ -33,6 +33,31 @@ module Optiaudit
33
33
 
34
34
  private
35
35
 
36
+ # Global after_action (added by the Railtie): tracks the action if it is in
37
+ # the registry. Unregistered actions cost one hash lookup.
38
+ def optiaudit_track_registered
39
+ entry = Optiaudit.registry.lookup(controller_path, action_name)
40
+ return unless entry && optiaudit_trackable_response?
41
+ return unless entry.formats.nil? || entry.formats.include?(request.format.to_sym)
42
+ return unless optiaudit_condition(entry.if, true) && !optiaudit_condition(entry.unless, false)
43
+
44
+ optiaudit_track(entry.event_type, entry.module_name, metadata: entry.metadata)
45
+ rescue StandardError => e
46
+ Optiaudit.log_warning("could not track #{entry&.key}: #{e.class}: #{e.message}")
47
+ end
48
+
49
+ def optiaudit_condition(condition, default)
50
+ case condition
51
+ when nil then default
52
+ when Symbol, String then send(condition)
53
+ else condition.call(self)
54
+ end
55
+ end
56
+
57
+ def optiaudit_trackable_response?
58
+ response.status >= 200 && response.status < 400
59
+ end
60
+
36
61
  # Tracks an event with the actor and request data of this controller.
37
62
  # `params` accepts every field of Optiaudit.track and overrides the defaults;
38
63
  # `metadata` may also be a lambda that receives the controller.
@@ -55,7 +80,7 @@ module Optiaudit
55
80
  end
56
81
 
57
82
  def optiaudit_track_action(event_type, module_name, metadata)
58
- return unless response.status >= 200 && response.status < 400
83
+ return unless optiaudit_trackable_response?
59
84
 
60
85
  optiaudit_track(event_type, module_name, metadata: metadata)
61
86
  end
@@ -8,6 +8,7 @@ module Optiaudit
8
8
  ActiveSupport.on_load(:action_controller) do
9
9
  require "optiaudit/controller"
10
10
  include Optiaudit::Controller
11
+ after_action :optiaudit_track_registered
11
12
  end
12
13
  end
13
14
 
@@ -16,5 +17,21 @@ module Optiaudit
16
17
  config.after_initialize do
17
18
  require "optiaudit/sidekiq_job" if defined?(::Sidekiq)
18
19
  end
20
+
21
+ # The app's list of audited actions (config/optiaudit.rb), loaded and
22
+ # verified against the routes when Rails finishes drawing them: an entry
23
+ # without a route stops the boot in development and test, and is logged in
24
+ # production. A routes.append block runs at that point (RouteSet#finalize!)
25
+ # in every supported Rails, without reordering initializers or loading the
26
+ # routes twice. By then the app initializers ran, so its config applies.
27
+ initializer "optiaudit.registry" do |app|
28
+ path = app.root.join("config", "optiaudit.rb")
29
+ next unless File.exist?(path)
30
+
31
+ app.routes.append do
32
+ Optiaudit::Registry.load_file(path)
33
+ Optiaudit.registry.verify!(app.routes, strict: !::Rails.env.production?)
34
+ end
35
+ end
19
36
  end
20
37
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ # Raised by Registry#verify! in strict mode when entries have no route.
5
+ class InvalidRegistry < StandardError; end
6
+
7
+ # The actions an app audits, declared in one place (config/optiaudit.rb)
8
+ # instead of with macros in each controller:
9
+ #
10
+ # Optiaudit.audit do
11
+ # page_view "o/pax/itineraries#index", module: :itineraries, formats: :html, if: :sky?
12
+ # event :create, "o/sky/demand_set_imports#confirm", module: :demand_sets,
13
+ # metadata: ->(controller) { { import_id: controller.params[:id] } }
14
+ # end
15
+ #
16
+ # Keys are "<controller_path>#<action>" of the controller that serves the
17
+ # request: a parent controller does not cover its subclasses.
18
+ #
19
+ # Every request looks itself up here, so the registry is a frozen two-level
20
+ # hash ({ "o/pax/itineraries" => { "index" => entry } }) and the lookup
21
+ # allocates nothing.
22
+ class Registry
23
+ Entry = Struct.new(:key, :event_type, :module_name, :formats, :if, :unless, :metadata, keyword_init: true)
24
+
25
+ # The DSL of Optiaudit.audit.
26
+ class Builder
27
+ def initialize(config)
28
+ @config = config
29
+ @entries = {}
30
+ end
31
+
32
+ def page_view(key, **options)
33
+ event(:page_view, key, **options)
34
+ end
35
+
36
+ # Options: module (required), formats, if, unless, metadata. if/unless
37
+ # take a controller method name or a lambda that receives the controller.
38
+ def event(event_type, key, module: nil, formats: nil, if: nil, unless: nil, metadata: nil)
39
+ module_name = binding.local_variable_get(:module)
40
+ controller_path, action = parse_key(key)
41
+ validate!(key, event_type.to_s, module_name)
42
+
43
+ actions = (@entries[controller_path] ||= {})
44
+ raise ArgumentError, "#{key} declared twice in the Optiaudit registry" if actions.key?(action)
45
+
46
+ actions[action] = Entry.new(
47
+ key: key, event_type: event_type.to_s, module_name: module_name.to_s,
48
+ formats: formats && Array(formats).map(&:to_sym).freeze,
49
+ if: binding.local_variable_get(:if), unless: binding.local_variable_get(:unless), metadata: metadata
50
+ ).freeze
51
+ end
52
+
53
+ def build
54
+ Registry.new(@entries.transform_values(&:freeze).freeze)
55
+ end
56
+
57
+ private
58
+
59
+ def parse_key(key)
60
+ controller_path, action = key.to_s.split("#", 2)
61
+ if Util.blank?(controller_path) || Util.blank?(action)
62
+ raise ArgumentError, %(#{key.inspect} is not "controller_path#action")
63
+ end
64
+
65
+ [controller_path.freeze, action.freeze]
66
+ end
67
+
68
+ def validate!(key, event_type, module_name)
69
+ raise ArgumentError, "#{key}: module is required" if Util.blank?(module_name)
70
+ return if @config.event_types.map(&:to_s).include?(event_type)
71
+
72
+ raise ArgumentError, "#{key}: unknown event_type #{event_type.inspect}"
73
+ end
74
+ end
75
+
76
+ # Loads the app's list (config/optiaudit.rb). Returns false when there is none.
77
+ def self.load_file(path)
78
+ return false unless File.exist?(path)
79
+
80
+ load path.to_s
81
+ true
82
+ end
83
+
84
+ def initialize(entries)
85
+ @entries = entries
86
+ freeze
87
+ end
88
+
89
+ def lookup(controller_path, action_name)
90
+ actions = @entries[controller_path]
91
+ actions && actions[action_name]
92
+ end
93
+
94
+ def empty?
95
+ @entries.empty?
96
+ end
97
+
98
+ def keys
99
+ @entries.flat_map { |_, actions| actions.values.map(&:key) }.sort
100
+ end
101
+
102
+ # Checks every entry against the app routes, so that renaming a controller
103
+ # does not silently stop auditing it. Strict mode raises; otherwise each
104
+ # missing action is logged. Returns the keys without a route.
105
+ def verify!(routes, strict:)
106
+ routed = routes.routes.each_with_object({}) do |route, set|
107
+ controller, action = route.defaults.values_at(:controller, :action)
108
+ set["#{controller}##{action}"] = true if controller && action
109
+ end
110
+ missing = keys.reject { |key| routed.key?(key) }
111
+ return missing if missing.empty?
112
+
113
+ raise InvalidRegistry, "Optiaudit registry entries without a route: #{missing.join(', ')}" if strict
114
+
115
+ missing.each { |key| Optiaudit.log_warning("#{key} has no route: that action is not being audited") }
116
+ missing
117
+ end
118
+
119
+ EMPTY = new({}.freeze)
120
+ end
121
+ end
@@ -49,5 +49,13 @@ module Optiaudit
49
49
  def clear!
50
50
  events.clear
51
51
  end
52
+
53
+ # For the app's test suite: fails when an entry of config/optiaudit.rb has
54
+ # no route.
55
+ #
56
+ # it("audits only routed actions") { Optiaudit::Testing.verify_registry! }
57
+ def verify_registry!
58
+ Optiaudit.registry.verify!(::Rails.application.routes, strict: true)
59
+ end
52
60
  end
53
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Optiaudit
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/optiaudit.rb CHANGED
@@ -9,6 +9,7 @@ require "optiaudit/event"
9
9
  require "optiaudit/client"
10
10
  require "optiaudit/actor"
11
11
  require "optiaudit/request_data"
12
+ require "optiaudit/registry"
12
13
  require "optiaudit/testing"
13
14
 
14
15
  # Sends audit events to OptiAudit. See README.md.
@@ -35,6 +36,19 @@ module Optiaudit
35
36
  Testing.fake? || configuration.enabled?
36
37
  end
37
38
 
39
+ # Declares the actions the app audits (see Optiaudit::Registry). Called from
40
+ # config/optiaudit.rb, which the Railtie loads at boot. Replaces any previous
41
+ # declaration.
42
+ def audit(&block)
43
+ builder = Registry::Builder.new(configuration)
44
+ builder.instance_exec(&block)
45
+ @registry = builder.build
46
+ end
47
+
48
+ def registry
49
+ @registry || Registry::EMPTY
50
+ end
51
+
38
52
  # Tracks an event. Returns true when it was handed to the adapter, false when
39
53
  # it was skipped (disabled, invalid) or failed. Never raises.
40
54
  #
@@ -80,6 +94,7 @@ module Optiaudit
80
94
  def reset!
81
95
  discard_adapter
82
96
  @configuration = nil
97
+ @registry = nil
83
98
  end
84
99
 
85
100
  private
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: optiaudit-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Optimal Partner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-22 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -50,6 +50,7 @@ files:
50
50
  - lib/optiaudit/event.rb
51
51
  - lib/optiaudit/rails.rb
52
52
  - lib/optiaudit/railtie.rb
53
+ - lib/optiaudit/registry.rb
53
54
  - lib/optiaudit/request_data.rb
54
55
  - lib/optiaudit/sidekiq_job.rb
55
56
  - lib/optiaudit/testing.rb