omnitrack-rb 0.1.0 → 3.0.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +6 -0
  3. data/AI_GEM_SETUP.md +33 -0
  4. data/CHANGELOG.md +8 -0
  5. data/README.md +44 -1
  6. data/app/controllers/omnitrack/application_controller.rb +24 -0
  7. data/app/controllers/omnitrack/path/application_controller.rb +15 -0
  8. data/app/controllers/omnitrack/path/sessions_controller.rb +39 -0
  9. data/app/controllers/omnitrack/path/visit_events_controller.rb +21 -0
  10. data/app/controllers/omnitrack/sessions_controller.rb +37 -0
  11. data/app/controllers/omnitrack/visit_events_controller.rb +19 -0
  12. data/app/models/omnitrack/application_record.rb +7 -0
  13. data/app/models/omnitrack/delivery_status.rb +12 -0
  14. data/app/models/omnitrack/visit_event.rb +15 -0
  15. data/app/views/layouts/omnitrack/admin.html.erb +55 -0
  16. data/app/views/omnitrack/path/sessions/new.html.erb +16 -0
  17. data/app/views/omnitrack/path/visit_events/index.html.erb +50 -0
  18. data/app/views/omnitrack/path/visit_events/show.html.erb +51 -0
  19. data/app/views/omnitrack/sessions/new.html.erb +16 -0
  20. data/app/views/omnitrack/visit_events/index.html.erb +50 -0
  21. data/app/views/omnitrack/visit_events/show.html.erb +51 -0
  22. data/config/locales/ar.yml +33 -0
  23. data/config/locales/en.yml +33 -0
  24. data/config/routes.rb +10 -0
  25. data/lib/generators/omnitrack/install/install_generator.rb +18 -2
  26. data/lib/generators/omnitrack/install/templates/create_omnitrack_dashboard_tables.rb +41 -0
  27. data/lib/generators/omnitrack/install/templates/env.example +6 -0
  28. data/lib/generators/omnitrack/install/templates/initializer.rb +9 -0
  29. data/lib/omnitrack/audit/recorder.rb +134 -0
  30. data/lib/omnitrack/compat/hash_compact_backport.rb +12 -0
  31. data/lib/omnitrack/configuration.rb +22 -0
  32. data/lib/omnitrack/context.rb +11 -3
  33. data/lib/omnitrack/engine.rb +11 -0
  34. data/lib/omnitrack/middleware/request_tracker.rb +7 -0
  35. data/lib/omnitrack/railtie.rb +8 -0
  36. data/lib/omnitrack/registry.rb +2 -2
  37. data/lib/omnitrack/version.rb +1 -1
  38. data/lib/omnitrack.rb +12 -2
  39. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3159ca70c1926bd7576f69daa82b043b7712219c920ad13c956560294b52940c
4
- data.tar.gz: 8f86771d6bba1b2c6531b1076c369eb464faa40406d111aacebe0610273f83df
3
+ metadata.gz: ff7c5f93aedbb1f18f5b24a083661516212f52c8973b2b464ddef8667701de31
4
+ data.tar.gz: dfb4173d07da82b896221c87f3c107b1061eea2d1183d5da16787c5420924235
5
5
  SHA512:
6
- metadata.gz: d49fed2d40c06182c33922feafdf8fb7969480c3468d03b95248594fb16517ce0e6f8cc3171c002bd3d8b83ce9fa097d0f61bb88bdf7156015cfa10ea50d16de
7
- data.tar.gz: d95277587d7eab7b07e4cebbad26e593d96ee00dafff1ebcbcc45953d073db1255c6b27585011e61b006131078c93814d6e530e4986660a6d0c7cd692efef4db
6
+ metadata.gz: 40b53c4312d9986943c0dd6b174b061070731f9a083e3cfd12c171692a6bc11630a68e89a7f013fd8c411dc47e31c3c047f88c8710bf959b5f68870b5c63ff9f
7
+ data.tar.gz: fbb0e26662fd43e79a9d87d7d6e18b9716681ae232c564deec84b4e0adf64f2a4718c5274a85ac30b956329cf850178b090364bfe62d92eb28d47e6b2553e451
data/.env.example CHANGED
@@ -41,3 +41,9 @@ TIKTOK_ACCESS_TOKEN=
41
41
  SNAPCHAT_ENABLED=false
42
42
  SNAPCHAT_PIXEL_ID=
43
43
  SNAPCHAT_ACCESS_TOKEN=
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # OmniTrack Admin Dashboard
47
+ # ---------------------------------------------------------------------------
48
+ OMNITRACK_ADMIN_USERNAME=admin
49
+ OMNITRACK_ADMIN_PASSWORD=change-me
data/AI_GEM_SETUP.md CHANGED
@@ -4,6 +4,30 @@ Use this document when **adding `omnitrack-rb` to an existing Rails 6+ app** so
4
4
 
5
5
  ---
6
6
 
7
+ ## 0. Ruby version (including 2.7.8)
8
+
9
+ The gem declares **`required_ruby_version >= 2.7.0`** and is written to run on **Ruby 2.7.8** without Ruby 3-only syntax. Your **host app** should use a supported Ruby and lock it so CI and production match.
10
+
11
+ **Example in the host `Gemfile` (optional but recommended):**
12
+
13
+ ```ruby
14
+ ruby "2.7.8" # or "3.0.6", "3.2.2", etc. — must be >= 2.7.0
15
+ ```
16
+
17
+ **Check locally:**
18
+
19
+ ```bash
20
+ ruby -v # should be 2.7.8 or newer
21
+ ```
22
+
23
+ **Rails:** use **Rails 6.x or 7.x** (or newer) with a Ruby version that Rails supports for that release. If the app is stuck on **Ruby 2.7.8**, keep **Rails 6.1.x** (or the last 6.x line you use) unless you have verified a newer stack.
24
+
25
+ **Bundler:** Bundler 2.x is typical; run `bundle install` with the same Ruby the app uses in production.
26
+
27
+ Nothing else is required for 2.7.8 specifically — no extra `require` in `application.rb`; the Railtie loads OmniTrack like any other Rails engine/gem.
28
+
29
+ ---
30
+
7
31
  ## 1. Gemfile (required)
8
32
 
9
33
  In the **host** application `Gemfile`:
@@ -33,6 +57,13 @@ rails generate omnitrack:install
33
57
  This creates:
34
58
 
35
59
  - `config/initializers/omnitrack.rb`
60
+ - `db/migrate/*_create_omnitrack_dashboard_tables.rb`
61
+
62
+ Then run:
63
+
64
+ ```bash
65
+ rails db:migrate
66
+ ```
36
67
 
37
68
  **Boot check (must succeed):**
38
69
 
@@ -128,6 +159,7 @@ Until `Omnitrack::Jobs::TrackingJob` and ActiveJob are loaded, the gem runs sync
128
159
 
