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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +319 -0
- data/lib/optiaudit/actor.rb +44 -0
- data/lib/optiaudit/adapters/inline.rb +18 -0
- data/lib/optiaudit/adapters/sidekiq.rb +20 -0
- data/lib/optiaudit/adapters/threaded.rb +89 -0
- data/lib/optiaudit/adapters.rb +23 -0
- data/lib/optiaudit/client.rb +90 -0
- data/lib/optiaudit/configuration.rb +86 -0
- data/lib/optiaudit/controller.rb +70 -0
- data/lib/optiaudit/event.rb +116 -0
- data/lib/optiaudit/rails.rb +4 -0
- data/lib/optiaudit/railtie.rb +20 -0
- data/lib/optiaudit/request_data.rb +41 -0
- data/lib/optiaudit/sidekiq_job.rb +26 -0
- data/lib/optiaudit/testing.rb +53 -0
- data/lib/optiaudit/util.rb +16 -0
- data/lib/optiaudit/version.rb +5 -0
- data/lib/optiaudit-rails.rb +3 -0
- data/lib/optiaudit.rb +106 -0
- metadata +85 -0
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,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
|
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: []
|