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.
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