129
160
  | Symptom | What to do |
130
161
  |--------|------------|
162
+ | `ruby` version error from Bundler | Host `Gemfile` `ruby "x.y.z"` must match your runtime; use `ruby -v` and align RVM/rbenv/asdf. |
131
163
  | `uninitialized constant Omnitrack` | Run `bundle install`; ensure `config/application.rb` loads Bundler default group; boot with `bin/rails c`. |
132
164
  | `ENV[...] is nil` in logs | Adapters with `enabled: true` but missing tokens may log or skip; set `*_ENABLED=false` until credentials exist. |
133
165
  | Job errors when `async: true` | Set `config.async = false` until ActiveJob and queue are configured, or add queue adapter. |
@@ -142,6 +174,7 @@ Until `Omnitrack::Jobs::TrackingJob` and ActiveJob are loaded, the gem runs sync
142
174
  |------|--------|
143
175
  | `Gemfile` | `gem "omnitrack-rb"` (and `dotenv-rails` if using `.env`) |
144
176
  | `config/initializers/omnitrack.rb` | `Omnitrack.configure` |
177
+ | `db/migrate/*_create_omnitrack_dashboard_tables.rb` | Dashboard history tables migration |
145
178
  | `.env.example` | Document variable names (no secrets) — optional if committed |
146
179
  | `.env` | Local secrets — **gitignored** |
147
180
  | `app/views/layouts/...` | `<%= omnitrack_tags %>` for full-stack |
data/CHANGELOG.md CHANGED
@@ -2,18 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.0.0] - 2026-04-26
6
+
5
7
  ### Added
6
8
  - `AI_GEM_SETUP.md` — step-by-step integration for host apps (Bundler, initializer, ENV, ActiveJob, pitfalls)
7
9
  - `.env.example` — variable names matching the install template; install generator copies `env.example` → `.env.example` when missing
8
10
  - `USAGE.md` — concrete ERB/Ruby examples for layout, HTML/API controllers, and a service object
11
+ - `lib/omnitrack/compat/hash_compact_backport` — `Hash#compact` on Rubies before 3.1 (e.g. 2.7.x, 3.0)
9
12
 
10
13
  ### Fixed
14
+ - **Ruby 2.7.8** and **Ruby 3.0** compatibility: no endless `def` or `filter_map` (require Ruby 3+ in core for those); compatible `Enumerable` + `Hash#compact` usage
11
15
  - Conventional `lib/omnitrack` gem layout, loadable `require "omnitrack"`
12
16
  - `rails generate omnitrack:install` generator at `lib/generators/omnitrack/install/`
13
17
  - `Omnitrack::Controller` as an alias of `Omnitrack::Concerns::Controller`
14
18
  - Default `adapter_name` for adapters uses `underscore` (snake_case) so config keys (e.g. `google_ads`) match the registry; `Omnitrack.reset!` re-registers first-party adapters in tests
15
19
  - `activesupport` as a direct runtime dependency; explicit requires for `ActiveSupport` extensions used at load time
16
20
 
21
+ ### Upgrade from 0.1.0
22
+ - In the host `Gemfile`, pin `gem "omnitrack-rb", "~> 2.0"` and run `bundle update omnitrack-rb`
23
+ - If you had relied on a mis-registered adapter key (`:googleads` from an old build), use **`google_ads`** in `config.adapters` (and env-driven flags) to match the registry
24
+
17
25
  ## [0.1.0] - 2024-01-15
18
26
 
