omnitrack-rb 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/.env.example +43 -0
- data/AI_GEM_SETUP.md +164 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +364 -0
- data/USAGE.md +276 -0
- data/lib/generators/omnitrack/install/install_generator.rb +47 -0
- data/lib/generators/omnitrack/install/templates/README +19 -0
- data/lib/generators/omnitrack/install/templates/env.example +43 -0
- data/lib/generators/omnitrack/install/templates/initializer.rb +112 -0
- data/lib/omnitrack/adapters/base.rb +197 -0
- data/lib/omnitrack/adapters/google_ads.rb +172 -0
- data/lib/omnitrack/adapters/google_analytics.rb +97 -0
- data/lib/omnitrack/adapters/meta.rb +151 -0
- data/lib/omnitrack/adapters/snapchat.rb +100 -0
- data/lib/omnitrack/adapters/tiktok.rb +114 -0
- data/lib/omnitrack/concerns/controller.rb +66 -0
- data/lib/omnitrack/configuration.rb +109 -0
- data/lib/omnitrack/context.rb +119 -0
- data/lib/omnitrack/errors.rb +26 -0
- data/lib/omnitrack/helpers/view_helpers.rb +148 -0
- data/lib/omnitrack/jobs/tracking_job.rb +27 -0
- data/lib/omnitrack/logger.rb +122 -0
- data/lib/omnitrack/middleware/request_tracker.rb +40 -0
- data/lib/omnitrack/pipeline/dispatcher.rb +56 -0
- data/lib/omnitrack/railtie.rb +53 -0
- data/lib/omnitrack/registry.rb +59 -0
- data/lib/omnitrack/result.rb +113 -0
- data/lib/omnitrack/tasks/omnitrack.rake +39 -0
- data/lib/omnitrack/version.rb +5 -0
- data/lib/omnitrack.rb +166 -0
- metadata +207 -0
data/USAGE.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# OmniTrack — where to put code and which methods to use
|
|
2
|
+
|
|
3
|
+
This guide is a **placement map** for a Rails app: which files to touch, and how the public API fits together. For platform-specific credentials, see the main [README.md](README.md).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. What runs automatically (no code from you)
|
|
8
|
+
|
|
9
|
+
| Piece | What it does |
|
|
10
|
+
|--------|----------------|
|
|
11
|
+
| **Railtie** | Loads the gem with Rails, registers adapters, and wires the parts below. |
|
|
12
|
+
| **Rack middleware** `Omnitrack::Middleware::RequestTracker` | On each request, builds `Omnitrack::Context` from the incoming request, stores it in **thread-local** storage for that request, then clears it after the response. |
|
|
13
|
+
| **Context capture** (when `config.auto_capture = true`) | Reads query params and common headers for `gclid`, `fbclid`, `ttclid`, `sclid`, `msclkid`, UTM fields, `User-Agent`, client IP, and (full-stack) cookies. |
|
|
14
|
+
| **Controller helpers** | The concern `Omnitrack::Controller` is **included for you** in `ActionController` via the Railtie. You get `omnitrack_context`, `omnitrack_event`, and similar unless you opt out. |
|
|
15
|
+
|
|
16
|
+
You do **not** manually “start the tracker” in `ApplicationController` for basic use — middleware + initializer are enough.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. Where you add configuration (once per app)
|
|
21
|
+
|
|
22
|
+
| File | Purpose |
|
|
23
|
+
|------|--------|
|
|
24
|
+
| `config/initializers/omnitrack.rb` | **Primary place.** `Omnitrack.configure do |config| … end` — adapters, `mode`, `async`, `log_level`, `auto_capture`, retries, `on_error`, etc. |
|
|
25
|
+
| `Gemfile` | `gem "omnitrack-rb"` |
|
|
26
|
+
| **Environment** | ENV vars (or `Rails.application.credentials`) referenced from the initializer — no secret keys in the repo. |
|
|
27
|
+
|
|
28
|
+
Run once:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
rails generate omnitrack:install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That copies a starter `config/initializers/omnitrack.rb` you should edit for your stack.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 3. Server-side API — three entry points (use anywhere in the app)
|
|
39
|
+
|
|
40
|
+
These are the main methods. They fan out to **each enabled adapter** in `config.adapters` and return an `Omnitrack::MultiResult` (never raise into your app in normal use).
|
|
41
|
+
|
|
42
|
+
| Method | Use when | Typical payload |
|
|
43
|
+
|--------|-----------|-----------------|
|
|
44
|
+
| `Omnitrack.track(event_name, **payload_hash)` | Any named event (e.g. `"purchase"`, `"lead"`, `"add_to_cart"`). | `value`, `currency`, `gclid`, `fbclid`, `order_id`, `email`, etc. (see main README) |
|
|
45
|
+
| `Omnitrack.track_conversion(data_hash)` | You want a **conversion**-shaped call (adapters map it; often same as a purchase/lead). | Same hash style as a conversion: `value`, `currency`, `order_id`, click IDs, etc. |
|
|
46
|
+
| `Omnitrack.identify(user_hash)` | Associate PII to the current journey before/after events (hashed by adapters that need it). | `email`, `phone`, `first_name`, `last_name`, `external_id` |
|
|
47
|
+
|
|
48
|
+
**Where to call them**
|
|
49
|
+
|
|
50
|
+
| Location | When to use |
|
|
51
|
+
|----------|-------------|
|
|
52
|
+
| **Controllers** | Right after a successful action: order created, form submitted, signup completed. |
|
|
53
|
+
| **Service objects / interactors** | Preferred for fat domain logic: call `Omnitrack.track(...)` from your service after the business work succeeds. |
|
|
54
|
+
| **ActiveJob** | Work already off the main thread: enqueue a job that calls `Omnitrack.track` (or set `config.async` so the gem enqueues for you; see [README](README.md#async--background-jobs)). |
|
|
55
|
+
| **Models** | Possible, but most teams avoid this; prefer services or callbacks that delegate to a small tracker service. |
|
|
56
|
+
|
|
57
|
+
**API-only / mobile clients:** your API never needs cookies for the server-side APIs. Pass `gclid` / `fbclid` / `ttclid` in the JSON body or as headers; merge them into the hash you pass to `Omnitrack.track`. The middleware will still set context from params when present.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 4. Controller convenience methods (same as the API, with context merged)
|
|
62
|
+
|
|
63
|
+
Included via `Omnitrack::Controller` (or `include Omnitrack::Controller` in a base class if you disabled auto-include).
|
|
64
|
+
|
|
65
|
+
| Method | Maps to | What it does extra |
|
|
66
|
+
|--------|---------|--------------------|
|
|
67
|
+
| `omnitrack_context` | — | Returns `Omnitrack::Context.current` (or builds from `request` if needed). |
|
|
68
|
+
| `omnitrack_event("name", payload)` | `Omnitrack.track` | Merges `__context__` (IP, click IDs, etc.) so adapters see the full request picture. |
|
|
69
|
+
| `omnitrack_conversion(data)` | `Omnitrack.track_conversion` | Same merge as above. |
|
|
70
|
+
| `omnitrack_identify(user_data)` | `Omnitrack.identify` | Same merge pattern. |
|
|
71
|
+
| `omnitrack_set(extra_hash)` | — | Merges custom keys into the current context for the **rest of this request** (not across requests). |
|
|
72
|
+
|
|
73
|
+
**Where to put them:** any `ActionController` action after a success path, e.g. `app/controllers/orders_controller.rb`, `app/controllers/api/v1/orders_controller.rb`.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 5. View helpers (full-stack, browser + pixels)
|
|
78
|
+
|
|
79
|
+
Only relevant when the app is **not** API-only in practice and `Omnitrack.frontend_mode?` is true (see `config.mode` and `effective_mode` in the main README).
|
|
80
|
+
|
|
81
|
+
| Helper | Where to put it | Role |
|
|
82
|
+
|--------|------------------|------|
|
|
83
|
+
| `omnitrack_tags` | Layout `<head>`, e.g. `app/views/layouts/application.html.erb` | Injects base scripts / pixels (GA4, Meta, TikTok, Snapchat) for enabled adapters. |
|
|
84
|
+
| `omnitrack_event_tag("event", **options)` | Specific pages, e.g. order confirmation / thank-you view | Pushes a client-side event to the injected stacks. |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 6. How “the tracker” ties together (mental model)
|
|
89
|
+
|
|
90
|
+
1. **Request comes in** → middleware builds **one `Context` per request** (thread-local).
|
|
91
|
+
2. **Your code** calls `Omnitrack.track` (or the controller helpers) with business fields + optional click IDs.
|
|
92
|
+
3. **Dispatcher** runs each **enabled** adapter in turn; one adapter failing does not break the others.
|
|
93
|
+
4. **Logger** (if not `:none`) writes JSON lines to **`log/omnitrack.log`** (or `config.log_file`).
|
|
94
|
+
5. If **`config.async` is true**, the public methods enqueue `Omnitrack::Jobs::TrackingJob` instead of calling adapters inline (when ActiveJob and the job class are loaded).
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
Request → Middleware (Context) → Your controller/service → Omnitrack.track
|
|
98
|
+
→ Adapters (Google, Meta, …)
|
|
99
|
+
→ log/omnitrack.log
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 7. Quick copy-paste checklist
|
|
105
|
+
|
|
106
|
+
- [ ] `config/initializers/omnitrack.rb` — set `config.adapters` and credentials.
|
|
107
|
+
- [ ] Full-stack: `<%= omnitrack_tags %>` in the main layout head.
|
|
108
|
+
- [ ] On conversion: `Omnitrack.track("purchase", ...)` or `omnitrack_event("purchase", ...)` in the controller, or a service you call from the controller.
|
|
109
|
+
- [ ] API: pass click IDs in params/body; keep using `Omnitrack.track` the same way.
|
|
110
|
+
- [ ] Production: configure `config.async` and an ActiveJob queue if you need non-blocking tracking.
|
|
111
|
+
- [ ] Set ENV from [`.env.example`](.env.example) (see [AI_GEM_SETUP.md](AI_GEM_SETUP.md) for `dotenv-rails` and production).
|
|
112
|
+
|
|
113
|
+
For errors, custom adapters, and Rake tasks, see [README.md](README.md).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 8. Concrete examples — which file, where the call goes
|
|
118
|
+
|
|
119
|
+
### A. Full-stack: layout (pixels / gtag, once per app)
|
|
120
|
+
|
|
121
|
+
**File:** `app/views/layouts/application.html.erb` (or your main HTML layout) — inside `<head>`:
|
|
122
|
+
|
|
123
|
+
```erb
|
|
124
|
+
<!DOCTYPE html>
|
|
125
|
+
<html>
|
|
126
|
+
<head>
|
|
127
|
+
<title>My App</title>
|
|
128
|
+
<%= csrf_meta_tags %>
|
|
129
|
+
<%= csp_meta_tag if respond_to?(:csp_meta_tag) %>
|
|
130
|
+
<%= omnitrack_tags %>
|
|
131
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<%= yield %>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
If you use a different layout (e.g. `layouts/shop.html.erb`), put `<%= omnitrack_tags %>` in that file’s `<head>` instead.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### B. Controller: purchase / conversion (server-side, same request as the redirect or JSON)
|
|
144
|
+
|
|
145
|
+
**File:** `app/controllers/orders_controller.rb`
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# frozen_string_literal: true
|
|
149
|
+
|
|
150
|
+
class OrdersController < ApplicationController
|
|
151
|
+
def create
|
|
152
|
+
@order = Order.create!(order_params)
|
|
153
|
+
|
|
154
|
+
# Context (gclid, IP, etc.) is merged for you; safe if an adapter is disabled
|
|
155
|
+
omnitrack_event("purchase",
|
|
156
|
+
value: @order.total,
|
|
157
|
+
currency: @order.currency,
|
|
158
|
+
order_id: @order.id,
|
|
159
|
+
gclid: params[:gclid], # optional if not already on the query string
|
|
160
|
+
email: current_user.email)
|
|
161
|
+
|
|
162
|
+
redirect_to @order
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def order_params
|
|
168
|
+
params.require(:order).permit(:total, :currency)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Equivalent using the module API directly (no `omnitrack_*` merge — pass what you need):
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Omnitrack.track("purchase",
|
|
177
|
+
value: @order.total,
|
|
178
|
+
currency: @order.currency,
|
|
179
|
+
order_id: @order.id)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### C. API-only JSON controller (no layout tags)
|
|
185
|
+
|
|
186
|
+
**File:** `app/controllers/api/v1/orders_controller.rb`
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# frozen_string_literal: true
|
|
190
|
+
|
|
191
|
+
module Api
|
|
192
|
+
module V1
|
|
193
|
+
class OrdersController < ApplicationController
|
|
194
|
+
def create
|
|
195
|
+
@order = Order.create!(order_params)
|
|
196
|
+
|
|
197
|
+
Omnitrack.track("purchase",
|
|
198
|
+
value: @order.total,
|
|
199
|
+
currency: @order.currency,
|
|
200
|
+
order_id: @order.id,
|
|
201
|
+
gclid: order_params[:gclid],
|
|
202
|
+
fbclid: order_params[:fbclid],
|
|
203
|
+
ttclid: order_params[:ttclid])
|
|
204
|
+
|
|
205
|
+
render json: @order, status: :created
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def order_params
|
|
211
|
+
params.require(:order).permit(:total, :currency, :gclid, :fbclid, :ttclid)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
### D. Service object (keeps controllers thin) — **recommended** for real apps
|
|
221
|
+
|
|
222
|
+
**File:** `app/services/marketing/record_purchase.rb` (create the `marketing` directory if it does not exist)
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
# frozen_string_literal: true
|
|
226
|
+
|
|
227
|
+
module Marketing
|
|
228
|
+
class RecordPurchase
|
|
229
|
+
def self.call(order:)
|
|
230
|
+
new(order: order).call
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def initialize(order:)
|
|
234
|
+
@order = order
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def call
|
|
238
|
+
Omnitrack.track("purchase",
|
|
239
|
+
value: @order.total,
|
|
240
|
+
currency: @order.currency,
|
|
241
|
+
order_id: @order.id,
|
|
242
|
+
email: @order.user_email)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**File:** `app/controllers/orders_controller.rb` (one line in `create` after the order is saved)
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
Marketing::RecordPurchase.call(order: @order)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Autoload: Rails 6+ with `app/services` — ensure `config.autoload_paths` includes `app/services` if you use a custom structure (default Zeitwerk will load `app/services/**/*.rb`).
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
### E. View: client-side event on a thank-you page (full-stack)
|
|
259
|
+
|
|
260
|
+
**File:** `app/views/orders/show.html.erb` (or your confirmation view)
|
|
261
|
+
|
|
262
|
+
```erb
|
|
263
|
+
<% if @order.paid? %>
|
|
264
|
+
<%= omnitrack_event_tag("Purchase", value: @order.total, currency: @order.currency) %>
|
|
265
|
+
<% end %>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
### F. “Where not to” put tracking
|
|
271
|
+
|
|
272
|
+
| Avoid | Prefer |
|
|
273
|
+
|--------|--------|
|
|
274
|
+
| `config/application.rb` (manual `Omnitrack.configure` outside an initializer) | `config/initializers/omnitrack.rb` only |
|
|
275
|
+
| `before_action` for every request without a reason | Call after a **successful** business action (order paid, form submitted) |
|
|
276
|
+
| Hard-coded tokens in the repo | `ENV` (see [`.env.example`](.env.example) + [AI_GEM_SETUP.md](AI_GEM_SETUP.md)) |
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Omnitrack
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
desc "Creates an Omnitrack initializer and adds it to your application"
|
|
10
|
+
|
|
11
|
+
def create_initializer_file
|
|
12
|
+
template "initializer.rb", "config/initializers/omnitrack.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_env_example
|
|
16
|
+
return if File.exist?(File.join(destination_root, ".env.example"))
|
|
17
|
+
|
|
18
|
+
copy_file "env.example", ".env.example"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show_readme
|
|
22
|
+
readme "README" if behavior == :invoke
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def readme(path)
|
|
28
|
+
say <<~MSG
|
|
29
|
+
|
|
30
|
+
============================================================
|
|
31
|
+
OmniTrack installed successfully! 🎯
|
|
32
|
+
============================================================
|
|
33
|
+
|
|
34
|
+
Next steps:
|
|
35
|
+
1. Copy .env.example to .env (gitignore .env) and set *_ENABLED + secrets
|
|
36
|
+
2. Optional: add gem "dotenv-rails" in development to load .env
|
|
37
|
+
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)
|
|
40
|
+
|
|
41
|
+
Full docs: README.md — integration checklist: AI_GEM_SETUP.md
|
|
42
|
+
============================================================
|
|
43
|
+
|
|
44
|
+
MSG
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
================================================================================
|
|
2
|
+
OmniTrack
|
|
3
|
+
================================================================================
|
|
4
|
+
|
|
5
|
+
The installer created:
|
|
6
|
+
|
|
7
|
+
config/initializers/omnitrack.rb
|
|
8
|
+
|
|
9
|
+
Next steps:
|
|
10
|
+
|
|
11
|
+
1. Set platform credentials (ENV, credentials, or inline in the initializer)
|
|
12
|
+
2. Full-stack apps: add <%= omnitrack_tags %> to your layout <head>
|
|
13
|
+
3. From controllers: use omnitrack_event / omnitrack_conversion / omnitrack_identify
|
|
14
|
+
or call Omnitrack.track(...) from services
|
|
15
|
+
4. Optional: set config.async in production to enqueue tracking (ActiveJob)
|
|
16
|
+
|
|
17
|
+
Full documentation: see the omnitrack-rb README in the gem or your copy in the project.
|
|
18
|
+
|
|
19
|
+
================================================================================
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# OmniTrack (omnitrack-rb) — copy to .env in your Rails app and fill real values.
|
|
2
|
+
# Never commit .env. Use "cp .env.example .env" and enable only what you need.
|
|
3
|
+
#
|
|
4
|
+
# Enable flags: set to the string "true" to turn an adapter on (see config/initializers/omnitrack.rb).
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Google Ads (server: Click Conversion Upload API)
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
GOOGLE_ADS_ENABLED=false
|
|
10
|
+
GOOGLE_ADS_CUSTOMER_ID=
|
|
11
|
+
GOOGLE_ADS_DEVELOPER_TOKEN=
|
|
12
|
+
GOOGLE_ADS_ACCESS_TOKEN=
|
|
13
|
+
GOOGLE_ADS_CONVERSION_ACTION_ID=
|
|
14
|
+
# GOOGLE_ADS_LOGIN_CUSTOMER_ID= # MCC / manager account only
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Google Analytics 4 (server: Measurement Protocol; browser: gtag in layout)
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
GA4_ENABLED=false
|
|
20
|
+
GA4_MEASUREMENT_ID=
|
|
21
|
+
GA4_API_SECRET=
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Meta (Facebook / Instagram) Conversions API + pixel
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
META_ENABLED=false
|
|
27
|
+
META_PIXEL_ID=
|
|
28
|
+
META_ACCESS_TOKEN=
|
|
29
|
+
# META_TEST_EVENT_CODE= # optional: Events Manager test tool
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# TikTok Ads
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
TIKTOK_ENABLED=false
|
|
35
|
+
TIKTOK_PIXEL_ID=
|
|
36
|
+
TIKTOK_ACCESS_TOKEN=
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Snapchat Ads
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
SNAPCHAT_ENABLED=false
|
|
42
|
+
SNAPCHAT_PIXEL_ID=
|
|
43
|
+
SNAPCHAT_ACCESS_TOKEN=
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
# OmniTrack Configuration
|
|
5
|
+
# Generated by: rails generate omnitrack:install
|
|
6
|
+
# Docs: https://github.com/yourorg/omnitrack-rb
|
|
7
|
+
# ==============================================================================
|
|
8
|
+
|
|
9
|
+
Omnitrack.configure do |config|
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Mode
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# :auto — auto-detect (API-only Rails → :backend, otherwise :frontend)
|
|
14
|
+
# :frontend — inject JS tags, use cookies/session
|
|
15
|
+
# :backend — server-side only (Conversions API / Measurement Protocol)
|
|
16
|
+
# :hybrid — both simultaneously (recommended for full-stack apps)
|
|
17
|
+
config.mode = :auto
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Adapter Configuration
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Set enabled: true only for platforms you actively use.
|
|
23
|
+
# Missing credentials will be logged as warnings, not exceptions.
|
|
24
|
+
|
|
25
|
+
config.adapters = {
|
|
26
|
+
# ------ Google Ads -------------------------------------------------------
|
|
27
|
+
google_ads: {
|
|
28
|
+
enabled: ENV["GOOGLE_ADS_ENABLED"] == "true",
|
|
29
|
+
customer_id: ENV["GOOGLE_ADS_CUSTOMER_ID"],
|
|
30
|
+
developer_token: ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
|
|
31
|
+
access_token: ENV["GOOGLE_ADS_ACCESS_TOKEN"],
|
|
32
|
+
conversion_action_id: ENV["GOOGLE_ADS_CONVERSION_ACTION_ID"]
|
|
33
|
+
# login_customer_id: ENV["GOOGLE_ADS_LOGIN_CUSTOMER_ID"] # MCC accounts only
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
# ------ Google Analytics 4 -----------------------------------------------
|
|
37
|
+
google_analytics: {
|
|
38
|
+
enabled: ENV["GA4_ENABLED"] == "true",
|
|
39
|
+
measurement_id: ENV["GA4_MEASUREMENT_ID"], # e.g. G-XXXXXXXXXX
|
|
40
|
+
api_secret: ENV["GA4_API_SECRET"]
|
|
41
|
+
# debug_mode: true # Uncomment to use the GA4 validation endpoint
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
# ------ Meta (Facebook / Instagram) --------------------------------------
|
|
45
|
+
meta: {
|
|
46
|
+
enabled: ENV["META_ENABLED"] == "true",
|
|
47
|
+
pixel_id: ENV["META_PIXEL_ID"],
|
|
48
|
+
access_token: ENV["META_ACCESS_TOKEN"]
|
|
49
|
+
# test_event_code: ENV["META_TEST_EVENT_CODE"] # Events Manager test tool
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
# ------ TikTok Ads -------------------------------------------------------
|
|
53
|
+
tiktok: {
|
|
54
|
+
enabled: ENV["TIKTOK_ENABLED"] == "true",
|
|
55
|
+
pixel_id: ENV["TIKTOK_PIXEL_ID"],
|
|
56
|
+
access_token: ENV["TIKTOK_ACCESS_TOKEN"]
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
# ------ Snapchat Ads -----------------------------------------------------
|
|
60
|
+
snapchat: {
|
|
61
|
+
enabled: ENV["SNAPCHAT_ENABLED"] == "true",
|
|
62
|
+
pixel_id: ENV["SNAPCHAT_PIXEL_ID"],
|
|
63
|
+
access_token: ENV["SNAPCHAT_ACCESS_TOKEN"]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# ------ Custom adapter example -------------------------------------------
|
|
67
|
+
# my_platform: {
|
|
68
|
+
# enabled: true,
|
|
69
|
+
# api_key: ENV["MY_PLATFORM_API_KEY"]
|
|
70
|
+
# }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Data Capture
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Automatically capture gclid, fbclid, ttclid, sclid, UTMs from every request
|
|
77
|
+
config.auto_capture = true
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Logging
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Writes structured JSON to log/omnitrack.log (separate from Rails main log)
|
|
83
|
+
config.log_level = Rails.env.production? ? :info : :debug
|
|
84
|
+
|
|
85
|
+
# Uncomment to write to a custom path:
|
|
86
|
+
# config.log_file = Rails.root.join("log", "omnitrack.log").to_s
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Async / Background Jobs
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# When true, tracking calls are dispatched via ActiveJob (non-blocking).
|
|
92
|
+
# Requires ActiveJob to be configured with a real queue adapter in production.
|
|
93
|
+
config.async = Rails.env.production?
|
|
94
|
+
config.queue_name = :omnitrack
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Reliability
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
config.timeout = 5 # seconds
|
|
100
|
+
config.max_retries = 3
|
|
101
|
+
config.retry_delay = 1 # seconds (exponential back-off applied automatically)
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Error hook (optional)
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Called with (error, adapter_instance) on every adapter failure.
|
|
107
|
+
# Useful for routing errors to Sentry, Honeybadger, etc.
|
|
108
|
+
#
|
|
109
|
+
# config.on_error = ->(error, adapter) {
|
|
110
|
+
# Sentry.capture_exception(error, tags: { adapter: adapter.name })
|
|
111
|
+
# }
|
|
112
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Omnitrack
|
|
8
|
+
module Adapters
|
|
9
|
+
# All platform adapters inherit from this class.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses MUST implement:
|
|
12
|
+
# - #track_event(event_name, payload)
|
|
13
|
+
# - #track_conversion(data)
|
|
14
|
+
# - #identify_user(user_data)
|
|
15
|
+
#
|
|
16
|
+
# Subclasses MAY override:
|
|
17
|
+
# - #enabled? — to add extra guards
|
|
18
|
+
# - #validate_config — to check required keys
|
|
19
|
+
#
|
|
20
|
+
class Base
|
|
21
|
+
# Name used in logs, config lookup, and error messages.
|
|
22
|
+
# Override in subclass: self.adapter_name = :google_ads
|
|
23
|
+
class << self
|
|
24
|
+
attr_writer :adapter_name
|
|
25
|
+
|
|
26
|
+
def adapter_name
|
|
27
|
+
@adapter_name || name.to_s.split("::").last.underscore.to_sym
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Convenience: register a custom adapter with the dispatcher
|
|
31
|
+
def inherited(subclass)
|
|
32
|
+
super
|
|
33
|
+
Omnitrack::Registry.register(subclass) if defined?(Omnitrack::Registry)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :config, :logger
|
|
38
|
+
|
|
39
|
+
def initialize(config: {}, logger: nil)
|
|
40
|
+
@config = config.transform_keys(&:to_sym)
|
|
41
|
+
@logger = logger || Omnitrack.logger
|
|
42
|
+
validate_config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# -------------------------------------------------------------------
|
|
46
|
+
# Interface — subclasses MUST implement these
|
|
47
|
+
# -------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# Track a named event.
|
|
50
|
+
# @param event_name [String, Symbol]
|
|
51
|
+
# @param payload [Hash] event-specific data
|
|
52
|
+
# @return [Omnitrack::Result]
|
|
53
|
+
def track_event(event_name, payload = {})
|
|
54
|
+
raise NotImplementedError, "#{self.class}#track_event not implemented"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Track a conversion (purchase, lead, etc.).
|
|
58
|
+
# @param data [Hash]
|
|
59
|
+
# @return [Omnitrack::Result]
|
|
60
|
+
def track_conversion(data = {})
|
|
61
|
+
raise NotImplementedError, "#{self.class}#track_conversion not implemented"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Identify / associate a user.
|
|
65
|
+
# @param user_data [Hash] may include :email, :phone, :external_id
|
|
66
|
+
# @return [Omnitrack::Result]
|
|
67
|
+
def identify_user(user_data = {})
|
|
68
|
+
raise NotImplementedError, "#{self.class}#identify_user not implemented"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# -------------------------------------------------------------------
|
|
72
|
+
# Helpers available to subclasses
|
|
73
|
+
# -------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def enabled?
|
|
76
|
+
config.fetch(:enabled, false)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def name
|
|
80
|
+
self.class.adapter_name
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
# Validate required config keys. Override to add adapter-specific checks.
|
|
86
|
+
def validate_config
|
|
87
|
+
# No-op in base; subclasses add their own required-key checks
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Assert that config contains a required key (raises ConfigurationError otherwise)
|
|
91
|
+
def require_config!(key, hint: nil)
|
|
92
|
+
return if config[key.to_sym].present? || (config[key.to_sym].is_a?(String) && !config[key.to_sym].empty?)
|
|
93
|
+
|
|
94
|
+
msg = "Omnitrack::Adapters::#{self.class.adapter_name} requires config[:#{key}]"
|
|
95
|
+
msg += " — #{hint}" if hint
|
|
96
|
+
raise Omnitrack::ConfigurationError, msg
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Safely execute a block, logging errors and returning an error Result
|
|
100
|
+
def safe_execute(event_name, &block)
|
|
101
|
+
logger.log_request(adapter: name, event: event_name)
|
|
102
|
+
started = monotonic_now
|
|
103
|
+
|
|
104
|
+
result = with_retries { block.call }
|
|
105
|
+
|
|
106
|
+
duration = elapsed_ms(started)
|
|
107
|
+
logger.log_response(adapter: name, event: event_name,
|
|
108
|
+
status: result.status, duration_ms: duration)
|
|
109
|
+
result
|
|
110
|
+
rescue Omnitrack::AdapterError => e
|
|
111
|
+
logger.log_error(adapter: name, event: event_name, error: e)
|
|
112
|
+
notify_error(e)
|
|
113
|
+
Omnitrack::Result.failure(error: e, adapter: name)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
wrapped = Omnitrack::AdapterError.new(e.message, original: e)
|
|
116
|
+
logger.log_error(adapter: name, event: event_name, error: wrapped)
|
|
117
|
+
notify_error(wrapped)
|
|
118
|
+
Omnitrack::Result.failure(error: wrapped, adapter: name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# HTTP helper — performs a JSON POST with timeout + error wrapping
|
|
122
|
+
def http_post(url, body:, headers: {})
|
|
123
|
+
uri = URI.parse(url)
|
|
124
|
+
timeout = Omnitrack.config.timeout
|
|
125
|
+
use_ssl = uri.scheme == "https"
|
|
126
|
+
|
|
127
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl,
|
|
128
|
+
open_timeout: timeout,
|
|
129
|
+
read_timeout: timeout) do |http|
|
|
130
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
131
|
+
req["Content-Type"] = "application/json"
|
|
132
|
+
req["Accept"] = "application/json"
|
|
133
|
+
headers.each { |k, v| req[k.to_s] = v }
|
|
134
|
+
req.body = JSON.generate(body)
|
|
135
|
+
|
|
136
|
+
response = http.request(req)
|
|
137
|
+
|
|
138
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
139
|
+
raise Omnitrack::AdapterError,
|
|
140
|
+
"HTTP #{response.code} from #{uri.host}: #{response.body.to_s[0, 200]}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
response
|
|
144
|
+
end
|
|
145
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
146
|
+
raise Omnitrack::AdapterError, "Timeout calling #{url}: #{e.message}"
|
|
147
|
+
rescue Omnitrack::AdapterError
|
|
148
|
+
raise
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
raise Omnitrack::AdapterError, "Network error calling #{url}: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# SHA-256 hash helper (for PII normalization required by many platforms)
|
|
154
|
+
def sha256(value)
|
|
155
|
+
require "digest"
|
|
156
|
+
Digest::SHA256.hexdigest(value.to_s.strip.downcase)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def with_retries(&block)
|
|
162
|
+
max = Omnitrack.config.max_retries.to_i
|
|
163
|
+
delay = Omnitrack.config.retry_delay.to_f
|
|
164
|
+
attempt = 0
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
attempt += 1
|
|
168
|
+
block.call
|
|
169
|
+
rescue Omnitrack::AdapterError => e
|
|
170
|
+
if attempt <= max
|
|
171
|
+
logger.warn("adapter.retry",
|
|
172
|
+
adapter: name, attempt: attempt,
|
|
173
|
+
error: e.message, retry_in_s: delay * (2**(attempt - 1)))
|
|
174
|
+
sleep(delay * (2**(attempt - 1)))
|
|
175
|
+
retry
|
|
176
|
+
end
|
|
177
|
+
raise
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def notify_error(error)
|
|
182
|
+
cb = Omnitrack.config.on_error
|
|
183
|
+
cb.call(error, self) if cb.respond_to?(:call)
|
|
184
|
+
rescue StandardError
|
|
185
|
+
# Swallow errors in the error callback — never blow up
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def monotonic_now
|
|
189
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def elapsed_ms(started)
|
|
193
|
+
((monotonic_now - started) * 1000).round(2)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|