sinaliza 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: 9347558f2c9e741fe50da41cfd1f43835d03df264036a1d584f2f5e0806e598b
4
+ data.tar.gz: 86d025a67d8e219f120cb7f8e6b0631298a1b81ed20d4045096c4e3e9a7cb7a7
5
+ SHA512:
6
+ metadata.gz: 9f557f39a26baab6127cbc66cdb24920186ce4e8d43845b93448445204e9b933be9025662da4a18597365a06e3d0ff7f67ce37ef4990f22a8fb731919da26f5a
7
+ data.tar.gz: df3fdc8f2cf43839a962a9e6e4529e0c8ba407f59fda8899e4bdb9fa787cc4644f0a84770346e1bcfd05ad700f53ab5ff5148743bfa2fa13434dbe4e075cbb67
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Marcelo Moraes
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # Sinaliza
2
+
3
+ A Rails engine for recording and browsing application events. Track user actions, system events, and anything worth logging — from models, controllers, or anywhere in your code.
4
+
5
+ Events are stored in the database and viewable through a mountable monitor dashboard.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "sinaliza", github: "marcelonmoraes/sinaliza"
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ bin/rails sinaliza:install:migrations
20
+ bin/rails db:migrate
21
+ ```
22
+
23
+ Mount the engine in your `config/routes.rb`:
24
+
25
+ ```ruby
26
+ mount Sinaliza::Engine => "/sinaliza"
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Direct recording
32
+
33
+ ```ruby
34
+ # Synchronous
35
+ Sinaliza.record(name: "user.signed_up", actor: user, metadata: { plan: "pro" })
36
+
37
+ # Asynchronous (via ActiveJob)
38
+ Sinaliza.record_later(name: "report.generated", target: report)
39
+ ```
40
+
41
+ Both methods accept:
42
+
43
+ | Parameter | Description | Default |
44
+ |-------------|--------------------------------------|-----------------------|
45
+ | `name` | Event name (required) | — |
46
+ | `actor` | Who performed the action (any model) | `nil` |
47
+ | `target` | What was acted upon (any model) | `nil` |
48
+ | `metadata` | Arbitrary hash of extra data | `{}` |
49
+ | `source` | Origin label | `"manual"` |
50
+ | `ip_address`| IP address | `nil` |
51
+ | `user_agent`| User agent string | `nil` |
52
+ | `request_id`| Request ID | `nil` |
53
+
54
+ ### Model concern — `Sinaliza::Trackable`
55
+
56
+ Include in any model to get event associations and helper methods:
57
+
58
+ ```ruby
59
+ class User < ApplicationRecord
60
+ include Sinaliza::Trackable
61
+ end
62
+ ```
63
+
64
+ This gives you:
65
+
66
+ ```ruby
67
+ user.events_as_actor # events where user is the actor
68
+ user.events_as_target # events where user is the target
69
+
70
+ user.track_event("profile.updated", metadata: { field: "email" })
71
+ user.track_event("post.published", target: post)
72
+
73
+ post.track_event_as_target("post.featured", actor: admin)
74
+ ```
75
+
76
+ Events are recorded with `source: "model"`. When an actor or target is destroyed, associated events are preserved with nullified references (`dependent: :nullify`).
77
+
78
+ ### Controller concern — `Sinaliza::Traceable`
79
+
80
+ Include in any controller to track actions declaratively or manually:
81
+
82
+ ```ruby
83
+ class OrdersController < ApplicationController
84
+ include Sinaliza::Traceable
85
+
86
+ # Declarative — runs as an after_action callback
87
+ track_event "orders.listed", only: :index
88
+ track_event "order.viewed", only: :show, metadata: -> { { order_id: params[:id] } }
89
+
90
+ # Conditional tracking
91
+ track_event "order.created", only: :create, if: -> { response.successful? }
92
+
93
+ def refund
94
+ order = Order.find(params[:id])
95
+ order.refund!
96
+
97
+ # Manual — call anywhere in an action
98
+ record_event("order.refunded", target: order, metadata: { reason: params[:reason] })
99
+
100
+ redirect_to order
101
+ end
102
+ end
103
+ ```
104
+
105
+ Events are recorded with `source: "controller"`. Request context (IP address, user agent, request ID) is captured automatically based on configuration.
106
+
107
+ The actor is resolved by calling the method defined in `Sinaliza.configuration.actor_method` (default: `current_user`).
108
+
109
+ ### Query scopes
110
+
111
+ ```ruby
112
+ Sinaliza::Event.by_name("user.login")
113
+ Sinaliza::Event.by_source("controller")
114
+ Sinaliza::Event.by_actor_type("User")
115
+ Sinaliza::Event.since(1.week.ago)
116
+ Sinaliza::Event.before(Date.yesterday)
117
+ Sinaliza::Event.between(1.week.ago, 1.day.ago)
118
+ Sinaliza::Event.search("login")
119
+ Sinaliza::Event.chronological # oldest first
120
+ Sinaliza::Event.reverse_chronological # newest first
121
+ ```
122
+
123
+ Scopes are chainable:
124
+
125
+ ```ruby
126
+ Sinaliza::Event.by_name("order.created").by_actor_type("User").since(1.day.ago)
127
+ ```
128
+
129
+ ## Dashboard
130
+
131
+ The engine mounts a monitor dashboard at your chosen path. It provides:
132
+
133
+ - Paginated event list (cursor-based, 50 per page)
134
+ - Filtering by name, source, actor type, date range, and free-text search
135
+ - Detail view for each event with formatted JSON metadata
136
+
137
+ ### Protecting the dashboard
138
+
139
+ The gem does not include authentication. Protect access via route constraints in your host app:
140
+
141
+ ```ruby
142
+ # config/routes.rb
143
+ authenticate :user, ->(u) { u.admin? } do
144
+ mount Sinaliza::Engine => "/sinaliza"
145
+ end
146
+
147
+ # or with a simple constraint
148
+ mount Sinaliza::Engine => "/sinaliza", constraints: AdminConstraint.new
149
+ ```
150
+
151
+ ## Configuration
152
+
153
+ ```ruby
154
+ # config/initializers/sinaliza.rb
155
+ Sinaliza.configure do |config|
156
+ # Controller method used to resolve the actor (default: :current_user)
157
+ config.actor_method = :current_user
158
+
159
+ # Default source label for Sinaliza.record calls (default: "manual")
160
+ config.default_source = "manual"
161
+
162
+ # Capture IP, user agent, and request ID in controller events (default: true)
163
+ config.record_request_info = true
164
+
165
+ # Auto-purge events older than this duration (default: nil — no purging)
166
+ config.purge_after = 90.days
167
+ end
168
+ ```
169
+
170
+ ## Purging old events
171
+
172
+ If `purge_after` is configured, run the rake task to delete old events:
173
+
174
+ ```bash
175
+ bin/rails sinaliza:purge
176
+ ```
177
+
178
+ Schedule it with cron, Heroku Scheduler, or whatever you prefer.
179
+
180
+ ## Database schema
181
+
182
+ Events are stored in a single `sinaliza_events` table with polymorphic `actor` and `target` columns. The `metadata` column uses `json` type for cross-database compatibility (SQLite, PostgreSQL, MySQL).
183
+
184
+ ## License
185
+
186
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,173 @@
1
+ .sinaliza-dashboard {
2
+ max-width: 1200px;
3
+ margin: 0 auto;
4
+ padding: 20px;
5
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
6
+ font-size: 14px;
7
+ color: #1a1a1a;
8
+ }
9
+
10
+ .sinaliza-dashboard h1 {
11
+ font-size: 24px;
12
+ font-weight: 600;
13
+ margin-bottom: 20px;
14
+ }
15
+
16
+ /* Filters */
17
+ .sinaliza-filters {
18
+ background: #f8f9fa;
19
+ border: 1px solid #dee2e6;
20
+ border-radius: 6px;
21
+ padding: 16px;
22
+ margin-bottom: 20px;
23
+ }
24
+
25
+ .sinaliza-filters__row {
26
+ display: flex;
27
+ flex-wrap: wrap;
28
+ gap: 12px;
29
+ align-items: flex-end;
30
+ }
31
+
32
+ .sinaliza-filters__field {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 4px;
36
+ }
37
+
38
+ .sinaliza-filters__field label {
39
+ font-size: 12px;
40
+ font-weight: 600;
41
+ color: #495057;
42
+ }
43
+
44
+ .sinaliza-filters__field input,
45
+ .sinaliza-filters__field select {
46
+ padding: 6px 10px;
47
+ border: 1px solid #ced4da;
48
+ border-radius: 4px;
49
+ font-size: 13px;
50
+ }
51
+
52
+ .sinaliza-filters__actions {
53
+ display: flex;
54
+ gap: 8px;
55
+ align-items: center;
56
+ }
57
+
58
+ .sinaliza-filters__actions button {
59
+ padding: 6px 16px;
60
+ background: #0d6efd;
61
+ color: white;
62
+ border: none;
63
+ border-radius: 4px;
64
+ font-size: 13px;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .sinaliza-filters__actions button:hover {
69
+ background: #0b5ed7;
70
+ }
71
+
72
+ /* Table */
73
+ .sinaliza-table {
74
+ width: 100%;
75
+ border-collapse: collapse;
76
+ background: white;
77
+ }
78
+
79
+ .sinaliza-table th,
80
+ .sinaliza-table td {
81
+ padding: 10px 12px;
82
+ text-align: left;
83
+ border-bottom: 1px solid #dee2e6;
84
+ }
85
+
86
+ .sinaliza-table th {
87
+ font-weight: 600;
88
+ font-size: 12px;
89
+ color: #495057;
90
+ text-transform: uppercase;
91
+ letter-spacing: 0.5px;
92
+ background: #f8f9fa;
93
+ }
94
+
95
+ .sinaliza-table tbody tr:hover {
96
+ background: #f8f9fa;
97
+ }
98
+
99
+ .sinaliza-time {
100
+ white-space: nowrap;
101
+ color: #6c757d;
102
+ font-size: 13px;
103
+ }
104
+
105
+ /* Detail table */
106
+ .sinaliza-detail {
107
+ width: 100%;
108
+ border-collapse: collapse;
109
+ }
110
+
111
+ .sinaliza-detail th,
112
+ .sinaliza-detail td {
113
+ padding: 10px 12px;
114
+ border-bottom: 1px solid #dee2e6;
115
+ vertical-align: top;
116
+ }
117
+
118
+ .sinaliza-detail th {
119
+ width: 140px;
120
+ font-weight: 600;
121
+ color: #495057;
122
+ text-align: right;
123
+ background: #f8f9fa;
124
+ }
125
+
126
+ /* Badges */
127
+ .sinaliza-badge {
128
+ display: inline-block;
129
+ padding: 2px 8px;
130
+ border-radius: 4px;
131
+ font-size: 12px;
132
+ font-weight: 500;
133
+ background: #e9ecef;
134
+ color: #495057;
135
+ }
136
+
137
+ .sinaliza-badge--source {
138
+ background: #d1ecf1;
139
+ color: #0c5460;
140
+ }
141
+
142
+ /* Links */
143
+ .sinaliza-link {
144
+ color: #0d6efd;
145
+ text-decoration: none;
146
+ }
147
+
148
+ .sinaliza-link:hover {
149
+ text-decoration: underline;
150
+ }
151
+
152
+ /* JSON */
153
+ .sinaliza-json {
154
+ background: #f8f9fa;
155
+ padding: 10px;
156
+ border-radius: 4px;
157
+ font-size: 13px;
158
+ overflow-x: auto;
159
+ margin: 0;
160
+ }
161
+
162
+ /* Empty state */
163
+ .sinaliza-empty {
164
+ text-align: center;
165
+ color: #6c757d;
166
+ padding: 40px;
167
+ }
168
+
169
+ /* Pagination */
170
+ .sinaliza-pagination {
171
+ padding: 16px 0;
172
+ text-align: center;
173
+ }
@@ -0,0 +1,47 @@
1
+ module Sinaliza
2
+ module Traceable
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def track_event(name, only: nil, except: nil, metadata: {}, if: nil)
7
+ callback_options = {}
8
+ callback_options[:only] = only if only
9
+ callback_options[:except] = except if except
10
+ callback_options[:if] = binding.local_variable_get(:if) if binding.local_variable_get(:if)
11
+
12
+ after_action(**callback_options) do
13
+ resolved_metadata = metadata.is_a?(Proc) ? instance_exec(&metadata) : metadata
14
+ record_event(name, metadata: resolved_metadata)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def record_event(name, target: nil, parent: nil, metadata: {})
22
+ actor = resolve_actor
23
+
24
+ attributes = {
25
+ name: name,
26
+ actor: actor,
27
+ target: target,
28
+ parent: parent,
29
+ metadata: metadata,
30
+ source: "controller"
31
+ }
32
+
33
+ if Sinaliza.configuration.record_request_info
34
+ attributes[:ip_address] = request.remote_ip
35
+ attributes[:user_agent] = request.user_agent
36
+ attributes[:request_id] = request.request_id
37
+ end
38
+
39
+ Sinaliza.record(**attributes)
40
+ end
41
+
42
+ def resolve_actor
43
+ method = Sinaliza.configuration.actor_method
44
+ send(method) if respond_to?(method, true)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,4 @@
1
+ module Sinaliza
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,41 @@
1
+ module Sinaliza
2
+ class EventsController < ApplicationController
3
+ PER_PAGE = 50
4
+
5
+ def index
6
+ @events = Event.roots.reverse_chronological
7
+
8
+ apply_filters
9
+ apply_cursor_pagination
10
+
11
+ @filter_names = Event.distinct.pluck(:name).sort
12
+ @filter_sources = Event.distinct.pluck(:source).sort
13
+ @filter_actor_types = Event.where.not(actor_type: nil).distinct.pluck(:actor_type).sort
14
+ end
15
+
16
+ def show
17
+ @event = Event.find(params[:id])
18
+ @children = @event.children.reverse_chronological
19
+ end
20
+
21
+ private
22
+
23
+ def apply_filters
24
+ @events = @events.by_name(params[:name]) if params[:name].present?
25
+ @events = @events.by_source(params[:source]) if params[:source].present?
26
+ @events = @events.by_actor_type(params[:actor_type]) if params[:actor_type].present?
27
+ @events = @events.search(params[:q]) if params[:q].present?
28
+ @events = @events.since(Date.parse(params[:since])) if params[:since].present?
29
+ @events = @events.before(Date.parse(params[:before]).end_of_day) if params[:before].present?
30
+ end
31
+
32
+ def apply_cursor_pagination
33
+ @events = @events.where("sinaliza_events.id < ?", params[:before_id].to_i) if params[:before_id].present?
34
+ @events = @events.limit(PER_PAGE + 1).to_a
35
+
36
+ @has_next_page = @events.size > PER_PAGE
37
+ @events = @events.first(PER_PAGE)
38
+ @next_cursor = @events.last&.id if @has_next_page
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,4 @@
1
+ module Sinaliza
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Sinaliza
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,19 @@
1
+ module Sinaliza
2
+ class RecordEventJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(attributes)
6
+ attributes = attributes.symbolize_keys
7
+
8
+ if attributes[:actor].is_a?(String)
9
+ attributes[:actor] = GlobalID::Locator.locate(attributes[:actor])
10
+ end
11
+
12
+ if attributes[:target].is_a?(String)
13
+ attributes[:target] = GlobalID::Locator.locate(attributes[:target])
14
+ end
15
+
16
+ Sinaliza::Event.create!(attributes)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ module Sinaliza
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,39 @@
1
+ module Sinaliza
2
+ module Trackable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_many :events_as_actor,
7
+ class_name: "Sinaliza::Event",
8
+ as: :actor,
9
+ dependent: :nullify
10
+
11
+ has_many :events_as_target,
12
+ class_name: "Sinaliza::Event",
13
+ as: :target,
14
+ dependent: :nullify
15
+ end
16
+
17
+ def track_event(name, target: nil, parent: nil, metadata: {})
18
+ Sinaliza.record(
19
+ name: name,
20
+ actor: self,
21
+ target: target,
22
+ parent: parent,
23
+ metadata: metadata,
24
+ source: "model"
25
+ )
26
+ end
27
+
28
+ def track_event_as_target(name, actor: nil, parent: nil, metadata: {})
29
+ Sinaliza.record(
30
+ name: name,
31
+ actor: actor,
32
+ target: self,
33
+ parent: parent,
34
+ metadata: metadata,
35
+ source: "model"
36
+ )
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ module Sinaliza
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,32 @@
1
+ module Sinaliza
2
+ class Event < ApplicationRecord
3
+ belongs_to :actor, polymorphic: true, optional: true
4
+ belongs_to :target, polymorphic: true, optional: true
5
+ belongs_to :parent, class_name: "Sinaliza::Event", optional: true
6
+
7
+ has_many :children, class_name: "Sinaliza::Event", foreign_key: :parent_id, dependent: :destroy
8
+
9
+ validates :name, presence: true
10
+
11
+ scope :by_name, ->(name) { where(name: name) }
12
+ scope :by_source, ->(source) { where(source: source) }
13
+ scope :by_actor_type, ->(type) { where(actor_type: type) }
14
+ scope :since, ->(time) { where(created_at: time..) }
15
+ scope :before, ->(time) { where(created_at: ..time) }
16
+ scope :between, ->(from, to) { where(created_at: from..to) }
17
+ scope :chronological, -> { order(created_at: :asc) }
18
+ scope :reverse_chronological, -> { order(created_at: :desc) }
19
+ scope :roots, -> { where(parent_id: nil) }
20
+ scope :search, ->(query) {
21
+ where("name LIKE :q OR source LIKE :q OR actor_type LIKE :q OR target_type LIKE :q", q: "%#{query}%")
22
+ }
23
+
24
+ def root?
25
+ parent_id.nil?
26
+ end
27
+
28
+ def child?
29
+ parent_id.present?
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Sinaliza</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "sinaliza/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,53 @@
1
+ <form method="get" action="<%= events_path %>" class="sinaliza-filters">
2
+ <div class="sinaliza-filters__row">
3
+ <div class="sinaliza-filters__field">
4
+ <label for="q">Search</label>
5
+ <input type="text" name="q" id="q" value="<%= params[:q] %>" placeholder="Search events...">
6
+ </div>
7
+
8
+ <div class="sinaliza-filters__field">
9
+ <label for="name">Name</label>
10
+ <select name="name" id="name">
11
+ <option value="">All</option>
12
+ <% @filter_names.each do |name| %>
13
+ <option value="<%= name %>" <%= "selected" if params[:name] == name %>><%= name %></option>
14
+ <% end %>
15
+ </select>
16
+ </div>
17
+
18
+ <div class="sinaliza-filters__field">
19
+ <label for="source">Source</label>
20
+ <select name="source" id="source">
21
+ <option value="">All</option>
22
+ <% @filter_sources.each do |source| %>
23
+ <option value="<%= source %>" <%= "selected" if params[:source] == source %>><%= source %></option>
24
+ <% end %>
25
+ </select>
26
+ </div>
27
+
28
+ <div class="sinaliza-filters__field">
29
+ <label for="actor_type">Actor type</label>
30
+ <select name="actor_type" id="actor_type">
31
+ <option value="">All</option>
32
+ <% @filter_actor_types.each do |type| %>
33
+ <option value="<%= type %>" <%= "selected" if params[:actor_type] == type %>><%= type %></option>
34
+ <% end %>
35
+ </select>
36
+ </div>
37
+
38
+ <div class="sinaliza-filters__field">
39
+ <label for="since">Since</label>
40
+ <input type="date" name="since" id="since" value="<%= params[:since] %>">
41
+ </div>
42
+
43
+ <div class="sinaliza-filters__field">
44
+ <label for="before">Before</label>
45
+ <input type="date" name="before" id="before" value="<%= params[:before] %>">
46
+ </div>
47
+
48
+ <div class="sinaliza-filters__actions">
49
+ <button type="submit">Filter</button>
50
+ <a href="<%= events_path %>" class="sinaliza-link">Clear</a>
51
+ </div>
52
+ </div>
53
+ </form>
@@ -0,0 +1,45 @@
1
+ <div class="sinaliza-dashboard">
2
+ <h1>Events Monitor</h1>
3
+
4
+ <%= render "filters" %>
5
+
6
+ <table class="sinaliza-table">
7
+ <thead>
8
+ <tr>
9
+ <th>Time</th>
10
+ <th>Name</th>
11
+ <th>Source</th>
12
+ <th>Actor</th>
13
+ <th>Target</th>
14
+ <th>Children</th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @events.each do |event| %>
20
+ <tr>
21
+ <td class="sinaliza-time"><%= event.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
22
+ <td><span class="sinaliza-badge"><%= event.name %></span></td>
23
+ <td><span class="sinaliza-badge sinaliza-badge--source"><%= event.source %></span></td>
24
+ <td><%= event.actor ? "#{event.actor_type}##{event.actor_id}" : "-" %></td>
25
+ <td><%= event.target ? "#{event.target_type}##{event.target_id}" : "-" %></td>
26
+ <td><%= event.children.size %></td>
27
+ <td><%= link_to "Detail", event_path(event), class: "sinaliza-link" %></td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+
33
+ <% if @events.empty? %>
34
+ <p class="sinaliza-empty">No events found.</p>
35
+ <% end %>
36
+
37
+ <% if @has_next_page %>
38
+ <div class="sinaliza-pagination">
39
+ <%
40
+ pagination_params = request.query_parameters.merge(before_id: @next_cursor)
41
+ %>
42
+ <%= link_to "Older events", events_path(pagination_params), class: "sinaliza-link" %>
43
+ </div>
44
+ <% end %>
45
+ </div>
@@ -0,0 +1,79 @@
1
+ <div class="sinaliza-dashboard">
2
+ <p><%= link_to "Back to events", events_path, class: "sinaliza-link" %></p>
3
+
4
+ <h1>Event #<%= @event.id %></h1>
5
+
6
+ <table class="sinaliza-detail">
7
+ <tr>
8
+ <th>Name</th>
9
+ <td><span class="sinaliza-badge"><%= @event.name %></span></td>
10
+ </tr>
11
+ <% if @event.parent %>
12
+ <tr>
13
+ <th>Parent</th>
14
+ <td><%= link_to "Event ##{@event.parent_id}", event_path(@event.parent), class: "sinaliza-link" %></td>
15
+ </tr>
16
+ <% end %>
17
+ <tr>
18
+ <th>Source</th>
19
+ <td><span class="sinaliza-badge sinaliza-badge--source"><%= @event.source %></span></td>
20
+ </tr>
21
+ <tr>
22
+ <th>Actor</th>
23
+ <td><%= @event.actor ? "#{@event.actor_type}##{@event.actor_id}" : "-" %></td>
24
+ </tr>
25
+ <tr>
26
+ <th>Target</th>
27
+ <td><%= @event.target ? "#{@event.target_type}##{@event.target_id}" : "-" %></td>
28
+ </tr>
29
+ <tr>
30
+ <th>Metadata</th>
31
+ <td><pre class="sinaliza-json"><%= JSON.pretty_generate(@event.metadata) %></pre></td>
32
+ </tr>
33
+ <tr>
34
+ <th>IP address</th>
35
+ <td><%= @event.ip_address || "-" %></td>
36
+ </tr>
37
+ <tr>
38
+ <th>User agent</th>
39
+ <td><%= @event.user_agent || "-" %></td>
40
+ </tr>
41
+ <tr>
42
+ <th>Request ID</th>
43
+ <td><%= @event.request_id || "-" %></td>
44
+ </tr>
45
+ <tr>
46
+ <th>Created at</th>
47
+ <td><%= @event.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></td>
48
+ </tr>
49
+ </table>
50
+
51
+ <% if @children.any? %>
52
+ <h2>Sub-events</h2>
53
+
54
+ <table class="sinaliza-table">
55
+ <thead>
56
+ <tr>
57
+ <th>Time</th>
58
+ <th>Name</th>
59
+ <th>Source</th>
60
+ <th>Actor</th>
61
+ <th>Target</th>
62
+ <th></th>
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+ <% @children.each do |child| %>
67
+ <tr>
68
+ <td class="sinaliza-time"><%= child.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
69
+ <td><span class="sinaliza-badge"><%= child.name %></span></td>
70
+ <td><span class="sinaliza-badge sinaliza-badge--source"><%= child.source %></span></td>
71
+ <td><%= child.actor ? "#{child.actor_type}##{child.actor_id}" : "-" %></td>
72
+ <td><%= child.target ? "#{child.target_type}##{child.target_id}" : "-" %></td>
73
+ <td><%= link_to "Detail", event_path(child), class: "sinaliza-link" %></td>
74
+ </tr>
75
+ <% end %>
76
+ </tbody>
77
+ </table>
78
+ <% end %>
79
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ Sinaliza::Engine.routes.draw do
2
+ resources :events, only: [ :index, :show ]
3
+ root to: "events#index"
4
+ end
@@ -0,0 +1,20 @@
1
+ class CreateSinalizaEvents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :sinaliza_events do |t|
4
+ t.string :name, null: false
5
+ t.references :actor, polymorphic: true, index: true
6
+ t.references :target, polymorphic: true, index: true
7
+ t.json :metadata, default: {}
8
+ t.string :source, default: "manual"
9
+ t.string :ip_address
10
+ t.string :user_agent
11
+ t.string :request_id
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :sinaliza_events, :name
17
+ add_index :sinaliza_events, :source
18
+ add_index :sinaliza_events, :created_at
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ class AddParentIdToSinalizaEvents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :sinaliza_events, :parent, null: true, foreign_key: { to_table: :sinaliza_events }, index: true
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ module Sinaliza
2
+ class Configuration
3
+ attr_accessor :actor_method, :default_source, :record_request_info, :purge_after
4
+
5
+ def initialize
6
+ @actor_method = :current_user
7
+ @default_source = "manual"
8
+ @record_request_info = true
9
+ @purge_after = nil
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ module Sinaliza
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Sinaliza
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module Sinaliza
2
+ VERSION = "0.1.0"
3
+ end
data/lib/sinaliza.rb ADDED
@@ -0,0 +1,48 @@
1
+ require "sinaliza/version"
2
+ require "sinaliza/engine"
3
+ require "sinaliza/configuration"
4
+
5
+ module Sinaliza
6
+ class << self
7
+ def configuration
8
+ @configuration ||= Configuration.new
9
+ end
10
+
11
+ def configure
12
+ yield(configuration)
13
+ end
14
+
15
+ def record(name:, actor: nil, target: nil, parent: nil, metadata: {}, source: nil, ip_address: nil, user_agent: nil, request_id: nil)
16
+ parent_id = parent.is_a?(Sinaliza::Event) ? parent.id : parent
17
+
18
+ Sinaliza::Event.create!(
19
+ name: name,
20
+ actor: actor,
21
+ target: target,
22
+ parent_id: parent_id,
23
+ metadata: metadata,
24
+ source: source || configuration.default_source,
25
+ ip_address: ip_address,
26
+ user_agent: user_agent,
27
+ request_id: request_id
28
+ )
29
+ end
30
+
31
+ def record_later(name:, actor: nil, target: nil, parent: nil, metadata: {}, source: nil, ip_address: nil, user_agent: nil, request_id: nil)
32
+ attributes = {
33
+ name: name,
34
+ metadata: metadata,
35
+ source: source || configuration.default_source,
36
+ ip_address: ip_address,
37
+ user_agent: user_agent,
38
+ request_id: request_id
39
+ }
40
+
41
+ attributes[:actor] = actor.to_global_id.to_s if actor
42
+ attributes[:target] = target.to_global_id.to_s if target
43
+ attributes[:parent_id] = parent.is_a?(Sinaliza::Event) ? parent.id : parent if parent
44
+
45
+ Sinaliza::RecordEventJob.perform_later(attributes)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,15 @@
1
+ namespace :sinaliza do
2
+ desc "Purge events older than Sinaliza.configuration.purge_after"
3
+ task purge: :environment do
4
+ purge_after = Sinaliza.configuration.purge_after
5
+
6
+ if purge_after.nil?
7
+ puts "No purge_after configured. Set Sinaliza.configuration.purge_after to a duration (e.g., 90.days)."
8
+ next
9
+ end
10
+
11
+ cutoff = purge_after.ago
12
+ count = Sinaliza::Event.where(created_at: ...cutoff).delete_all
13
+ puts "Purged #{count} events older than #{cutoff}."
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sinaliza
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Marcelo Moraes
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 8.1.2
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '8.1'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 8.1.2
32
+ description: Track user actions, system events, and anything worth logging — from
33
+ models, controllers, or anywhere in your code. Events are stored in the database
34
+ and viewable through a mountable monitor dashboard.
35
+ email:
36
+ - marcelonmoraes@gmail.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - MIT-LICENSE
42
+ - README.md
43
+ - Rakefile
44
+ - app/assets/stylesheets/sinaliza/application.css
45
+ - app/controllers/concerns/sinaliza/traceable.rb
46
+ - app/controllers/sinaliza/application_controller.rb
47
+ - app/controllers/sinaliza/events_controller.rb
48
+ - app/helpers/sinaliza/application_helper.rb
49
+ - app/jobs/sinaliza/application_job.rb
50
+ - app/jobs/sinaliza/record_event_job.rb
51
+ - app/mailers/sinaliza/application_mailer.rb
52
+ - app/models/concerns/sinaliza/trackable.rb
53
+ - app/models/sinaliza/application_record.rb
54
+ - app/models/sinaliza/event.rb
55
+ - app/views/layouts/sinaliza/application.html.erb
56
+ - app/views/sinaliza/events/_filters.html.erb
57
+ - app/views/sinaliza/events/index.html.erb
58
+ - app/views/sinaliza/events/show.html.erb
59
+ - config/routes.rb
60
+ - db/migrate/20260219000000_create_sinaliza_events.rb
61
+ - db/migrate/20260219100000_add_parent_id_to_sinaliza_events.rb
62
+ - lib/sinaliza.rb
63
+ - lib/sinaliza/configuration.rb
64
+ - lib/sinaliza/engine.rb
65
+ - lib/sinaliza/version.rb
66
+ - lib/tasks/sinaliza_tasks.rake
67
+ homepage: https://github.com/marcelonmoraes/sinaliza
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ homepage_uri: https://github.com/marcelonmoraes/sinaliza
72
+ source_code_uri: https://github.com/marcelonmoraes/sinaliza/tree/main
73
+ changelog_uri: https://github.com/marcelonmoraes/sinaliza/blob/main/CHANGELOG.md
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.7.2
89
+ specification_version: 4
90
+ summary: Rails engine for recording and browsing application events.
91
+ test_files: []