optiaudit-rails 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 44ee00e821c324db9b3aba699c39e04eff9b471b1d1dfd5779deae13c6b2f293
4
+ data.tar.gz: cc49b0ca5595307c1461067dc06467a1f056c998b8e795a35beb29510aeb44d7
5
+ SHA512:
6
+ metadata.gz: 3b50e99331b77e089addca33877bf5ba22c38b7f37f3303fb911593382c48200b74b36eaeb321ace3baa748e1a47ac9f5c7c622e98609656f6b067d54bb38e10
7
+ data.tar.gz: 8559093f201881f42ab2482949b5128c9778f095e0e394fa74187df8b1d6ff48120d8a0093b4032f03feefd47a24d3fbc76552a5bd103f839dac1f301cf032a9
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-22
11
+
12
+ Primera versión: el equivalente para Rails de `@theoptimalpartner/nestjs-optiaudit` 0.1.8 (SC25-2703).
13
+
14
+ ### Added
15
+
16
+ - **Macros de controller** `optiaudit_page_view` y `optiaudit_event`, equivalentes a `@TrackPageView` y `@TrackEvent`. Corren en un `after_action`, sólo si la acción no lanzó excepción y la respuesta es 2xx o 3xx. Aceptan `only`/`except`/`if`/`unless` y `metadata` como hash o lambda.
17
+ - **`Optiaudit.track`** para tracking manual y **`optiaudit_track`** dentro de una acción, que completa actor y datos del request.
18
+ - **Actor** desde `current_user` (`id`, `email`, `organization_id`), configurable con `config.actor`. Sin actor no se envía nada, como en la 0.1.7 de la librería JS.
19
+ - **IP real del cliente** con `request.remote_ip`, que salta los proxies de confianza (rangos privados) igual que el `extractClientIp` de la 0.1.8 de la JS. Si Rails detecta spoofing (`IpSpoofAttackError`), cae en `request.ip` y deja un warning.
20
+ - **`session_id` hasheado con SHA-256**: el id crudo de la sesión nunca sale de la app. **`X-Request-ID`** del request en `metadata.request_id` y en el header del POST.
21
+ - **Saneo antes de enviar.** OptiAudit rechaza el evento entero (y en un batch, el batch entero) ante un campo inválido, así que la gema trunca los strings a los largos del API, omite un `actor_email` que no es email y un `organization_id` que no es entero, y descarta con warning los `event_type` fuera del catálogo (configurable con `config.event_types`).
22
+ - **Adaptadores de entrega**: `:sidekiq` (default si Sidekiq está cargado; un job por evento, reintentos sólo en 5xx/429/timeout/conexión), `:thread` (batches a `/events/batch` desde un thread en segundo plano, seguro con fork) e `:inline`. Ningún error llega al request.
23
+ - **`Optiaudit::Testing`** para que las apps verifiquen sus eventos en tests sin HTTP.
24
+ - 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.
25
+
26
+ [Unreleased]: https://bitbucket.org/RoutingUC/optiaudit-rails/branches/compare/main%0Dv0.1.0
27
+ [0.1.0]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.1.0/
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Optimal Partner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,319 @@
1
+ # optiaudit-rails
2
+
3
+ Rails client for OptiAudit event tracking. The Ruby counterpart of
4
+ [`@theoptimalpartner/nestjs-optiaudit`](https://www.npmjs.com/package/@theoptimalpartner/nestjs-optiaudit):
5
+ same conventions, same events, so a Rails app and a NestJS app report to OptiAudit the same way.
6
+
7
+ Integrating an app is adding the gem, two environment variables and one line per action you want to audit.
8
+
9
+ ## Features
10
+
11
+ - **Zero-config**: Reads `OPTIAUDIT_URL` and `OPTIAUDIT_WRITE_KEY` from the environment
12
+ - **Controller macros**: `optiaudit_page_view` and `optiaudit_event`, the equivalent of `@TrackPageView()` and `@TrackEvent()`
13
+ - **Auto context extraction**: actor from `current_user`, client IP, user agent, path, request id and a hashed session id
14
+ - **Never breaks the request**: delivery runs in Sidekiq or a background thread, and every error is logged as a warning
15
+ - **No lost events on deploy**: with Sidekiq, events survive restarts and are retried on 5xx, 429 and timeouts
16
+ - **Valid payloads**: fields that would make OptiAudit reject the whole event are fixed or dropped before sending
17
+ - **Silent fallback**: Disabled automatically in tests or when env vars are missing
18
+ - **Test mode**: `Optiaudit::Testing` records events in memory so apps can assert on them
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem "optiaudit-rails"
25
+ ```
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ The only runtime dependency is `railties`. HTTP goes through `Net::HTTP`, so the gem does not pull or
32
+ constrain Faraday, HTTParty or any other client your app already uses.
33
+
34
+ ## Quick Start
35
+
36
+ ### 1. Environment Variables
37
+
38
+ ```bash
39
+ OPTIAUDIT_URL=https://optiaudit-api.theoptimalpartner.com/api/v1
40
+ OPTIAUDIT_WRITE_KEY=oat-12-your_write_key
41
+ ```
42
+
43
+ The write key belongs to one OptiAudit source system. Use one source system per deployment of your app:
44
+ the same `organization_id` in two deployments is two different organizations.
45
+
46
+ ### 2. Sidekiq queue
47
+
48
+ If the app runs Sidekiq, events go through the `optiaudit` queue. Add it to the queues your workers process:
49
+
50
+ ```yaml
51
+ # config/sidekiq.yml
52
+ :queues:
53
+ - default
54
+ - optiaudit
55
+ ```
56
+
57
+ Apps without Sidekiq need nothing: the gem uses a background thread (see [Delivery](#delivery)).
58
+
59
+ ### 3. Use the Macros
60
+
61
+ ```ruby
62
+ class RoutesController < ApplicationController
63
+ optiaudit_page_view :routes, only: %i[index show]
64
+
65
+ optiaudit_event :update, :routes, only: :update
66
+ optiaudit_event :delete, :routes, only: :destroy,
67
+ metadata: ->(controller) { { action: "delete_route", route_id: controller.params[:id] } }
68
+
69
+ def index
70
+ # ...
71
+ end
72
+ end
73
+ ```
74
+
75
+ `optiaudit_page_view :routes` is `optiaudit_event :page_view, :routes`. The second argument is the
76
+ module, sent as `metadata.module`, which is how OptiAudit reports group events.
77
+
78
+ Events are tracked after the action, and only when:
79
+
80
+ - the action did not raise, and the response is 2xx or 3xx (a redirect after a create counts);
81
+ - there is an actor (see [Context Extraction](#context-extraction)).
82
+
83
+ Both macros accept the usual callback options (`only`, `except`, `if`, `unless`) and `metadata`, as a
84
+ hash or as a lambda that receives the controller.
85
+
86
+ ### Event types
87
+
88
+ OptiAudit only accepts the event types registered in its catalog and rejects any other with
89
+ `422 EVENT_TYPE_NOT_FOUND`:
90
+
91
+ `page_view`, `login`, `login_failed`, `logout`, `create`, `update`, `delete`, `export`, `config_change`
92
+
93
+ Use these generic types and put the specific action in the metadata:
94
+
95
+ ```ruby
96
+ optiaudit_event :update, :routes, only: :reschedule, metadata: { action: "reschedule_route" }
97
+ ```
98
+
99
+ The gem drops events with an unknown type (with a warning) instead of sending them. If your OptiAudit
100
+ instance registers more types, add them with `config.event_types`.
101
+
102
+ ## Manual Tracking
103
+
104
+ Inside a controller, `optiaudit_track` fills in the actor and the request data, like the macros:
105
+
106
+ ```ruby
107
+ def export
108
+ report = Reports::Routes.new(params).to_xlsx
109
+ optiaudit_track(:export, :reports, resource_type: "report", resource_id: report.id,
110
+ metadata: { action: "export_routes", rows: report.size })
111
+ send_data report.data
112
+ end
113
+ ```
114
+
115
+ Anywhere else (jobs, services, rake tasks) use `Optiaudit.track` with the fields you have:
116
+
117
+ ```ruby
118
+ Optiaudit.track(
119
+ event_type: "config_change",
120
+ actor_id: user.id,
121
+ actor_email: user.email,
122
+ organization_id: user.organization_id,
123
+ resource_type: "organization",
124
+ resource_id: organization.id,
125
+ metadata: { module: "settings", action: "toggle_flag", flag: "auto_dispatch" }
126
+ )
127
+ ```
128
+
129
+ `Optiaudit.track` returns `true` when the event was handed to the delivery adapter and `false` when it
130
+ was skipped or failed. It never raises.
131
+
132
+ ## Context Extraction
133
+
134
+ The macros take the actor from `current_user`:
135
+
136
+ | `current_user` | Event field |
137
+ |----------------|-------------|
138
+ | `id` | `actor_id` (required: without it nothing is sent) |
139
+ | `email` | `actor_email` (omitted if it is not a valid email) |
140
+ | `organization_id` | `organization_id` (omitted if it is not an integer) |
141
+
142
+ `actor_type` is `"user"`.
143
+
144
+ If the actor lives somewhere else (impersonation, an API client, a different method name), configure it.
145
+ The block receives the controller and returns a user-like object, a hash, or `nil` to skip the event:
146
+
147
+ ```ruby
148
+ # config/initializers/optiaudit.rb
149
+ Optiaudit.configure do |config|
150
+ # Audit the admin behind an impersonated session, not the impersonated user.
151
+ config.actor { |controller| controller.send(:true_user) }
152
+
153
+ # Or build the actor yourself.
154
+ config.actor do |controller|
155
+ client = controller.instance_variable_get(:@api_client)
156
+ client && { id: client.id, type: "api_client", organization_id: client.organization_id }
157
+ end
158
+ end
159
+ ```
160
+
161
+ Request data, also filled in by `optiaudit_track`:
162
+
163
+ | Field | Source |
164
+ |-------|--------|
165
+ | `resource_path` | `request.path` |
166
+ | `ip_address` | `request.remote_ip` (see below) |
167
+ | `user_agent` | `request.user_agent` |
168
+ | `session_id` | SHA-256 of the session id. The raw id is a credential and never leaves the app. Only sent when the action already loaded the session. |
169
+ | `metadata.request_id` | `request.request_id` (`X-Request-ID`), also sent as the `X-Request-ID` header to OptiAudit |
170
+ | `metadata.module` | the module given to the macro |
171
+
172
+ ## Client IP Resolution
173
+
174
+ Behind an ALB, ingress or reverse proxy, `request.ip` is the internal hop (e.g. `10.x.x.x`), not the user.
175
+ The gem uses `request.remote_ip`, which walks `X-Forwarded-For` from right to left and skips trusted
176
+ proxies (loopback and private ranges by default), returning the client's public IP. This is the same
177
+ rule as the NestJS package since `v0.1.8`: values a client injects on the left are ignored.
178
+
179
+ If your proxies use public addresses (a CDN in front of the load balancer), add them to
180
+ `config.action_dispatch.trusted_proxies`, or `remote_ip` will return the proxy.
181
+
182
+ In Rails 5.2, `remote_ip` raises `IpSpoofAttackError` when `Client-IP` and `X-Forwarded-For` disagree.
183
+ The gem then logs a warning and uses `request.ip`, so the event is still sent and the request is not affected.
184
+
185
+ ## Delivery
186
+
187
+ | Adapter | When | How |
188
+ |---------|------|-----|
189
+ | `:sidekiq` | Default when Sidekiq is loaded | One job per event in the `optiaudit` queue. Survives deploys. Retried with Sidekiq's backoff (5 times by default) on 5xx, 429, timeouts and connection errors. Other 4xx are dropped with a warning: retrying would not fix them. |
190
+ | `:thread` | Default without Sidekiq | In-memory buffer sent to `POST /events/batch` (up to 100 events) every 5 seconds or when full, from a background thread. Works with forking servers (Puma, Unicorn): each process starts its own thread. Pending events are flushed at exit. Events still in memory are lost if the process is killed, and failed batches are not retried. |
191
+ | `:inline` | Console, scripts, debugging | Synchronous `POST /events` in the caller's thread. Adds up to `timeout` seconds to a request. |
192
+
193
+ Sidekiq jobs receive the event as a hash of string keys and JSON values, so they work with Sidekiq 7's
194
+ `strict_args` (the default) without configuration.
195
+
196
+ ## Compatibility
197
+
198
+ | Dependency | Versions | Tested in CI |
199
+ |------------|----------|--------------|
200
+ | Ruby | >= 2.7 | 2.7, 3.0, 3.2 |
201
+ | Rails (`railties`) | >= 5.2 | 5.2, 6.1, 7.0 |
202
+ | Sidekiq (optional) | >= 6.3 | 6.3, 6.5, 7.2 |
203
+
204
+ The CI matrix follows the apps that use the gem: Llegando (Ruby 2.7, Rails 5.2, Sidekiq 6.3),
205
+ Sonic (Ruby 3.0, Rails 6.1, Sidekiq 6.5) and SIR Web (Ruby 3.2, Rails 7.0, Sidekiq 7.2).
206
+
207
+ > **Note**: If your project uses a version outside this table, add it to `gemfiles/` and to
208
+ > `bitbucket-pipelines.yml` in a PR, or report an issue.
209
+
210
+ ## Advanced Configuration
211
+
212
+ ```ruby
213
+ # config/initializers/optiaudit.rb
214
+ Optiaudit.configure do |config|
215
+ config.url = "https://custom-url.com/api/v1" # Override env var
216
+ config.write_key = "oat-12-custom-key" # Override env var
217
+ config.timeout = 5 # Open/read timeout in seconds (default: 3)
218
+ config.enabled = false # Force disable (true forces enable, e.g. in staging tests)
219
+ config.log_errors = false # Disable warning logs on failure
220
+ config.logger = Logger.new($stdout) # Default: Rails.logger
221
+
222
+ config.adapter = :thread # :sidekiq, :thread or :inline
223
+ config.queue = "low" # Sidekiq queue (default: "optiaudit")
224
+ config.max_retries = 3 # Sidekiq retries (default: 5)
225
+ config.batch_size = 50 # :thread, events per batch (default and max: 100)
226
+ config.flush_interval = 10 # :thread, seconds between batches (default: 5)
227
+ config.max_queue_size = 5_000 # :thread, events in memory before dropping (default: 10_000)
228
+
229
+ config.event_types += %w[approve] # Types registered in your OptiAudit instance
230
+ config.actor { |controller| controller.send(:true_user) }
231
+ end
232
+ ```
233
+
234
+ ## Testing your app
235
+
236
+ Tracking is disabled in the test environment. To assert on events, switch to test mode: events are
237
+ built and validated as in production but kept in memory.
238
+
239
+ ```ruby
240
+ # spec/rails_helper.rb
241
+ require "optiaudit/testing"
242
+
243
+ RSpec.configure do |config|
244
+ config.before { Optiaudit::Testing.fake! }
245
+ config.after { Optiaudit::Testing.disable! }
246
+ end
247
+
248
+ # spec/requests/routes_spec.rb
249
+ it "audits the update" do
250
+ patch route_path(route), params: { route: { name: "Norte" } }
251
+
252
+ expect(Optiaudit::Testing.events.last).to include(
253
+ "event_type" => "update",
254
+ "actor_id" => user.id.to_s,
255
+ "metadata" => include("module" => "routes")
256
+ )
257
+ end
258
+ ```
259
+
260
+ ## Track Params
261
+
262
+ Fields accepted by `Optiaudit.track` and `optiaudit_track`, with the limits of OptiAudit's API:
263
+
264
+ | Field | Type | Notes |
265
+ |-------|------|-------|
266
+ | `event_type` | string | Required. One of the [event types](#event-types). |
267
+ | `actor_id` | string | Required. Integers are converted. Truncated to 100. |
268
+ | `actor_type` | string | Default `"user"`. Truncated to 20. |
269
+ | `actor_email` | string | Omitted if it is not a valid email. |
270
+ | `organization_id` | integer | Omitted if it is not an integer (digit strings are converted). |
271
+ | `resource_type` | string | Truncated to 100. |
272
+ | `resource_id` | string | Truncated to 100. |
273
+ | `resource_path` | string | Truncated to 500. |
274
+ | `ip_address` | string | Truncated to 45. |
275
+ | `user_agent` | string | |
276
+ | `session_id` | string | Truncated to 255. `optiaudit_track` sends a SHA-256. |
277
+ | `metadata` | hash | Free-form. Converted to JSON types. |
278
+ | `client_timestamp` | Time or ISO 8601 string | Default: when `track` is called, not when the event is sent. |
279
+
280
+ OptiAudit rejects the whole event when one field is invalid, and in a batch, the whole batch. That is why
281
+ the gem truncates or omits fields instead of sending them as they are.
282
+
283
+ ## Behavior
284
+
285
+ | Condition | Behavior |
286
+ |-----------|----------|
287
+ | No env vars | Silently disabled |
288
+ | `RAILS_ENV=test` | Silently disabled (unless `enabled = true` or `Optiaudit::Testing.fake!`) |
289
+ | No actor | Event not sent |
290
+ | Action raised or response 4xx/5xx | Event not sent |
291
+ | Unknown `event_type` | Event not sent, logs warning |
292
+ | Invalid `actor_email` / `organization_id` | Field omitted, event sent |
293
+ | HTTP 5xx, 429, timeout (3s), connection error | Logs warning, doesn't raise. Sidekiq retries. |
294
+ | HTTP 4xx (other) | Logs warning, doesn't raise, no retry |
295
+ | HTTP 207 on a batch | Logs warning with the rejected events |
296
+ | Redis down when enqueueing | Logs warning, doesn't raise |
297
+
298
+ ## Contributing
299
+
300
+ 1. Clone the repository and create your branch (`git checkout -b feature/amazing-feature`)
301
+ 2. Run the tests for each supported Rails version:
302
+ ```bash
303
+ BUNDLE_GEMFILE=gemfiles/rails_5_2.gemfile bundle install && BUNDLE_GEMFILE=gemfiles/rails_5_2.gemfile bundle exec rspec
304
+ ```
305
+ 3. Update `CHANGELOG.md` and, for a release, `lib/optiaudit/version.rb`
306
+ 4. Push the branch and open a Pull Request
307
+
308
+ To try the gem end to end against a real OptiAudit (e.g. development), `examples/smoke.rb` sends a
309
+ `page_view` through the full Rails stack:
310
+
311
+ ```bash
312
+ OPTIAUDIT_URL=https://... OPTIAUDIT_WRITE_KEY=oat-... bundle exec ruby examples/smoke.rb
313
+ ```
314
+
315
+ Releases are published to rubygems.org from Bitbucket Pipelines with the manual `custom: publish` pipeline.
316
+
317
+ ## License
318
+
319
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ # Finds who performed the action, as the NestJS package does with the request
5
+ # user: id, email and organization. Without an id there is no event.
6
+ module Actor
7
+ module_function
8
+
9
+ # Returns the actor fields for Optiaudit.track, or nil.
10
+ def resolve(controller, config)
11
+ source = config.actor ? config.actor.call(controller) : current_user(controller)
12
+ return if source.nil?
13
+
14
+ fields = source.is_a?(Hash) ? from_hash(source) : from_object(source)
15
+ Util.blank?(fields[:actor_id]) ? nil : fields
16
+ end
17
+
18
+ def current_user(controller)
19
+ controller.send(:current_user) if controller.respond_to?(:current_user, true)
20
+ end
21
+
22
+ def from_hash(hash)
23
+ hash = Util.stringify_keys(hash)
24
+ {
25
+ actor_id: hash["id"],
26
+ actor_email: hash["email"],
27
+ actor_type: hash["type"],
28
+ organization_id: hash["organization_id"]
29
+ }.compact
30
+ end
31
+
32
+ def from_object(user)
33
+ {
34
+ actor_id: read(user, :id),
35
+ actor_email: read(user, :email),
36
+ organization_id: read(user, :organization_id)
37
+ }.compact
38
+ end
39
+
40
+ def read(object, attribute)
41
+ object.public_send(attribute) if object.respond_to?(attribute)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ module Adapters
5
+ # Sends the event synchronously, in the caller's thread. Meant for the
6
+ # console, scripts and debugging: in a request it adds up to `timeout`
7
+ # seconds of latency.
8
+ class Inline
9
+ def initialize(config)
10
+ @client = Client.new(config)
11
+ end
12
+
13
+ def deliver(event)
14
+ @client.send_event(event)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optiaudit/sidekiq_job"
4
+
5
+ module Optiaudit
6
+ module Adapters
7
+ # Enqueues one Sidekiq job per event. The event survives deploys and
8
+ # restarts, and failures that can succeed later are retried with Sidekiq's
9
+ # backoff (see Optiaudit::SidekiqJob).
10
+ class Sidekiq
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def deliver(event)
16
+ Optiaudit::SidekiqJob.set(queue: @config.queue, retry: @config.max_retries).perform_async(event)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ module Adapters
5
+ # Keeps events in memory and sends them from a background thread to
6
+ # POST /events/batch, every `flush_interval` seconds or as soon as
7
+ # `batch_size` events are waiting.
8
+ #
9
+ # The thread starts with the first event of each process, so it also works
10
+ # in forked servers (Puma, Unicorn): a child process notices the pid change
11
+ # and starts its own buffer and thread. Pending events are flushed at exit.
12
+ #
13
+ # Events in memory are lost if the process is killed, and failed batches are
14
+ # not retried. When that matters, use the :sidekiq adapter.
15
+ class Threaded
16
+ def initialize(config)
17
+ @config = config
18
+ @client = Client.new(config)
19
+ @start_lock = Mutex.new
20
+ @pid = nil
21
+ end
22
+
23
+ def deliver(event)
24
+ start_for_current_process
25
+ @mutex.synchronize do
26
+ if @buffer.size >= @config.max_queue_size
27
+ Optiaudit.log_warning("event #{event['event_type']} dropped: #{@buffer.size} events waiting")
28
+ return
29
+ end
30
+ @buffer << event
31
+ @wakeup.signal if @buffer.size >= @config.batch_size
32
+ end
33
+ end
34
+
35
+ def flush
36
+ return unless started_in_this_process?
37
+
38
+ events = @mutex.synchronize { @buffer.slice!(0..-1) }
39
+ events.each_slice(@config.batch_size) { |batch| @client.send_batch(batch) }
40
+ end
41
+
42
+ def shutdown(timeout = @config.timeout + 1)
43
+ return unless started_in_this_process?
44
+
45
+ @stopping = true
46
+ @mutex.synchronize { @wakeup.signal }
47
+ @thread.join(timeout)
48
+ flush
49
+ end
50
+
51
+ private
52
+
53
+ def started_in_this_process?
54
+ @pid == Process.pid
55
+ end
56
+
57
+ def start_for_current_process
58
+ return if started_in_this_process?
59
+
60
+ @start_lock.synchronize do
61
+ next if started_in_this_process?
62
+
63
+ # A forked child inherits the parent's buffer and a mutex whose owner
64
+ # thread does not exist here: start from scratch.
65
+ @mutex = Mutex.new
66
+ @wakeup = ConditionVariable.new
67
+ @buffer = []
68
+ @stopping = false
69
+ @thread = Thread.new { run }
70
+ @pid = Process.pid
71
+ at_exit { shutdown }
72
+ end
73
+ end
74
+
75
+ def run
76
+ until @stopping
77
+ @mutex.synchronize do
78
+ @wakeup.wait(@mutex, @config.flush_interval) if @buffer.size < @config.batch_size && !@stopping
79
+ end
80
+ begin
81
+ flush
82
+ rescue StandardError => e
83
+ Optiaudit.log_warning("batch not sent: #{e.class}: #{e.message}")
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optiaudit/adapters/inline"
4
+ require "optiaudit/adapters/threaded"
5
+
6
+ module Optiaudit
7
+ # Delivery adapters. Each one answers `deliver(event)`; :thread also `flush`
8
+ # and `shutdown`, because it is the only one that keeps events in memory.
9
+ module Adapters
10
+ # name => [file, class name]. The Sidekiq adapter loads on demand: it needs Sidekiq.
11
+ REGISTRY = {
12
+ sidekiq: ["optiaudit/adapters/sidekiq", "Sidekiq"],
13
+ thread: ["optiaudit/adapters/threaded", "Threaded"],
14
+ inline: ["optiaudit/adapters/inline", "Inline"]
15
+ }.freeze
16
+
17
+ def self.build(name, config)
18
+ file, class_name = REGISTRY.fetch(name)
19
+ require file
20
+ const_get(class_name).new(config)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Optiaudit
8
+ # Sends events to the OptiAudit API with Net::HTTP. Never raises: every
9
+ # outcome, including timeouts and connection errors, comes back as a Response.
10
+ class Client
11
+ class Response
12
+ attr_reader :status, :body, :error
13
+
14
+ def initialize(status: nil, body: nil, error: nil)
15
+ @status = status
16
+ @body = body
17
+ @error = error
18
+ end
19
+
20
+ def success?
21
+ !status.nil? && status >= 200 && status < 300
22
+ end
23
+
24
+ # 5xx, 429 and network errors can succeed later. Any other 4xx means the
25
+ # event (or the key) is invalid and sending it again changes nothing.
26
+ def retryable?
27
+ !error.nil? || status == 429 || status >= 500
28
+ end
29
+
30
+ def to_s
31
+ error ? "#{error.class}: #{error.message}" : "HTTP #{status} #{body}".strip
32
+ end
33
+ end
34
+
35
+ def initialize(config)
36
+ @config = config
37
+ end
38
+
39
+ def send_event(event)
40
+ request_id = event["metadata"] && event["metadata"]["request_id"]
41
+ response = post("/events", event, request_id)
42
+ warn_failure("event #{event['event_type']} not sent", response)
43
+ response
44
+ end
45
+
46
+ # OptiAudit answers 202 when every event was queued, 207 when some failed
47
+ # validation (their indexes come in `errors`) and 422 when all of them did.
48
+ def send_batch(events)
49
+ response = post("/events/batch", { "events" => events }, nil)
50
+ if response.status == 207
51
+ Optiaudit.log_warning("batch partially rejected: #{response.body}")
52
+ else
53
+ warn_failure("batch of #{events.size} events not sent", response)
54
+ end
55
+ response
56
+ end
57
+
58
+ private
59
+
60
+ def post(path, payload, request_id)
61
+ uri = URI.parse("#{@config.url.to_s.chomp('/')}#{path}")
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = uri.scheme == "https"
64
+ http.open_timeout = @config.timeout
65
+ http.read_timeout = @config.timeout
66
+ http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
67
+
68
+ request = Net::HTTP::Post.new(uri.request_uri, headers(request_id))
69
+ request.body = JSON.generate(payload)
70
+ response = http.request(request)
71
+ Response.new(status: response.code.to_i, body: response.body)
72
+ rescue StandardError => e
73
+ Response.new(error: e)
74
+ end
75
+
76
+ def headers(request_id)
77
+ headers = {
78
+ "Content-Type" => "application/json",
79
+ "X-API-Key" => @config.write_key.to_s,
80
+ "User-Agent" => "optiaudit-rails/#{Optiaudit::VERSION}"
81
+ }
82
+ headers["X-Request-ID"] = request_id if request_id
83
+ headers
84
+ end
85
+
86
+ def warn_failure(message, response)
87
+ Optiaudit.log_warning("#{message}: #{response}") unless response.success?
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ class Configuration
5
+ # Event types seeded in OptiAudit. Any other type is rejected by the API with
6
+ # 422 EVENT_TYPE_NOT_FOUND, so the gem drops it before sending.
7
+ DEFAULT_EVENT_TYPES = %w[
8
+ page_view login login_failed logout create update delete export config_change
9
+ ].freeze
10
+
11
+ DEFAULT_QUEUE = "optiaudit"
12
+ DEFAULT_MAX_RETRIES = 5
13
+
14
+ # url - OptiAudit API base URL, including /api/v1 (default: ENV["OPTIAUDIT_URL"])
15
+ # write_key - source system write key (default: ENV["OPTIAUDIT_WRITE_KEY"])
16
+ # timeout - HTTP open/read timeout in seconds (default: 3)
17
+ # enabled - nil decides from url, write_key and environment; true/false forces it
18
+ # log_errors - log a warning when an event cannot be sent (default: true)
19
+ # logger - defaults to Rails.logger
20
+ # adapter - :sidekiq, :thread or :inline (default: :sidekiq if Sidekiq is loaded, else :thread)
21
+ # queue - Sidekiq queue (default: "optiaudit")
22
+ # max_retries - Sidekiq retries on 5xx, 429, timeout or connection errors (default: 5)
23
+ # batch_size - :thread adapter, events per POST /events/batch (default and API maximum: 100)
24
+ # flush_interval - :thread adapter, seconds between flushes (default: 5)
25
+ # max_queue_size - :thread adapter, events kept in memory before dropping new ones (default: 10_000)
26
+ # event_types - event types accepted by the OptiAudit instance (default: DEFAULT_EVENT_TYPES)
27
+ attr_accessor :url, :write_key, :timeout, :enabled, :log_errors, :logger,
28
+ :adapter, :queue, :max_retries,
29
+ :batch_size, :flush_interval, :max_queue_size,
30
+ :event_types
31
+
32
+ def initialize
33
+ @url = ENV["OPTIAUDIT_URL"]
34
+ @write_key = ENV["OPTIAUDIT_WRITE_KEY"]
35
+ @timeout = 3
36
+ @enabled = nil
37
+ @log_errors = true
38
+ @logger = nil
39
+ @adapter = nil
40
+ @queue = DEFAULT_QUEUE
41
+ @max_retries = DEFAULT_MAX_RETRIES
42
+ @batch_size = 100
43
+ @flush_interval = 5
44
+ @max_queue_size = 10_000
45
+ @event_types = DEFAULT_EVENT_TYPES.dup
46
+ @actor_resolver = nil
47
+ end
48
+
49
+ # Sets how the controller macros find the actor. The block receives the controller
50
+ # and returns a user-like object (id, email, organization_id) or a hash with
51
+ # :id, :email, :organization_id and :type. Returning nil skips the event.
52
+ #
53
+ # config.actor { |controller| controller.send(:true_user) }
54
+ def actor(&block)
55
+ @actor_resolver = block if block
56
+ @actor_resolver
57
+ end
58
+
59
+ # Disabled without url or write_key. Otherwise enabled outside the test
60
+ # environment, unless `enabled` forces it.
61
+ def enabled?
62
+ return false if @enabled == false
63
+ return false if Util.blank?(url) || Util.blank?(write_key)
64
+
65
+ @enabled == true || !test_environment?
66
+ end
67
+
68
+ def adapter_name
69
+ name = adapter ? adapter.to_sym : default_adapter
70
+ return name if Adapters::REGISTRY.key?(name)
71
+
72
+ raise ArgumentError, "unknown Optiaudit adapter #{adapter.inspect} (use one of #{Adapters::REGISTRY.keys.join(', ')})"
73
+ end
74
+
75
+ private
76
+
77
+ def default_adapter
78
+ defined?(::Sidekiq) ? :sidekiq : :thread
79
+ end
80
+
81
+ def test_environment?
82
+ rails_env = ::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
83
+ (rails_env || ENV["RAILS_ENV"] || ENV["RACK_ENV"]).to_s == "test"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Optiaudit
6
+ # Controller macros, included in every controller by the Railtie.
7
+ #
8
+ # class RoutesController < ApplicationController
9
+ # optiaudit_page_view :routes, only: :index
10
+ # optiaudit_event :update, :routes, only: :update,
11
+ # metadata: ->(controller) { { action: "reschedule", route_id: controller.params[:id] } }
12
+ # end
13
+ #
14
+ # Events are tracked in an after_action, so they are only sent when the action
15
+ # did not raise, and only for 2xx and 3xx responses. Without an actor (no
16
+ # current_user, or the `actor` block returns nil) nothing is sent.
17
+ module Controller
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ def optiaudit_page_view(module_name, **options)
22
+ optiaudit_event(:page_view, module_name, **options)
23
+ end
24
+
25
+ # Options: `metadata` (a hash, or a lambda that receives the controller and
26
+ # returns one) plus any after_action option (`only`, `except`, `if`, `unless`).
27
+ def optiaudit_event(event_type, module_name, metadata: nil, **callback_options)
28
+ after_action(**callback_options) do |controller|
29
+ controller.send(:optiaudit_track_action, event_type, module_name, metadata)
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Tracks an event with the actor and request data of this controller.
37
+ # `params` accepts every field of Optiaudit.track and overrides the defaults;
38
+ # `metadata` may also be a lambda that receives the controller.
39
+ #
40
+ # optiaudit_track(:export, :reports, metadata: { action: "export_routes" })
41
+ def optiaudit_track(event_type, module_name, params = {})
42
+ return false unless Optiaudit.enabled?
43
+
44
+ actor = Actor.resolve(self, Optiaudit.configuration)
45
+ return false unless actor
46
+
47
+ Optiaudit.track(
48
+ actor.merge(RequestData.from(request))
49
+ .merge(params)
50
+ .merge(event_type: event_type.to_s, metadata: optiaudit_metadata(module_name, params[:metadata]))
51
+ )
52
+ rescue StandardError => e
53
+ Optiaudit.log_warning("could not track #{event_type} in #{self.class.name}##{action_name}: #{e.class}: #{e.message}")
54
+ false
55
+ end
56
+
57
+ def optiaudit_track_action(event_type, module_name, metadata)
58
+ return unless response.status >= 200 && response.status < 400
59
+
60
+ optiaudit_track(event_type, module_name, metadata: metadata)
61
+ end
62
+
63
+ def optiaudit_metadata(module_name, extra)
64
+ extra = extra.call(self) if extra.respond_to?(:call)
65
+ metadata = { "module" => module_name.to_s }
66
+ metadata["request_id"] = request.request_id if request.request_id
67
+ metadata.merge(Util.stringify_keys(extra || {}))
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Optiaudit
7
+ # Builds the payload of POST /events from the params of Optiaudit.track.
8
+ #
9
+ # OptiAudit validates every field and rejects the whole event (and, in a batch,
10
+ # the whole batch) when one is invalid. So instead of sending something that
11
+ # will come back as 400/422, fields are fixed here: strings are truncated to
12
+ # the API limits, an invalid actor_email is omitted and a non-integer
13
+ # organization_id is omitted.
14
+ module Event
15
+ # Limits from OptiAudit's CreateEventDto.
16
+ MAX_LENGTHS = {
17
+ "event_type" => 50,
18
+ "actor_id" => 100,
19
+ "actor_type" => 20,
20
+ "actor_email" => 255,
21
+ "resource_type" => 100,
22
+ "resource_id" => 100,
23
+ "resource_path" => 500,
24
+ "ip_address" => 45,
25
+ "session_id" => 255
26
+ }.freeze
27
+
28
+ STRING_FIELDS = (MAX_LENGTHS.keys + ["user_agent"]).freeze
29
+
30
+ LOCAL_ATOM = %r{\A[\p{L}\p{N}!#$%&'*+\-/=?^_`{|}~]+\z}.freeze
31
+ QUOTED_LOCAL_PART = /\A"[^"\\]*"\z/.freeze
32
+ DOMAIN_LABEL = /\A[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\z/.freeze
33
+ TOP_LEVEL_DOMAIN = /\A(?:\p{L}{2,}|xn--[a-z0-9-]+)\z/i.freeze
34
+
35
+ DEFAULT_ACTOR_TYPE = "user"
36
+
37
+ module_function
38
+
39
+ # Returns the event as a hash of string keys and JSON types (safe as a
40
+ # Sidekiq argument), or nil when it must not be sent.
41
+ def build(params, config)
42
+ params = Util.stringify_keys(params || {})
43
+ event_type = params["event_type"].to_s
44
+ actor_id = params["actor_id"].to_s
45
+
46
+ if Util.blank?(event_type) || Util.blank?(actor_id)
47
+ Optiaudit.log_warning("event skipped: event_type and actor_id are required")
48
+ return nil
49
+ end
50
+ unless config.event_types.map(&:to_s).include?(event_type)
51
+ Optiaudit.log_warning("event skipped: unknown event_type #{event_type.inspect}")
52
+ return nil
53
+ end
54
+
55
+ event = {}
56
+ STRING_FIELDS.each do |field|
57
+ value = params[field]
58
+ event[field] = truncate(value.to_s, MAX_LENGTHS[field]) unless Util.blank?(value)
59
+ end
60
+ event["actor_type"] ||= DEFAULT_ACTOR_TYPE
61
+ event.delete("actor_email") unless email?(event["actor_email"])
62
+
63
+ organization_id = integer(params["organization_id"])
64
+ event["organization_id"] = organization_id if organization_id
65
+ event["metadata"] = params["metadata"] if params["metadata"].is_a?(Hash) && !params["metadata"].empty?
66
+ event["client_timestamp"] = timestamp(params["client_timestamp"])
67
+
68
+ # Round trip through JSON: symbols, times and nested objects in metadata
69
+ # end up as JSON types, which Sidekiq 7 requires for job arguments.
70
+ JSON.parse(JSON.generate(event))
71
+ end
72
+
73
+ def truncate(value, max_length)
74
+ max_length && value.length > max_length ? value[0, max_length] : value
75
+ end
76
+
77
+ # Same rule as class-validator's IsEmail (validator.js isEmail with its
78
+ # defaults): local part of dot-separated atoms or a quoted string, up to 64
79
+ # characters; a domain of valid labels ending in an alphabetic TLD of 2 or
80
+ # more characters. "a@b.c", "a@x.123", "john.@x.com" and "ana@x.com." are
81
+ # rejected by the API, so they are rejected here.
82
+ def email?(value)
83
+ return false if value.nil? || value.length > MAX_LENGTHS["actor_email"]
84
+
85
+ local, at, domain = value.rpartition("@")
86
+ return false if at.empty? || local.empty? || local.length > 64
87
+
88
+ local_part?(local) && domain?(domain)
89
+ end
90
+
91
+ def local_part?(local)
92
+ return local.match?(QUOTED_LOCAL_PART) if local.start_with?('"')
93
+
94
+ local.split(".", -1).all? { |atom| atom.match?(LOCAL_ATOM) }
95
+ end
96
+
97
+ def domain?(domain)
98
+ labels = domain.split(".", -1)
99
+ labels.size >= 2 && labels.all? { |label| label.match?(DOMAIN_LABEL) } && labels.last.match?(TOP_LEVEL_DOMAIN)
100
+ end
101
+
102
+ def integer(value)
103
+ return value if value.is_a?(Integer)
104
+
105
+ Integer(value, 10) if value.is_a?(String) && value.match?(/\A\d+\z/)
106
+ end
107
+
108
+ def timestamp(value)
109
+ case value
110
+ when nil then Time.now.utc.iso8601(3)
111
+ when Time, DateTime then value.to_time.utc.iso8601(3)
112
+ else value.to_s
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optiaudit"
4
+ require "optiaudit/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Optiaudit
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "optiaudit.controller" do
8
+ ActiveSupport.on_load(:action_controller) do
9
+ require "optiaudit/controller"
10
+ include Optiaudit::Controller
11
+ end
12
+ end
13
+
14
+ # The Sidekiq server process has to know the job class even if no event
15
+ # was tracked in that process.
16
+ config.after_initialize do
17
+ require "optiaudit/sidekiq_job" if defined?(::Sidekiq)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Optiaudit
6
+ # Event fields taken from the request: path, client IP, user agent and session.
7
+ module RequestData
8
+ module_function
9
+
10
+ def from(request)
11
+ {
12
+ resource_path: request.path,
13
+ ip_address: client_ip(request),
14
+ user_agent: request.user_agent,
15
+ session_id: session_id(request)
16
+ }
17
+ end
18
+
19
+ # remote_ip skips trusted proxies (private ranges by default), so behind a
20
+ # load balancer it returns the client's public IP. In Rails 5.2 it raises
21
+ # when Client-IP and X-Forwarded-For disagree.
22
+ def client_ip(request)
23
+ request.remote_ip
24
+ rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
25
+ Optiaudit.log_warning("remote_ip rejected (#{e.message}), using request.ip")
26
+ request.ip
27
+ end
28
+
29
+ # Hashed: the raw session id is a credential and never leaves the app.
30
+ # Only read when the action already loaded the session (authentication
31
+ # does), so tracking never creates a session or sets a cookie.
32
+ def session_id(request)
33
+ session = request.session
34
+ return unless session.respond_to?(:loaded?) && session.loaded?
35
+
36
+ id = session.id
37
+ id = id.public_id if id.respond_to?(:public_id)
38
+ Digest::SHA256.hexdigest(id.to_s) unless Util.blank?(id)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ # Raised so that Sidekiq retries the job. Only for failures that can succeed
5
+ # later: 5xx, 429, timeouts and connection errors.
6
+ class RetryableDeliveryError < StandardError; end
7
+
8
+ class SidekiqJob
9
+ include(defined?(::Sidekiq::Job) ? ::Sidekiq::Job : ::Sidekiq::Worker)
10
+
11
+ # Only used when the job is enqueued by hand: the adapter sets the queue and
12
+ # retries from the configuration on every event.
13
+ sidekiq_options queue: Configuration::DEFAULT_QUEUE, retry: Configuration::DEFAULT_MAX_RETRIES
14
+
15
+ sidekiq_retries_exhausted do |job, _exception|
16
+ Optiaudit.log_warning("event #{job['args'].first['event_type']} dropped: retries exhausted")
17
+ end
18
+
19
+ # Runs in the Sidekiq process, where there is no adapter instance: the
20
+ # configuration comes from the app's initializer.
21
+ def perform(event)
22
+ response = Client.new(Optiaudit.configuration).send_event(event)
23
+ raise RetryableDeliveryError, response.to_s if response.retryable?
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ # Test mode for apps that use the gem. Events are built and validated as in
5
+ # production but kept in memory instead of being sent.
6
+ #
7
+ # # spec/rails_helper.rb
8
+ # require "optiaudit/testing"
9
+ # config.before { Optiaudit::Testing.fake! }
10
+ # config.after { Optiaudit::Testing.disable! }
11
+ #
12
+ # expect(Optiaudit::Testing.events.last).to include("event_type" => "update")
13
+ module Testing
14
+ class Adapter
15
+ attr_reader :events
16
+
17
+ def initialize
18
+ @events = []
19
+ end
20
+
21
+ def deliver(event)
22
+ @events << event
23
+ end
24
+ end
25
+
26
+ module_function
27
+
28
+ # Enables tracking without url or write key and records every event.
29
+ def fake!
30
+ @adapter = Adapter.new
31
+ end
32
+
33
+ def disable!
34
+ @adapter = nil
35
+ end
36
+
37
+ def fake?
38
+ !@adapter.nil?
39
+ end
40
+
41
+ def adapter
42
+ @adapter
43
+ end
44
+
45
+ def events
46
+ fake? ? @adapter.events : []
47
+ end
48
+
49
+ def clear!
50
+ events.clear
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ # Helpers shared by the event builder, the actor and the configuration.
5
+ module Util
6
+ module_function
7
+
8
+ def stringify_keys(hash)
9
+ hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
10
+ end
11
+
12
+ def blank?(value)
13
+ value.nil? || value.to_s.strip.empty?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Optiaudit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optiaudit/rails"
data/lib/optiaudit.rb ADDED
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "optiaudit/version"
5
+ require "optiaudit/util"
6
+ require "optiaudit/adapters"
7
+ require "optiaudit/configuration"
8
+ require "optiaudit/event"
9
+ require "optiaudit/client"
10
+ require "optiaudit/actor"
11
+ require "optiaudit/request_data"
12
+ require "optiaudit/testing"
13
+
14
+ # Sends audit events to OptiAudit. See README.md.
15
+ module Optiaudit
16
+ ADAPTER_LOCK = Mutex.new
17
+ private_constant :ADAPTER_LOCK
18
+
19
+ class << self
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ # Optiaudit.configure do |config|
25
+ # config.adapter = :sidekiq
26
+ # config.actor { |controller| controller.send(:true_user) }
27
+ # end
28
+ def configure
29
+ yield configuration
30
+ discard_adapter
31
+ configuration
32
+ end
33
+
34
+ def enabled?
35
+ Testing.fake? || configuration.enabled?
36
+ end
37
+
38
+ # Tracks an event. Returns true when it was handed to the adapter, false when
39
+ # it was skipped (disabled, invalid) or failed. Never raises.
40
+ #
41
+ # Optiaudit.track(event_type: "export", actor_id: user.id, actor_email: user.email,
42
+ # organization_id: user.organization_id, metadata: { module: "reports" })
43
+ #
44
+ # Fields: event_type, actor_id (required), actor_type, actor_email,
45
+ # organization_id, resource_type, resource_id, resource_path, ip_address,
46
+ # user_agent, session_id, metadata, client_timestamp (default: now).
47
+ def track(params = {})
48
+ return false unless enabled?
49
+
50
+ event = Event.build(params, configuration)
51
+ return false unless event
52
+
53
+ adapter.deliver(event)
54
+ true
55
+ rescue StandardError => e
56
+ log_warning("event not tracked: #{e.class}: #{e.message}")
57
+ false
58
+ end
59
+
60
+ # Sends what the :thread adapter has in memory. No-op for other adapters.
61
+ def flush
62
+ adapter.flush if adapter.respond_to?(:flush)
63
+ end
64
+
65
+ def adapter
66
+ return Testing.adapter if Testing.fake?
67
+
68
+ @adapter || ADAPTER_LOCK.synchronize { @adapter ||= build_adapter }
69
+ end
70
+
71
+ def logger
72
+ configuration.logger || rails_logger || (@default_logger ||= Logger.new($stderr))
73
+ end
74
+
75
+ def log_warning(message)
76
+ logger.warn("[Optiaudit] #{message}") if configuration.log_errors
77
+ end
78
+
79
+ # Back to defaults (reads the environment again). For tests.
80
+ def reset!
81
+ discard_adapter
82
+ @configuration = nil
83
+ end
84
+
85
+ private
86
+
87
+ def build_adapter
88
+ Adapters.build(configuration.adapter_name, configuration)
89
+ end
90
+
91
+ # A :thread adapter built with the previous configuration sends what it
92
+ # has and stops, instead of running alongside the new one.
93
+ def discard_adapter
94
+ adapter = ADAPTER_LOCK.synchronize do
95
+ previous = @adapter
96
+ @adapter = nil
97
+ previous
98
+ end
99
+ adapter.shutdown if adapter.respond_to?(:shutdown)
100
+ end
101
+
102
+ def rails_logger
103
+ ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
104
+ end
105
+ end
106
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: optiaudit-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - The Optimal Partner
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ description: Rails client for OptiAudit, the audit and usage tracking service of The
28
+ Optimal Partner. Controller macros, manual tracking, and Sidekiq, background thread
29
+ or inline delivery that never breaks or slows down the request. Ruby counterpart
30
+ of @theoptimalpartner/nestjs-optiaudit.
31
+ email:
32
+ - dev@theoptimalpartner.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - lib/optiaudit-rails.rb
41
+ - lib/optiaudit.rb
42
+ - lib/optiaudit/actor.rb
43
+ - lib/optiaudit/adapters.rb
44
+ - lib/optiaudit/adapters/inline.rb
45
+ - lib/optiaudit/adapters/sidekiq.rb
46
+ - lib/optiaudit/adapters/threaded.rb
47
+ - lib/optiaudit/client.rb
48
+ - lib/optiaudit/configuration.rb
49
+ - lib/optiaudit/controller.rb
50
+ - lib/optiaudit/event.rb
51
+ - lib/optiaudit/rails.rb
52
+ - lib/optiaudit/railtie.rb
53
+ - lib/optiaudit/request_data.rb
54
+ - lib/optiaudit/sidekiq_job.rb
55
+ - lib/optiaudit/testing.rb
56
+ - lib/optiaudit/util.rb
57
+ - lib/optiaudit/version.rb
58
+ homepage: https://bitbucket.org/RoutingUC/optiaudit-rails
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://bitbucket.org/RoutingUC/optiaudit-rails
63
+ source_code_uri: https://bitbucket.org/RoutingUC/optiaudit-rails
64
+ changelog_uri: https://bitbucket.org/RoutingUC/optiaudit-rails/src/main/CHANGELOG.md
65
+ rubygems_mfa_required: 'true'
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '2.7'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.1.6
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: Send audit events from Rails apps to OptiAudit.
85
+ test_files: []