19
27
  ### Added
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # OmniTrack-rb 🎯
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/omnitrack-rb)](https://rubygems.org/gems/omnitrack-rb)
4
- [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-red)](https://www.ruby-lang.org)
4
+ [![Ruby](https://img.shields.io/badge/ruby-2.7%2B-red)](https://www.ruby-lang.org)
5
5
  [![Rails](https://img.shields.io/badge/rails-%3E%3D%206.0-red)](https://rubyonrails.org)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
7
7
 
@@ -9,6 +9,8 @@
9
9
 
10
10
  OmniTrack dispatches events to multiple ad/analytics platforms through a clean adapter pattern — works identically in full-stack and API-only Rails apps, never crashes your main application, and emits structured JSON logs to a dedicated file.
11
11
 
12
+ Runtime: **Ruby ≥ 2.7** (including **2.7.8**) and **Rails ≥ 6**. No Ruby 3-only syntax in the gem runtime.
13
+
12
14
  **Practical usage (file placement + method examples):** [USAGE.md](USAGE.md)
13
15
  **Add the gem to any Rails app without boot errors (ENV, jobs, checklists):** [AI_GEM_SETUP.md](AI_GEM_SETUP.md)
14
16
  **ENV variable names (copy into host `.env.example`):** [.env.example](.env.example)
@@ -321,6 +323,47 @@ config.active_job.queue_adapter = :sidekiq
321
323
 
322
324
  ---
323
325
 
326
+ ## Admin Dashboard (Full Visitor History)
327
+
328
+ OmniTrack now includes a dashboard UI at:
329
+
330
+ `/omnitrack/login` (default mount path)
331
+
332
+ Features:
333
+
334
+ - Admin login (username/password)
335
+ - Full visitor lifecycle history (from first request through dispatch)
336
+ - Per-platform delivery status (`success`, `failure`, `queued`, etc.)
337
+ - Event detail page with payload + context snapshots
338
+ - Pagination
339
+ - Arabic/English toggle
340
+
341
+ Setup:
342
+
343
+ 1. Run installer and migration:
344
+
345
+ ```bash
346
+ rails generate omnitrack:install
347
+ rails db:migrate
348
+ ```
349
+
350
+ 2. Set dashboard credentials in ENV:
351
+
352
+ ```bash
353
+ OMNITRACK_ADMIN_USERNAME=admin
354
+ OMNITRACK_ADMIN_PASSWORD=change-me
355
+ ```
356
+
357
+ 3. Optional initializer settings:
358
+
359
+ ```ruby
360
+ config.dashboard_enabled = true
361
+ config.dashboard_per_page = 25
362
+ config.dashboard_mount_path = "/omnitrack" # change per host app if needed
363
+ ```
364
+
365
+ ---
366
+
324
367
  ## Middleware
325
368
 
326
369
  `Omnitrack::Middleware::RequestTracker` is automatically inserted into your Rack stack. It:
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ class ApplicationController < ActionController::Base
5
+ layout "omnitrack/admin"
6
+
7
+ before_action :set_locale
8
+ before_action :require_admin_authentication
9
+
10
+ private
11
+
12
+ def require_admin_authentication
13
+ return if session[:omnitrack_admin_authenticated]
14
+
15
+ redirect_to login_path(locale: I18n.locale), alert: t("omnitrack.auth.login_required")
16
+ end
17
+
18
+ def set_locale
19
+ requested_locale = params[:locale].presence
20
+ locale = requested_locale&.to_sym
21
+ I18n.locale = I18n.available_locales.include?(locale) ? locale : I18n.default_locale
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ module Path
5
+ class ApplicationController < ::Omnitrack::ApplicationController
6
+ private
7
+
8
+ def require_admin_authentication
9
+ return if session[:omnitrack_admin_authenticated]
10
+
11
+ redirect_to login_path(locale: I18n.locale), alert: t("omnitrack.auth.login_required")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Omnitrack
6
+ module Path
7
+ class SessionsController < ApplicationController
8
+ skip_before_action :require_admin_authentication, only: %i[new create]
9
+
10
+ def new; end
11
+
12
+ def create
13
+ if valid_credentials?
14
+ session[:omnitrack_admin_authenticated] = true
15
+ redirect_to visit_events_path(locale: I18n.locale), notice: t("omnitrack.auth.login_success")
16
+ else
17
+ flash.now[:alert] = t("omnitrack.auth.login_failed")
18
+ render :new, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ def destroy
23
+ session.delete(:omnitrack_admin_authenticated)
24
+ redirect_to login_path(locale: I18n.locale), notice: t("omnitrack.auth.logout_success")
25
+ end
26
+
27
+ private
28
+
29
+ def valid_credentials?
30
+ secure_compare(params[:username].to_s, Omnitrack.config.admin_username.to_s) &&
31
+ secure_compare(params[:password].to_s, Omnitrack.config.admin_password.to_s)
32
+ end
33
+
34
+ def secure_compare(value, expected)
35
+ ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(value), Digest::SHA256.hexdigest(expected))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ module Path
5
+ class VisitEventsController < ApplicationController
6
+ def index
7
+ @page = [params.fetch(:page, 1).to_i, 1].max
8
+ @per_page = Omnitrack.config.dashboard_per_page.to_i
9
+
10
+ scoped = Omnitrack::VisitEvent.recent_first
11
+ @total_count = scoped.count
12
+ @total_pages = (@total_count.to_f / @per_page).ceil
13
+ @events = scoped.includes(:delivery_statuses).offset((@page - 1) * @per_page).limit(@per_page)
14
+ end
15
+
16
+ def show
17
+ @event = Omnitrack::VisitEvent.includes(:delivery_statuses).find(params[:id])
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Omnitrack
6
+ class SessionsController < ApplicationController
7
+ skip_before_action :require_admin_authentication, only: %i[new create]
8
+
9
+ def new; end
10
+
11
+ def create
12
+ if valid_credentials?
13
+ session[:omnitrack_admin_authenticated] = true
14
+ redirect_to visit_events_path(locale: I18n.locale), notice: t("omnitrack.auth.login_success")
15
+ else
16
+ flash.now[:alert] = t("omnitrack.auth.login_failed")
17
+ render :new, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ def destroy
22
+ session.delete(:omnitrack_admin_authenticated)
23
+ redirect_to login_path(locale: I18n.locale), notice: t("omnitrack.auth.logout_success")
24
+ end
25
+
26
+ private
27
+
28
+ def valid_credentials?
29
+ secure_compare(params[:username].to_s, Omnitrack.config.admin_username.to_s) &&
30
+ secure_compare(params[:password].to_s, Omnitrack.config.admin_password.to_s)
31
+ end
32
+
33
+ def secure_compare(value, expected)
34
+ ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(value), Digest::SHA256.hexdigest(expected))
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ class VisitEventsController < ApplicationController
5
+ def index
6
+ @page = [params.fetch(:page, 1).to_i, 1].max
7
+ @per_page = Omnitrack.config.dashboard_per_page.to_i
8
+
9
+ scoped = Omnitrack::VisitEvent.recent_first
10
+ @total_count = scoped.count
11
+ @total_pages = (@total_count.to_f / @per_page).ceil
12
+ @events = scoped.includes(:delivery_statuses).offset((@page - 1) * @per_page).limit(@per_page)
13
+ end
14
+
15
+ def show
16
+ @event = Omnitrack::VisitEvent.includes(:delivery_statuses).find(params[:id])
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ class DeliveryStatus < ApplicationRecord
5
+ self.table_name = "omnitrack_delivery_statuses"
6
+
7
+ belongs_to :visit_event,
8
+ class_name: "Omnitrack::VisitEvent",
9
+ foreign_key: :visit_event_id,
10
+ inverse_of: :delivery_statuses
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ class VisitEvent < ApplicationRecord
5
+ self.table_name = "omnitrack_visit_events"
6
+
7
+ has_many :delivery_statuses,
8
+ class_name: "Omnitrack::DeliveryStatus",
9
+ foreign_key: :visit_event_id,
10
+ inverse_of: :visit_event,
11
+ dependent: :delete_all
12
+
13
+ scope :recent_first, -> { order(started_at: :desc) }
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>" dir="<%= I18n.locale.to_s == 'ar' ? 'rtl' : 'ltr' %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title><%= t("omnitrack.ui.title") %></title>
7
+ <style>
8
+ :root { --bg:#0f172a; --card:#111827; --text:#f3f4f6; --muted:#9ca3af; --ok:#22c55e; --bad:#ef4444; --warn:#f59e0b; --line:#374151; }
9
+ body { margin:0; background:var(--bg); color:var(--text); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
10
+ .shell { max-width: 1200px; margin: 0 auto; padding: 24px; }
11
+ .top { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:20px; }
12
+ .brand { font-size: 20px; font-weight: 700; }
13
+ .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:16px; margin-bottom:16px; }
14
+ .grid { display:grid; gap:12px; }
15
+ table { width:100%; border-collapse: collapse; }
16
+ th, td { padding:10px; border-bottom:1px solid var(--line); text-align:start; vertical-align:top; }
17
+ th { color:var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing:.04em; }
18
+ a { color:#93c5fd; text-decoration:none; }
19
+ .badge { border-radius:999px; padding:4px 10px; font-size:12px; font-weight:600; display:inline-block; }
20
+ .success { background: rgba(34,197,94,.15); color: var(--ok); }
21
+ .failure { background: rgba(239,68,68,.15); color: var(--bad); }
22
+ .partial_failure, .processing, .queued, .skipped { background: rgba(245,158,11,.15); color: var(--warn); }
23
+ .muted { color:var(--muted); }
24
+ input, button, select { font: inherit; padding:10px 12px; border-radius:10px; border:1px solid var(--line); background:#0b1220; color:var(--text); }
25
+ button { cursor:pointer; }
26
+ .row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
27
+ .alert { padding:10px 12px; border-radius:10px; margin-bottom:14px; }
28
+ .alert-ok { background: rgba(34,197,94,.15); color: var(--ok); }
29
+ .alert-bad { background: rgba(239,68,68,.15); color: var(--bad); }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <div class="shell">
34
+ <div class="top">
35
+ <div class="brand"><%= t("omnitrack.ui.title") %></div>
36
+ <div class="row">
37
+ <%= link_to "EN", params.to_unsafe_h.merge(locale: :en) %>
38
+ <%= link_to "AR", params.to_unsafe_h.merge(locale: :ar) %>
39
+ <% if session[:omnitrack_admin_authenticated] %>
40
+ <%= button_to t("omnitrack.auth.logout"), logout_path(locale: I18n.locale), method: :delete, form_class: "row" %>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+
45
+ <% if flash[:notice] %>
46
+ <div class="alert alert-ok"><%= flash[:notice] %></div>
47
+ <% end %>
48
+ <% if flash[:alert] %>
49
+ <div class="alert alert-bad"><%= flash[:alert] %></div>
50
+ <% end %>
51
+
52
+ <%= yield %>
53
+ </div>
54
+ </body>
55
+ </html>
@@ -0,0 +1,16 @@
1
+ <div class="card" style="max-width: 440px; margin: 50px auto;">
2
+ <h2><%= t("omnitrack.auth.login_title") %></h2>
3
+ <p class="muted"><%= t("omnitrack.auth.login_hint") %></p>
4
+
5
+ <%= form_with url: login_submit_path(locale: I18n.locale), method: :post, local: true, class: "grid" do %>
6
+ <label>
7
+ <span><%= t("omnitrack.auth.username") %></span><br>
8
+ <%= text_field_tag :username, nil, required: true, autocomplete: "username" %>
9
+ </label>
10
+ <label>
11
+ <span><%= t("omnitrack.auth.password") %></span><br>
12
+ <%= password_field_tag :password, nil, required: true, autocomplete: "current-password" %>
13
+ </label>
14
+ <button type="submit"><%= t("omnitrack.auth.login_button") %></button>
15
+ <% end %>
16
+ </div>
@@ -0,0 +1,50 @@
1
+ <div class="card">
2
+ <div class="row" style="justify-content: space-between;">
3
+ <h2 style="margin:0;"><%= t("omnitrack.events.title") %></h2>
4
+ <div class="muted">
5
+ <%= t("omnitrack.events.total", count: @total_count) %> |
6
+ <%= t("omnitrack.events.page", current: @page, total: [@total_pages, 1].max) %>
7
+ </div>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="card">
12
+ <table>
13
+ <thead>
14
+ <tr>
15
+ <th><%= t("omnitrack.events.columns.id") %></th>
16
+ <th><%= t("omnitrack.events.columns.event") %></th>
17
+ <th><%= t("omnitrack.events.columns.ip") %></th>
18
+ <th><%= t("omnitrack.events.columns.started_at") %></th>
19
+ <th><%= t("omnitrack.events.columns.status") %></th>
20
+ <th><%= t("omnitrack.events.columns.actions") %></th>
21
+ </tr>
22
+ </thead>
23
+ <tbody>
24
+ <% @events.each do |event| %>
25
+ <tr>
26
+ <td><%= event.id %></td>
27
+ <td><%= event.event_name %></td>
28
+ <td><%= event.ip.presence || "-" %></td>
29
+ <td><%= event.started_at %></td>
30
+ <td><span class="badge <%= event.overall_status %>"><%= event.overall_status %></span></td>
31
+ <td><%= link_to t("omnitrack.events.view"), visit_event_path(event, locale: I18n.locale) %></td>
32
+ </tr>
33
+ <% end %>
34
+ <% if @events.empty? %>
35
+ <tr>
36
+ <td colspan="6" class="muted"><%= t("omnitrack.events.empty") %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+
43
+ <div class="row">
44
+ <% if @page > 1 %>
45
+ <%= link_to t("omnitrack.events.prev"), visit_events_path(page: @page - 1, locale: I18n.locale) %>
46
+ <% end %>
47
+ <% if @page < @total_pages %>
48
+ <%= link_to t("omnitrack.events.next"), visit_events_path(page: @page + 1, locale: I18n.locale) %>
49
+ <% end %>
50
+ </div>
@@ -0,0 +1,51 @@
1
+ <div class="card">
2
+ <div class="row" style="justify-content: space-between;">
3
+ <h2 style="margin:0;"><%= t("omnitrack.events.details_title", id: @event.id) %></h2>
4
+ <%= link_to t("omnitrack.events.back"), visit_events_path(locale: I18n.locale) %>
5
+ </div>
6
+
7
+ <div class="grid" style="margin-top:12px;">
8
+ <div><strong><%= t("omnitrack.events.columns.event") %>:</strong> <%= @event.event_name %></div>
9
+ <div><strong><%= t("omnitrack.events.columns.status") %>:</strong> <span class="badge <%= @event.overall_status %>"><%= @event.overall_status %></span></div>
10
+ <div><strong><%= t("omnitrack.events.columns.ip") %>:</strong> <%= @event.ip.presence || "-" %></div>
11
+ <div><strong>User Agent:</strong> <%= @event.user_agent.presence || "-" %></div>
12
+ <div><strong>Path:</strong> <%= @event.request_method %> <%= @event.request_path %></div>
13
+ <div><strong>Visitor Token:</strong> <%= @event.visitor_token %></div>
14
+ <div><strong>Started:</strong> <%= @event.started_at %></div>
15
+ <div><strong>Completed:</strong> <%= @event.completed_at %></div>
16
+ </div>
17
+ </div>
18
+
19
+ <div class="card">
20
+ <h3><%= t("omnitrack.events.delivery_history") %></h3>
21
+ <table>
22
+ <thead>
23
+ <tr>
24
+ <th>Adapter</th>
25
+ <th><%= t("omnitrack.events.columns.status") %></th>
26
+ <th>Sent At</th>
27
+ <th>Error</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ <% @event.delivery_statuses.order(sent_at: :asc).each do |delivery| %>
32
+ <tr>
33
+ <td><%= delivery.adapter_name %></td>
34
+ <td><span class="badge <%= delivery.status %>"><%= delivery.status %></span></td>
35
+ <td><%= delivery.sent_at %></td>
36
+ <td><%= delivery.error_message.presence || "-" %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+
43
+ <div class="card">
44
+ <h3>Payload</h3>
45
+ <pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.payload_json || {}) %></pre>
46
+ </div>
47
+
48
+ <div class="card">
49
+ <h3>Context</h3>
50
+ <pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.context_json || {}) %></pre>
51
+ </div>
@@ -0,0 +1,16 @@
1
+ <div class="card" style="max-width: 440px; margin: 50px auto;">
2
+ <h2><%= t("omnitrack.auth.login_title") %></h2>
3
+ <p class="muted"><%= t("omnitrack.auth.login_hint") %></p>
4
+
5
+ <%= form_with url: admin_login_submit_path(locale: I18n.locale), method: :post, local: true, class: "grid" do %>
6
+ <label>
7
+ <span><%= t("omnitrack.auth.username") %></span><br>
8
+ <%= text_field_tag :username, nil, required: true, autocomplete: "username" %>
9
+ </label>
10
+ <label>
11
+ <span><%= t("omnitrack.auth.password") %></span><br>
12
+ <%= password_field_tag :password, nil, required: true, autocomplete: "current-password" %>
13
+ </label>
14
+ <button type="submit"><%= t("omnitrack.auth.login_button") %></button>
15
+ <% end %>
16
+ </div>
@@ -0,0 +1,50 @@
1
+ <div class="card">
2
+ <div class="row" style="justify-content: space-between;">
3
+ <h2 style="margin:0;"><%= t("omnitrack.events.title") %></h2>
4
+ <div class="muted">
5
+ <%= t("omnitrack.events.total", count: @total_count) %> |
6
+ <%= t("omnitrack.events.page", current: @page, total: [@total_pages, 1].max) %>
7
+ </div>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="card">
12
+ <table>
13
+ <thead>
14
+ <tr>
15
+ <th><%= t("omnitrack.events.columns.id") %></th>
16
+ <th><%= t("omnitrack.events.columns.event") %></th>
17
+ <th><%= t("omnitrack.events.columns.ip") %></th>
18
+ <th><%= t("omnitrack.events.columns.started_at") %></th>
19
+ <th><%= t("omnitrack.events.columns.status") %></th>
20
+ <th><%= t("omnitrack.events.columns.actions") %></th>
21
+ </tr>
22
+ </thead>
23
+ <tbody>
24
+ <% @events.each do |event| %>
25
+ <tr>
26
+ <td><%= event.id %></td>
27
+ <td><%= event.event_name %></td>
28
+ <td><%= event.ip.presence || "-" %></td>
29
+ <td><%= event.started_at %></td>
30
+ <td><span class="badge <%= event.overall_status %>"><%= event.overall_status %></span></td>
31
+ <td><%= link_to t("omnitrack.events.view"), visit_event_path(event, locale: I18n.locale) %></td>
32
+ </tr>
33
+ <% end %>
34
+ <% if @events.empty? %>
35
+ <tr>
36
+ <td colspan="6" class="muted"><%= t("omnitrack.events.empty") %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+
43
+ <div class="row">
44
+ <% if @page > 1 %>
45
+ <%= link_to t("omnitrack.events.prev"), visit_events_path(page: @page - 1, locale: I18n.locale) %>
46
+ <% end %>
47
+ <% if @page < @total_pages %>
48
+ <%= link_to t("omnitrack.events.next"), visit_events_path(page: @page + 1, locale: I18n.locale) %>
49
+ <% end %>
50
+ </div>
@@ -0,0 +1,51 @@
1
+ <div class="card">
2
+ <div class="row" style="justify-content: space-between;">
3
+ <h2 style="margin:0;"><%= t("omnitrack.events.details_title", id: @event.id) %></h2>
4
+ <%= link_to t("omnitrack.events.back"), visit_events_path(locale: I18n.locale) %>
5
+ </div>
6
+
7
+ <div class="grid" style="margin-top:12px;">
8
+ <div><strong><%= t("omnitrack.events.columns.event") %>:</strong> <%= @event.event_name %></div>
9
+ <div><strong><%= t("omnitrack.events.columns.status") %>:</strong> <span class="badge <%= @event.overall_status %>"><%= @event.overall_status %></span></div>
10
+ <div><strong><%= t("omnitrack.events.columns.ip") %>:</strong> <%= @event.ip.presence || "-" %></div>
11
+ <div><strong>User Agent:</strong> <%= @event.user_agent.presence || "-" %></div>
12
+ <div><strong>Path:</strong> <%= @event.request_method %> <%= @event.request_path %></div>
13
+ <div><strong>Visitor Token:</strong> <%= @event.visitor_token %></div>
14
+ <div><strong>Started:</strong> <%= @event.started_at %></div>
15
+ <div><strong>Completed:</strong> <%= @event.completed_at %></div>
16
+ </div>
17
+ </div>
18
+
19
+ <div class="card">
20
+ <h3><%= t("omnitrack.events.delivery_history") %></h3>
21
+ <table>
22
+ <thead>
23
+ <tr>
24
+ <th>Adapter</th>
25
+ <th><%= t("omnitrack.events.columns.status") %></th>
26
+ <th>Sent At</th>
27
+ <th>Error</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ <% @event.delivery_statuses.order(sent_at: :asc).each do |delivery| %>
32
+ <tr>
33
+ <td><%= delivery.adapter_name %></td>
34
+ <td><span class="badge <%= delivery.status %>"><%= delivery.status %></span></td>
35
+ <td><%= delivery.sent_at %></td>
36
+ <td><%= delivery.error_message.presence || "-" %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+
43
+ <div class="card">
44
+ <h3>Payload</h3>
45
+ <pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.payload_json || {}) %></pre>
46
+ </div>
47
+
48
+ <div class="card">
49
+ <h3>Context</h3>
50
+ <pre style="white-space: pre-wrap;"><%= JSON.pretty_generate(@event.context_json || {}) %></pre>
51
+ </div>
@@ -0,0 +1,33 @@
1
+ ar:
2
+ omnitrack:
3
+ ui:
4
+ title: "لوحة أومنِتراك"
5
+ auth:
6
+ login_required: "يرجى تسجيل الدخول كمسؤول أولاً."
7
+ login_success: "تم تسجيل الدخول بنجاح."
8
+ login_failed: "اسم المستخدم أو كلمة المرور غير صحيحة."
9
+ logout_success: "تم تسجيل الخروج بنجاح."
10
+ logout: "تسجيل الخروج"
11
+ login_title: "تسجيل دخول المسؤول"
12
+ login_hint: "استخدم بيانات دخول مسؤول OmniTrack."
13
+ username: "اسم المستخدم"
14
+ password: "كلمة المرور"
15
+ login_button: "دخول"
16
+ events:
17
+ title: "سجل الزائر الكامل"
18
+ details_title: "الحدث رقم %{id}"
19
+ total: "الإجمالي: %{count}"
20
+ page: "الصفحة %{current} / %{total}"
21
+ view: "عرض"
22
+ back: "العودة إلى الأحداث"
23
+ prev: "السابق"
24
+ next: "التالي"
25
+ empty: "لا توجد أحداث بعد."
26
+ delivery_history: "سجل الإرسال للمنصات"
27
+ columns:
28
+ id: "المعرف"
29
+ event: "الحدث"
30
+ ip: "عنوان IP"
31
+ started_at: "وقت البدء"
32
+ status: "الحالة"
33
+ actions: "إجراءات"
@@ -0,0 +1,33 @@
1
+ en:
2
+ omnitrack:
3
+ ui:
4
+ title: "OmniTrack Admin"
5
+ auth:
6
+ login_required: "Please login as admin first."
7
+ login_success: "Logged in successfully."
8
+ login_failed: "Invalid username or password."
9
+ logout_success: "Logged out successfully."
10
+ logout: "Logout"
11
+ login_title: "Admin Login"
12
+ login_hint: "Use your OmniTrack admin credentials."
13
+ username: "Username"
14
+ password: "Password"
15
+ login_button: "Login"
16
+ events:
17
+ title: "Visitor Event History"
18
+ details_title: "Event #%{id}"
19
+ total: "Total: %{count}"
20
+ page: "Page %{current} / %{total}"
21
+ view: "View"
22
+ back: "Back to events"
23
+ prev: "Previous"
24
+ next: "Next"
25
+ empty: "No events found yet."
26
+ delivery_history: "Delivery History"
27
+ columns:
28
+ id: "ID"
29
+ event: "Event"
30
+ ip: "IP"
31
+ started_at: "Started At"
32
+ status: "Status"
33
+ actions: "Actions"
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ Omnitrack::Engine.routes.draw do
2
+ scope module: :path do
3
+ get "login", to: "sessions#new", as: :login
4
+ post "login", to: "sessions#create", as: :login_submit
5
+ delete "logout", to: "sessions#destroy", as: :logout
6
+ resources :visit_events, path: "events", only: %i[index show]
7
+ end
8
+
9
+ root to: "path/sessions#new"
10
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "rails/generators/migration"
4
5
 
5
6
  module Omnitrack
6
7
  class InstallGenerator < Rails::Generators::Base
8
+ include Rails::Generators::Migration
9
+
7
10
  source_root File.expand_path("templates", __dir__)
8
11
 
9
12
  desc "Creates an Omnitrack initializer and adds it to your application"
@@ -18,12 +21,23 @@ module Omnitrack
18
21
  copy_file "env.example", ".env.example"
19
22
  end
20
23
 
24
+ def create_dashboard_migration
25
+ migration_template "create_omnitrack_dashboard_tables.rb",
26
+ "db/migrate/create_omnitrack_dashboard_tables.rb"
27
+ end
28
+
21
29
  def show_readme
22
30
  readme "README" if behavior == :invoke
23
31
  end
24
32
 
25
33
  private
26
34
 
35
+ def self.next_migration_number(dirname)
36
+ @previous_migration_number ||= current_migration_number(dirname)
37
+ @previous_migration_number = @previous_migration_number.succ
38
+ @previous_migration_number.to_s
39
+ end
40
+
27
41
  def readme(path)
28
42
  say <<~MSG
29
43
 
@@ -35,8 +49,10 @@ module Omnitrack
35
49
  1. Copy .env.example to .env (gitignore .env) and set *_ENABLED + secrets
36
50
  2. Optional: add gem "dotenv-rails" in development to load .env
37
51
  3. Edit config/initializers/omnitrack.rb
38
- 4. Place <%= omnitrack_tags %> in your layout <head> (full-stack)
39
- 5. Call Omnitrack.track(...) from controllers/services (see USAGE.md)
52
+ 4. Run rails db:migrate to create OmniTrack dashboard tables
53
+ 5. Visit /omnitrack/login for the dashboard (or custom dashboard_mount_path)
54
+ 6. Place <%= omnitrack_tags %> in your layout <head> (full-stack)
55
+ 7. Call Omnitrack.track(...) from controllers/services (see USAGE.md)
40
56
 
41
57
  Full docs: README.md — integration checklist: AI_GEM_SETUP.md
42
58
  ============================================================
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOmnitrackDashboardTables < ActiveRecord::Migration[6.0]
4
+ def change
5
+ create_table :omnitrack_visit_events do |t|
6
+ t.string :operation
7
+ t.string :event_name
8
+ t.string :request_path
9
+ t.string :request_method
10
+ t.string :ip
11
+ t.string :user_agent
12
+ t.string :session_id
13
+ t.string :visitor_token
14
+ t.string :locale
15
+ t.json :payload_json
16
+ t.json :context_json
17
+ t.datetime :started_at
18
+ t.datetime :completed_at
19
+ t.string :overall_status
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :omnitrack_visit_events, :started_at
24
+ add_index :omnitrack_visit_events, :visitor_token
25
+ add_index :omnitrack_visit_events, :overall_status
26
+
27
+ create_table :omnitrack_delivery_statuses do |t|
28
+ t.references :visit_event, null: false, foreign_key: { to_table: :omnitrack_visit_events }
29
+ t.string :adapter_name
30
+ t.string :status
31
+ t.string :error_message
32
+ t.json :response_json
33
+ t.json :metadata_json
34
+ t.datetime :sent_at
35
+ t.timestamps
36
+ end
37
+
38
+ add_index :omnitrack_delivery_statuses, :status
39
+ add_index :omnitrack_delivery_statuses, :sent_at
40
+ end
41
+ end
@@ -41,3 +41,9 @@ TIKTOK_ACCESS_TOKEN=
41
41
  SNAPCHAT_ENABLED=false
42
42
  SNAPCHAT_PIXEL_ID=
43
43
  SNAPCHAT_ACCESS_TOKEN=
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # OmniTrack Dashboard
47
+ # ---------------------------------------------------------------------------
48
+ OMNITRACK_ADMIN_USERNAME=admin
49
+ OMNITRACK_ADMIN_PASSWORD=change-me
@@ -109,4 +109,13 @@ Omnitrack.configure do |config|
109
109
  # config.on_error = ->(error, adapter) {
110
110
  # Sentry.capture_exception(error, tags: { adapter: adapter.name })
111
111
  # }
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Admin Dashboard (full visitor history + platform delivery statuses)
115
+ # ---------------------------------------------------------------------------
116
+ config.dashboard_enabled = true
117
+ config.dashboard_per_page = 25
118
+ config.dashboard_mount_path = "/omnitrack"
119
+ config.admin_username = ENV.fetch("OMNITRACK_ADMIN_USERNAME", "admin")
120
+ config.admin_password = ENV.fetch("OMNITRACK_ADMIN_PASSWORD", "change-me")
112
121
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Omnitrack
6
+ module Audit
7
+ class Recorder
8
+ def self.start(operation, args)
9
+ return NullRecorder.new unless available?
10
+
11
+ new(operation, args)
12
+ rescue StandardError
13
+ NullRecorder.new
14
+ end
15
+
16
+ def self.available?
17
+ return false unless defined?(ActiveRecord::Base)
18
+ return false unless Omnitrack.config.dashboard_enabled
19
+
20
+ Omnitrack::VisitEvent.table_exists? && Omnitrack::DeliveryStatus.table_exists?
21
+ end
22
+
23
+ def initialize(operation, args)
24
+ @operation = operation.to_s
25
+ @args = args
26
+ @event = create_event!
27
+ end
28
+
29
+ def finish(result)
30
+ Array(result&.results).each do |adapter_result|
31
+ Omnitrack::DeliveryStatus.create!(
32
+ visit_event_id: @event.id,
33
+ adapter_name: adapter_result.adapter.to_s.presence || "queue",
34
+ status: adapter_result.status.to_s,
35
+ error_message: adapter_result.error&.message,
36
+ response_json: safe_json(adapter_result.data),
37
+ metadata_json: safe_json(adapter_result.metadata),
38
+ sent_at: Time.current
39
+ )
40
+ end
41
+
42
+ @event.update!(
43
+ overall_status: overall_status_from(result),
44
+ completed_at: Time.current
45
+ )
46
+ rescue StandardError
47
+ # Never interrupt app flow because of dashboard persistence.
48
+ end
49
+
50
+ def fail!(error)
51
+ @event&.update!(
52
+ overall_status: "failure",
53
+ completed_at: Time.current,
54
+ context_json: safe_json(base_context.merge(dispatch_error: error.message))
55
+ )
56
+ rescue StandardError
57
+ # no-op
58
+ end
59
+
60
+ private
61
+
62
+ def create_event!
63
+ Omnitrack::VisitEvent.create!(
64
+ operation: @operation,
65
+ event_name: extract_event_name,
66
+ request_path: request_meta[:path],
67
+ request_method: request_meta[:method],
68
+ ip: context_hash["ip"],
69
+ user_agent: context_hash["user_agent"],
70
+ session_id: request_meta[:session_id],
71
+ visitor_token: request_meta[:visitor_token] || SecureRandom.uuid,
72
+ locale: request_meta[:locale],
73
+ payload_json: safe_json(extract_payload),
74
+ context_json: safe_json(base_context),
75
+ started_at: Time.current,
76
+ overall_status: "processing"
77
+ )
78
+ end
79
+
80
+ def request_meta
81
+ @request_meta ||= (context_hash["custom_data"] || {}).transform_keys(&:to_sym)
82
+ end
83
+
84
+ def base_context
85
+ {
86
+ click_ids: context_hash["click_ids"],
87
+ utm_params: context_hash["utm_params"],
88
+ headers: context_hash["headers"],
89
+ custom_data: context_hash["custom_data"]
90
+ }
91
+ end
92
+
93
+ def context_hash
94
+ @context_hash ||= begin
95
+ raw = Omnitrack::Context.current&.to_h || {}
96
+ raw.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
97
+ end
98
+ end
99
+
100
+ def extract_event_name
101
+ return @args.first.to_s if @operation == "track_event"
102
+ return "conversion" if @operation == "track_conversion"
103
+ return "identify" if @operation == "identify_user"
104
+
105
+ @operation
106
+ end
107
+
108
+ def extract_payload
109
+ return @args.second || {} if @operation == "track_event"
110
+
111
+ @args.first || {}
112
+ end
113
+
114
+ def overall_status_from(result)
115
+ return "success" if result.respond_to?(:success?) && result.success?
116
+ return "partial_failure" if result.respond_to?(:any_failure?) && result.any_failure?
117
+
118
+ "queued"
119
+ end
120
+
121
+ def safe_json(value)
122
+ value.to_h
123
+ rescue StandardError
124
+ value
125
+ end
126
+ end
127
+
128
+ class NullRecorder
129
+ def finish(_result); end
130
+
131
+ def fail!(_error); end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hash#compact was added in Ruby 3.1. This gem supports Ruby 2.7+ and runs on
4
+ # Ruby 3.0 with no core Hash#compact, so we backport only when missing.
5
+
6
+ unless {}.respond_to?(:compact)
7
+ class Hash
8
+ def compact
9
+ select { |_, v| !v.nil? }
10
+ end
11
+ end
12
+ end
@@ -45,6 +45,12 @@ module Omnitrack
45
45
  # Hook: called with (event_name, adapter_name, error) on adapter failure
46
46
  attr_accessor :on_error
47
47
 
48
+ # Admin dashboard authentication (used by Omnitrack::Engine)
49
+ attr_accessor :admin_username, :admin_password
50
+
51
+ # Admin dashboard behavior
52
+ attr_accessor :dashboard_enabled, :dashboard_per_page, :dashboard_mount_path
53
+
48
54
  def initialize
49
55
  @mode = :auto
50
56
  @adapters = {}
@@ -57,6 +63,11 @@ module Omnitrack
57
63
  @max_retries = 3
58
64
  @retry_delay = 1
59
65
  @on_error = nil
66
+ @admin_username = ENV.fetch("OMNITRACK_ADMIN_USERNAME", "admin")
67
+ @admin_password = ENV.fetch("OMNITRACK_ADMIN_PASSWORD", "change-me")
68
+ @dashboard_enabled = true
69
+ @dashboard_per_page = 25
70
+ @dashboard_mount_path = "/omnitrack"
60
71
  end
61
72
 
62
73
  def validate!
@@ -70,6 +81,17 @@ module Omnitrack
70
81
  "Invalid log_level: #{@log_level.inspect}. Must be one of #{VALID_LOG_LVLS}"
71
82
  end
72
83
 
84
+ unless @dashboard_per_page.to_i.positive?
85
+ raise Omnitrack::ConfigurationError,
86
+ "dashboard_per_page must be a positive integer"
87
+ end
88
+
89
+ mount_path = @dashboard_mount_path.to_s
90
+ unless mount_path.start_with?("/") && mount_path.length > 1
91
+ raise Omnitrack::ConfigurationError,
92
+ "dashboard_mount_path must start with '/' (example: /omnitrack)"
93
+ end
94
+
73
95
  true
74
96
  end
75
97
 
@@ -54,9 +54,17 @@ module Omnitrack
54
54
  self
55
55
  end
56
56
 
57
- def gclid = click_ids[:gclid]
58
- def fbclid = click_ids[:fbclid]
59
- def ttclid = click_ids[:ttclid]
57
+ def gclid
58
+ click_ids[:gclid]
59
+ end
60
+
61
+ def fbclid
62
+ click_ids[:fbclid]
63
+ end
64
+
65
+ def ttclid
66
+ click_ids[:ttclid]
67
+ end
60
68
 
61
69
  # Full context as a hash — passed into adapter payloads
62
70
  def to_h
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omnitrack
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Omnitrack
6
+
7
+ initializer "omnitrack.load_i18n" do |app|
8
+ app.config.i18n.load_path += Dir[root.join("config", "locales", "*.yml")]
9
+ end
10
+ end
11
+ end
@@ -22,6 +22,13 @@ module Omnitrack
22
22
 
23
23
  if Omnitrack.config.auto_capture
24
24
  ctx = Omnitrack::Context.from_request(request)
25
+ ctx.merge!(
26
+ path: request.path,
27
+ method: request.request_method,
28
+ session_id: request.session&.id.to_s,
29
+ visitor_token: request.cookies["omnitrack_visitor_token"] || request.session&.id.to_s,
30
+ locale: request.params["locale"]
31
+ )
25
32
  Omnitrack::Context.current = ctx
26
33
 
27
34
  Omnitrack.logger.debug("middleware.request",
@@ -45,6 +45,14 @@ module Omnitrack
45
45
  Omnitrack.send(:reset_logger!)
46
46
  end
47
47
 
48
+ initializer "omnitrack.mount_dashboard_routes" do |app|
49
+ next unless Omnitrack.config.dashboard_enabled
50
+
51
+ app.routes.prepend do
52
+ mount Omnitrack::Engine => Omnitrack.config.dashboard_mount_path, as: :omnitrack
53
+ end
54
+ end
55
+
48
56
  # Expose rake tasks
49
57
  rake_tasks do
50
58
  load File.join(__dir__, "tasks/omnitrack.rake")
@@ -38,7 +38,7 @@ module Omnitrack
38
38
  # Instantiate all *enabled* adapters based on current configuration.
39
39
  # @return [Array<Omnitrack::Adapters::Base>]
40
40
  def enabled_adapters
41
- MUTEX.synchronize { @adapters.dup }.filter_map do |adapter_name, klass|
41
+ MUTEX.synchronize { @adapters.dup }.map do |adapter_name, klass|
42
42
  cfg = Omnitrack.config.adapter_config(adapter_name)
43
43
  next unless cfg.fetch(:enabled, false)
44
44
 
@@ -47,7 +47,7 @@ module Omnitrack
47
47
  Omnitrack.logger.error("registry.init_error",
48
48
  adapter: adapter_name, message: e.message)
49
49
  nil
50
- end
50
+ end.compact
51
51
  end
52
52
 
53
53
  # Clear all registrations (useful in tests)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omnitrack
4
- VERSION = "0.1.0"
4
+ VERSION = "3.0.0"
5
5
  end
data/lib/omnitrack.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "omnitrack/compat/hash_compact_backport"
3
4
  require "active_support/concern"
4
5
  require "active_support/core_ext/object/blank"
5
6
  require "active_support/core_ext/string/inflections"
@@ -14,6 +15,7 @@ require_relative "omnitrack/logger"
14
15
  require_relative "omnitrack/context"
15
16
  require_relative "omnitrack/registry"
16
17
  require_relative "omnitrack/pipeline/dispatcher"
18
+ require_relative "omnitrack/audit/recorder"
17
19
  require_relative "omnitrack/adapters/base"
18
20
  require_relative "omnitrack/adapters/google_ads"
19
21
  require_relative "omnitrack/adapters/google_analytics"
@@ -26,6 +28,7 @@ require_relative "omnitrack/concerns/controller"
26
28
 
27
29
  # Lazy-load Rails integrations only when Rails is present
28
30
  if defined?(Rails)
31
+ require_relative "omnitrack/engine"
29
32
  require_relative "omnitrack/railtie"
30
33
  require_relative "generators/omnitrack/install/install_generator"
31
34
  end
@@ -135,18 +138,25 @@ module Omnitrack
135
138
  private
136
139
 
137
140
  def dispatch(operation, *args)
141
+ recorder = Omnitrack::Audit::Recorder.start(operation, args)
142
+
138
143
  if config.async && defined?(Omnitrack::Jobs::TrackingJob)
139
144
  Omnitrack::Jobs::TrackingJob.perform_later(operation.to_s, *args)
140
- return Omnitrack::MultiResult.new([
145
+ result = Omnitrack::MultiResult.new([
141
146
  Omnitrack::Result.success(metadata: { queued: true, operation: operation })
142
147
  ])
148
+ recorder.finish(result)
149
+ return result
143
150
  end
144
151
 
145
- Omnitrack::Pipeline::Dispatcher.dispatch(operation, *args)
152
+ result = Omnitrack::Pipeline::Dispatcher.dispatch(operation, *args)
153
+ recorder.finish(result)
154
+ result
146
155
  rescue StandardError => e
147
156
  # Top-level safety net — never raise into the host application
148
157
  logger.error("omnitrack.dispatch_error",
149
158
  operation: operation, error: e.class.name, message: e.message)
159
+ recorder&.fail!(e)
150
160
  Omnitrack::MultiResult.new([
151
161
  Omnitrack::Result.failure(error: e)
152
162
  ])
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnitrack-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Your Name
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-26 00:00:00.000000000 Z
11
+ date: 2026-04-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -153,8 +153,28 @@ files:
153
153
  - LICENSE.txt
154
154
  - README.md
155
155
  - USAGE.md
156
+ - app/controllers/omnitrack/application_controller.rb
157
+ - app/controllers/omnitrack/path/application_controller.rb
158
+ - app/controllers/omnitrack/path/sessions_controller.rb
159
+ - app/controllers/omnitrack/path/visit_events_controller.rb
160
+ - app/controllers/omnitrack/sessions_controller.rb
161
+ - app/controllers/omnitrack/visit_events_controller.rb
162
+ - app/models/omnitrack/application_record.rb
163
+ - app/models/omnitrack/delivery_status.rb
164
+ - app/models/omnitrack/visit_event.rb
165
+ - app/views/layouts/omnitrack/admin.html.erb
166
+ - app/views/omnitrack/path/sessions/new.html.erb
167
+ - app/views/omnitrack/path/visit_events/index.html.erb
168
+ - app/views/omnitrack/path/visit_events/show.html.erb
169
+ - app/views/omnitrack/sessions/new.html.erb
170
+ - app/views/omnitrack/visit_events/index.html.erb
171
+ - app/views/omnitrack/visit_events/show.html.erb
172
+ - config/locales/ar.yml
173
+ - config/locales/en.yml
174
+ - config/routes.rb
156
175
  - lib/generators/omnitrack/install/install_generator.rb
157
176
  - lib/generators/omnitrack/install/templates/README
177
+ - lib/generators/omnitrack/install/templates/create_omnitrack_dashboard_tables.rb
158
178
  - lib/generators/omnitrack/install/templates/env.example
159
179
  - lib/generators/omnitrack/install/templates/initializer.rb
160
180
  - lib/omnitrack.rb
@@ -164,9 +184,12 @@ files:
164
184
  - lib/omnitrack/adapters/meta.rb
165
185
  - lib/omnitrack/adapters/snapchat.rb
166
186
  - lib/omnitrack/adapters/tiktok.rb
187
+ - lib/omnitrack/audit/recorder.rb
188
+ - lib/omnitrack/compat/hash_compact_backport.rb
167
189
  - lib/omnitrack/concerns/controller.rb
168
190
  - lib/omnitrack/configuration.rb
169
191
  - lib/omnitrack/context.rb
192
+ - lib/omnitrack/engine.rb
170
193
  - lib/omnitrack/errors.rb
171
194
  - lib/omnitrack/helpers/view_helpers.rb
172
195
  - lib/omnitrack/jobs/tracking_job.rb