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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
# Central configuration object. Use Omnitrack.configure { |c| ... } in an initializer.
|
|
5
|
+
class Configuration
|
|
6
|
+
VALID_MODES = %i[auto frontend backend hybrid].freeze
|
|
7
|
+
VALID_LOG_LVLS = %i[debug info warn error none].freeze
|
|
8
|
+
|
|
9
|
+
# :auto — gem detects API-only vs full-stack at runtime
|
|
10
|
+
# :frontend — always inject JS helpers / use cookies
|
|
11
|
+
# :backend — server-side only; no JS
|
|
12
|
+
# :hybrid — both simultaneously
|
|
13
|
+
attr_accessor :mode
|
|
14
|
+
|
|
15
|
+
# Hash keyed by adapter symbol. Each value is a sub-hash of adapter options.
|
|
16
|
+
# Example:
|
|
17
|
+
# { google_ads: { enabled: true, customer_id: "123" },
|
|
18
|
+
# meta: { enabled: true, pixel_id: "456" } }
|
|
19
|
+
attr_accessor :adapters
|
|
20
|
+
|
|
21
|
+
# Automatically capture gclid / fbclid / ttclid from incoming requests
|
|
22
|
+
attr_accessor :auto_capture
|
|
23
|
+
|
|
24
|
+
# :debug | :info | :warn | :error | :none
|
|
25
|
+
attr_accessor :log_level
|
|
26
|
+
|
|
27
|
+
# Path to dedicated log file. Defaults to Rails.root/log/omnitrack.log
|
|
28
|
+
attr_accessor :log_file
|
|
29
|
+
|
|
30
|
+
# When true, dispatch tracking calls through ActiveJob (non-blocking)
|
|
31
|
+
attr_accessor :async
|
|
32
|
+
|
|
33
|
+
# Queue name used for ActiveJob tracking jobs
|
|
34
|
+
attr_accessor :queue_name
|
|
35
|
+
|
|
36
|
+
# HTTP timeout (seconds) for outbound adapter API calls
|
|
37
|
+
attr_accessor :timeout
|
|
38
|
+
|
|
39
|
+
# Max retry attempts for failed adapter calls
|
|
40
|
+
attr_accessor :max_retries
|
|
41
|
+
|
|
42
|
+
# Retry delay base in seconds (exponential back-off: delay * 2^attempt)
|
|
43
|
+
attr_accessor :retry_delay
|
|
44
|
+
|
|
45
|
+
# Hook: called with (event_name, adapter_name, error) on adapter failure
|
|
46
|
+
attr_accessor :on_error
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@mode = :auto
|
|
50
|
+
@adapters = {}
|
|
51
|
+
@auto_capture = true
|
|
52
|
+
@log_level = :info
|
|
53
|
+
@log_file = nil # resolved lazily against Rails.root
|
|
54
|
+
@async = false
|
|
55
|
+
@queue_name = :omnitrack
|
|
56
|
+
@timeout = 5
|
|
57
|
+
@max_retries = 3
|
|
58
|
+
@retry_delay = 1
|
|
59
|
+
@on_error = nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate!
|
|
63
|
+
unless VALID_MODES.include?(@mode)
|
|
64
|
+
raise Omnitrack::ConfigurationError,
|
|
65
|
+
"Invalid mode: #{@mode.inspect}. Must be one of #{VALID_MODES}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless VALID_LOG_LVLS.include?(@log_level)
|
|
69
|
+
raise Omnitrack::ConfigurationError,
|
|
70
|
+
"Invalid log_level: #{@log_level.inspect}. Must be one of #{VALID_LOG_LVLS}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the resolved log file path (String)
|
|
77
|
+
def resolved_log_file
|
|
78
|
+
return @log_file.to_s if @log_file
|
|
79
|
+
|
|
80
|
+
if defined?(Rails) && Rails.root
|
|
81
|
+
Rails.root.join("log", "omnitrack.log").to_s
|
|
82
|
+
else
|
|
83
|
+
"log/omnitrack.log"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the hash config for a specific adapter (or {})
|
|
88
|
+
def adapter_config(name)
|
|
89
|
+
@adapters.fetch(name.to_sym, {})
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# True when the given adapter is enabled in config
|
|
93
|
+
def adapter_enabled?(name)
|
|
94
|
+
cfg = adapter_config(name)
|
|
95
|
+
cfg.fetch(:enabled, false)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Effective Rails mode — resolves :auto at runtime
|
|
99
|
+
def effective_mode
|
|
100
|
+
return @mode unless @mode == :auto
|
|
101
|
+
|
|
102
|
+
if defined?(Rails) && Rails.application
|
|
103
|
+
Rails.application.config.api_only ? :backend : :frontend
|
|
104
|
+
else
|
|
105
|
+
:backend
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
# Captures and exposes tracking parameters from a Rack request.
|
|
5
|
+
# Works in both cookie-based (full-stack) and cookie-free (API-only) modes.
|
|
6
|
+
#
|
|
7
|
+
# Stored per-request in thread-local storage via Omnitrack::Context.current.
|
|
8
|
+
class Context
|
|
9
|
+
CLICK_ID_PARAMS = %w[gclid fbclid ttclid sclid msclkid].freeze
|
|
10
|
+
UTM_PARAMS = %w[utm_source utm_medium utm_campaign utm_term utm_content].freeze
|
|
11
|
+
|
|
12
|
+
# Thread-local current context — set by the middleware
|
|
13
|
+
def self.current
|
|
14
|
+
Thread.current[:omnitrack_context]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.current=(ctx)
|
|
18
|
+
Thread.current[:omnitrack_context] = ctx
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.clear!
|
|
22
|
+
Thread.current[:omnitrack_context] = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build a Context from a Rack::Request object
|
|
26
|
+
def self.from_request(request)
|
|
27
|
+
new(
|
|
28
|
+
ip: extract_ip(request),
|
|
29
|
+
user_agent: request.env["HTTP_USER_AGENT"],
|
|
30
|
+
click_ids: extract_click_ids(request),
|
|
31
|
+
utm_params: extract_utm_params(request),
|
|
32
|
+
cookies: safe_cookies(request),
|
|
33
|
+
headers: extract_tracking_headers(request)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :ip, :user_agent, :click_ids, :utm_params, :cookies, :headers,
|
|
38
|
+
:custom_data
|
|
39
|
+
|
|
40
|
+
def initialize(ip: nil, user_agent: nil, click_ids: {}, utm_params: {},
|
|
41
|
+
cookies: {}, headers: {}, custom_data: {})
|
|
42
|
+
@ip = ip
|
|
43
|
+
@user_agent = user_agent
|
|
44
|
+
@click_ids = click_ids.transform_keys(&:to_sym)
|
|
45
|
+
@utm_params = utm_params.transform_keys(&:to_sym)
|
|
46
|
+
@cookies = cookies
|
|
47
|
+
@headers = headers
|
|
48
|
+
@custom_data = custom_data || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Merge extra data into the context (e.g., from a controller concern)
|
|
52
|
+
def merge!(data = {})
|
|
53
|
+
@custom_data.merge!(data.to_h)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def gclid = click_ids[:gclid]
|
|
58
|
+
def fbclid = click_ids[:fbclid]
|
|
59
|
+
def ttclid = click_ids[:ttclid]
|
|
60
|
+
|
|
61
|
+
# Full context as a hash — passed into adapter payloads
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
ip: @ip,
|
|
65
|
+
user_agent: @user_agent,
|
|
66
|
+
click_ids: @click_ids,
|
|
67
|
+
utm_params: @utm_params,
|
|
68
|
+
custom_data: @custom_data
|
|
69
|
+
}.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -------------------------------------------------------------------
|
|
73
|
+
# Private extraction helpers
|
|
74
|
+
# -------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def self.extract_ip(request)
|
|
77
|
+
# Respect common proxy headers
|
|
78
|
+
forwarded = request.env["HTTP_X_FORWARDED_FOR"]
|
|
79
|
+
if forwarded
|
|
80
|
+
forwarded.split(",").first.to_s.strip
|
|
81
|
+
else
|
|
82
|
+
request.ip
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
private_class_method :extract_ip
|
|
86
|
+
|
|
87
|
+
def self.extract_click_ids(request)
|
|
88
|
+
CLICK_ID_PARAMS.each_with_object({}) do |param, h|
|
|
89
|
+
value = request.params[param] ||
|
|
90
|
+
request.env["HTTP_X_#{param.upcase}"]
|
|
91
|
+
h[param] = value if value && !value.empty?
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
private_class_method :extract_click_ids
|
|
95
|
+
|
|
96
|
+
def self.extract_utm_params(request)
|
|
97
|
+
UTM_PARAMS.each_with_object({}) do |param, h|
|
|
98
|
+
value = request.params[param]
|
|
99
|
+
h[param] = value if value && !value.empty?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_class_method :extract_utm_params
|
|
103
|
+
|
|
104
|
+
def self.safe_cookies(request)
|
|
105
|
+
request.cookies.to_h
|
|
106
|
+
rescue StandardError
|
|
107
|
+
{}
|
|
108
|
+
end
|
|
109
|
+
private_class_method :safe_cookies
|
|
110
|
+
|
|
111
|
+
def self.extract_tracking_headers(request)
|
|
112
|
+
{
|
|
113
|
+
referer: request.env["HTTP_REFERER"],
|
|
114
|
+
accept_language: request.env["HTTP_ACCEPT_LANGUAGE"]
|
|
115
|
+
}.compact
|
|
116
|
+
end
|
|
117
|
+
private_class_method :extract_tracking_headers
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
# Base class for all Omnitrack errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the gem is misconfigured
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised (and caught internally) when an adapter API call fails.
|
|
11
|
+
# Carries the original exception for diagnostics.
|
|
12
|
+
class AdapterError < Error
|
|
13
|
+
attr_reader :original
|
|
14
|
+
|
|
15
|
+
def initialize(message = nil, original: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@original = original
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when an event payload fails validation
|
|
22
|
+
class InvalidPayloadError < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised when an adapter is referenced but not registered
|
|
25
|
+
class UnknownAdapterError < Error; end
|
|
26
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Omnitrack
|
|
6
|
+
module Helpers
|
|
7
|
+
# Rails view helpers for injecting tracking pixels and JS snippets.
|
|
8
|
+
# Auto-included in ActionView::Base by the Railtie when in frontend mode.
|
|
9
|
+
module ViewHelpers
|
|
10
|
+
# Emit all enabled platform JS/pixel tags as a single HTML blob.
|
|
11
|
+
# Place in your layout <head>:
|
|
12
|
+
# <%= omnitrack_tags %>
|
|
13
|
+
def omnitrack_tags
|
|
14
|
+
return "".html_safe unless Omnitrack.frontend_mode?
|
|
15
|
+
|
|
16
|
+
tags = []
|
|
17
|
+
tags << google_analytics_tag if Omnitrack.config.adapter_enabled?(:google_analytics)
|
|
18
|
+
tags << meta_pixel_tag if Omnitrack.config.adapter_enabled?(:meta)
|
|
19
|
+
tags << tiktok_pixel_tag if Omnitrack.config.adapter_enabled?(:tiktok)
|
|
20
|
+
tags << snapchat_pixel_tag if Omnitrack.config.adapter_enabled?(:snapchat)
|
|
21
|
+
|
|
22
|
+
tags.compact.join("\n").html_safe
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Emit a single <script> block to push a custom event to all enabled platforms.
|
|
26
|
+
# Place after a key action (e.g., order confirmation page):
|
|
27
|
+
# <%= omnitrack_event_tag("purchase", value: 99.00, currency: "USD") %>
|
|
28
|
+
def omnitrack_event_tag(event_name, **payload)
|
|
29
|
+
return "".html_safe unless Omnitrack.frontend_mode?
|
|
30
|
+
|
|
31
|
+
js = []
|
|
32
|
+
js << gtag_event(event_name, payload) if Omnitrack.config.adapter_enabled?(:google_analytics)
|
|
33
|
+
js << fbq_event(event_name, payload) if Omnitrack.config.adapter_enabled?(:meta)
|
|
34
|
+
js << ttq_event(event_name, payload) if Omnitrack.config.adapter_enabled?(:tiktok)
|
|
35
|
+
|
|
36
|
+
return "".html_safe if js.empty?
|
|
37
|
+
|
|
38
|
+
content_tag(:script) { js.join("\n").html_safe }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Platform-specific tag builders
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def google_analytics_tag
|
|
48
|
+
measurement_id = Omnitrack.config.adapter_config(:google_analytics)[:measurement_id]
|
|
49
|
+
return unless measurement_id.present?
|
|
50
|
+
|
|
51
|
+
<<~HTML
|
|
52
|
+
<!-- OmniTrack: Google Analytics 4 -->
|
|
53
|
+
<script async src="https://www.googletagmanager.com/gtag/js?id=#{measurement_id}"></script>
|
|
54
|
+
<script>
|
|
55
|
+
window.dataLayer = window.dataLayer || [];
|
|
56
|
+
function gtag(){dataLayer.push(arguments);}
|
|
57
|
+
gtag('js', new Date());
|
|
58
|
+
gtag('config', '#{measurement_id}');
|
|
59
|
+
</script>
|
|
60
|
+
HTML
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def meta_pixel_tag
|
|
64
|
+
pixel_id = Omnitrack.config.adapter_config(:meta)[:pixel_id]
|
|
65
|
+
return unless pixel_id.present?
|
|
66
|
+
|
|
67
|
+
<<~HTML
|
|
68
|
+
<!-- OmniTrack: Meta Pixel -->
|
|
69
|
+
<script>
|
|
70
|
+
!function(f,b,e,v,n,t,s)
|
|
71
|
+
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
72
|
+
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
|
73
|
+
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
|
74
|
+
n.queue=[];t=b.createElement(e);t.async=!0;
|
|
75
|
+
t.src=v;s=b.getElementsByTagName(e)[0];
|
|
76
|
+
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
|
77
|
+
'https://connect.facebook.net/en_US/fbevents.js');
|
|
78
|
+
fbq('init', '#{pixel_id}');
|
|
79
|
+
fbq('track', 'PageView');
|
|
80
|
+
</script>
|
|
81
|
+
<noscript>
|
|
82
|
+
<img height="1" width="1" style="display:none"
|
|
83
|
+
src="https://www.facebook.com/tr?id=#{pixel_id}&ev=PageView&noscript=1"/>
|
|
84
|
+
</noscript>
|
|
85
|
+
HTML
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tiktok_pixel_tag
|
|
89
|
+
pixel_id = Omnitrack.config.adapter_config(:tiktok)[:pixel_id]
|
|
90
|
+
return unless pixel_id.present?
|
|
91
|
+
|
|
92
|
+
<<~HTML
|
|
93
|
+
<!-- OmniTrack: TikTok Pixel -->
|
|
94
|
+
<script>
|
|
95
|
+
!function (w, d, t) {
|
|
96
|
+
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];
|
|
97
|
+
ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"];
|
|
98
|
+
ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};
|
|
99
|
+
for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);
|
|
100
|
+
ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e};
|
|
101
|
+
ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";
|
|
102
|
+
ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};
|
|
103
|
+
var o=document.createElement("script");o.type="text/javascript";o.async=!0;o.src=i+"?sdkid="+e+"&lib="+t;
|
|
104
|
+
var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
|
|
105
|
+
ttq.load('#{pixel_id}');
|
|
106
|
+
ttq.page();
|
|
107
|
+
}(window, document, 'ttq');
|
|
108
|
+
</script>
|
|
109
|
+
HTML
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def snapchat_pixel_tag
|
|
113
|
+
pixel_id = Omnitrack.config.adapter_config(:snapchat)[:pixel_id]
|
|
114
|
+
return unless pixel_id.present?
|
|
115
|
+
|
|
116
|
+
<<~HTML
|
|
117
|
+
<!-- OmniTrack: Snapchat Pixel -->
|
|
118
|
+
<script type='text/javascript'>
|
|
119
|
+
(function(e,t,n){if(e.snaptr)return;var a=e.snaptr=function()
|
|
120
|
+
{a.handleRequest?a.handleRequest.apply(a,arguments):a.queue.push(arguments)};
|
|
121
|
+
a.queue=[];var s='script';r=t.createElement(s);r.async=!0;
|
|
122
|
+
r.src=n;var u=t.getElementsByTagName(s)[0];
|
|
123
|
+
u.parentNode.insertBefore(r,u);})(window,document,
|
|
124
|
+
'https://sc-static.net/scevent.min.js');
|
|
125
|
+
snaptr('init', '#{pixel_id}', {});
|
|
126
|
+
snaptr('track', 'PAGE_VIEW');
|
|
127
|
+
</script>
|
|
128
|
+
HTML
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# JS event helpers
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def gtag_event(event_name, payload)
|
|
136
|
+
"gtag('event', '#{event_name}', #{payload.to_json});"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def fbq_event(event_name, payload)
|
|
140
|
+
"fbq('track', '#{event_name}', #{payload.to_json});"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ttq_event(event_name, payload)
|
|
144
|
+
"ttq.track('#{event_name}', #{payload.to_json});"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Jobs
|
|
5
|
+
# ActiveJob wrapper for asynchronous event dispatch.
|
|
6
|
+
# Enqueued automatically when Omnitrack.config.async == true.
|
|
7
|
+
#
|
|
8
|
+
# The job serializes the operation + arguments to JSON so they survive
|
|
9
|
+
# the queue boundary. All arguments must be plain Ruby primitives.
|
|
10
|
+
#
|
|
11
|
+
class TrackingJob < ActiveJob::Base
|
|
12
|
+
queue_as { Omnitrack.config.queue_name }
|
|
13
|
+
|
|
14
|
+
# @param operation [String] stringified symbol, e.g. "track_event"
|
|
15
|
+
# @param args [Array] JSON-serialisable positional arguments
|
|
16
|
+
def perform(operation, *args)
|
|
17
|
+
Omnitrack::Pipeline::Dispatcher.dispatch(operation.to_sym, *args)
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
Omnitrack.logger.error("tracking_job.error",
|
|
20
|
+
operation: operation,
|
|
21
|
+
error: e.class.name,
|
|
22
|
+
message: e.message)
|
|
23
|
+
raise # Re-raise so ActiveJob retry logic still works
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Omnitrack
|
|
8
|
+
# Structured JSON logger that writes to a dedicated omnitrack.log file.
|
|
9
|
+
# All public methods are thread-safe (delegating to Ruby's stdlib Logger).
|
|
10
|
+
class Logger
|
|
11
|
+
SEVERITY_MAP = {
|
|
12
|
+
debug: ::Logger::DEBUG,
|
|
13
|
+
info: ::Logger::INFO,
|
|
14
|
+
warn: ::Logger::WARN,
|
|
15
|
+
error: ::Logger::ERROR,
|
|
16
|
+
none: ::Logger::UNKNOWN
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(log_file:, log_level: :info)
|
|
20
|
+
@level = log_level.to_sym
|
|
21
|
+
@log_file = log_file
|
|
22
|
+
@backend = build_backend(log_file)
|
|
23
|
+
apply_level!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -------------------------------------------------------------------
|
|
27
|
+
# Public logging API
|
|
28
|
+
# -------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def debug(message, **context)
|
|
31
|
+
write(:debug, message, context)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def info(message, **context)
|
|
35
|
+
write(:info, message, context)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def warn(message, **context)
|
|
39
|
+
write(:warn, message, context)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def error(message, **context)
|
|
43
|
+
write(:error, message, context)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Convenience: log an adapter request
|
|
47
|
+
def log_request(adapter:, event:, payload: {})
|
|
48
|
+
info("adapter.request",
|
|
49
|
+
adapter: adapter,
|
|
50
|
+
event: event,
|
|
51
|
+
payload: payload)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Convenience: log an adapter response
|
|
55
|
+
def log_response(adapter:, event:, status:, body: nil, duration_ms: nil)
|
|
56
|
+
info("adapter.response",
|
|
57
|
+
adapter: adapter,
|
|
58
|
+
event: event,
|
|
59
|
+
status: status,
|
|
60
|
+
body: body,
|
|
61
|
+
duration_ms: duration_ms)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Convenience: log an adapter error
|
|
65
|
+
def log_error(adapter:, event:, error:, attempt: nil)
|
|
66
|
+
error("adapter.error",
|
|
67
|
+
adapter: adapter,
|
|
68
|
+
event: event,
|
|
69
|
+
error: error.class.name,
|
|
70
|
+
message: error.message,
|
|
71
|
+
attempt: attempt,
|
|
72
|
+
backtrace: error.backtrace&.first(5))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Re-open the log file (useful after logrotate)
|
|
76
|
+
def reopen
|
|
77
|
+
@backend.reopen(@log_file)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def build_backend(log_file)
|
|
83
|
+
# Ensure directory exists
|
|
84
|
+
dir = File.dirname(log_file)
|
|
85
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
86
|
+
|
|
87
|
+
backend = ::Logger.new(log_file, "daily")
|
|
88
|
+
backend.formatter = method(:json_formatter)
|
|
89
|
+
backend
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_level!
|
|
93
|
+
@backend.level = SEVERITY_MAP.fetch(@level, ::Logger::INFO)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def write(severity, message, context)
|
|
97
|
+
return if @level == :none
|
|
98
|
+
|
|
99
|
+
@backend.public_send(severity, build_payload(message, context))
|
|
100
|
+
rescue StandardError
|
|
101
|
+
# Never let logger failures propagate into the main app
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_payload(message, context)
|
|
105
|
+
{
|
|
106
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
107
|
+
message: message
|
|
108
|
+
}.merge(compact_context(context))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def compact_context(context)
|
|
112
|
+
context.reject { |_, v| v.nil? }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def json_formatter(_severity, _time, _progname, msg)
|
|
116
|
+
payload = msg.is_a?(Hash) ? msg : { message: msg.to_s }
|
|
117
|
+
"#{JSON.generate(payload)}\n"
|
|
118
|
+
rescue StandardError
|
|
119
|
+
"#{msg}\n"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
|
|
5
|
+
module Omnitrack
|
|
6
|
+
module Middleware
|
|
7
|
+
# Rack middleware that:
|
|
8
|
+
# 1. Builds an Omnitrack::Context from the incoming request
|
|
9
|
+
# 2. Makes it available thread-locally as Omnitrack::Context.current
|
|
10
|
+
# 3. Clears it after the response (prevents cross-request data leakage)
|
|
11
|
+
#
|
|
12
|
+
# Auto-inserted by the Railtie; can also be added manually:
|
|
13
|
+
# config.middleware.use Omnitrack::Middleware::RequestTracker
|
|
14
|
+
#
|
|
15
|
+
class RequestTracker
|
|
16
|
+
def initialize(app)
|
|
17
|
+
@app = app
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
request = Rack::Request.new(env)
|
|
22
|
+
|
|
23
|
+
if Omnitrack.config.auto_capture
|
|
24
|
+
ctx = Omnitrack::Context.from_request(request)
|
|
25
|
+
Omnitrack::Context.current = ctx
|
|
26
|
+
|
|
27
|
+
Omnitrack.logger.debug("middleware.request",
|
|
28
|
+
method: request.request_method,
|
|
29
|
+
path: request.path,
|
|
30
|
+
click_ids: ctx.click_ids,
|
|
31
|
+
utm_params: ctx.utm_params)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@app.call(env)
|
|
35
|
+
ensure
|
|
36
|
+
Omnitrack::Context.clear!
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Central event dispatcher.
|
|
6
|
+
# Instantiates all enabled adapters, runs the requested operation on each,
|
|
7
|
+
# and returns an Omnitrack::MultiResult.
|
|
8
|
+
#
|
|
9
|
+
# Usage (called by Omnitrack module methods):
|
|
10
|
+
# Omnitrack::Pipeline::Dispatcher.dispatch(:track_event, "purchase", payload)
|
|
11
|
+
#
|
|
12
|
+
class Dispatcher
|
|
13
|
+
class << self
|
|
14
|
+
# @param operation [Symbol] one of :track_event, :track_conversion, :identify_user
|
|
15
|
+
# @param args [Array] forwarded to the adapter method
|
|
16
|
+
# @return [Omnitrack::MultiResult]
|
|
17
|
+
def dispatch(operation, *args)
|
|
18
|
+
adapters = Omnitrack::Registry.enabled_adapters
|
|
19
|
+
|
|
20
|
+
if adapters.empty?
|
|
21
|
+
Omnitrack.logger.warn("dispatcher.no_adapters",
|
|
22
|
+
operation: operation)
|
|
23
|
+
return Omnitrack::MultiResult.new([
|
|
24
|
+
Omnitrack::Result.skipped(reason: "no enabled adapters")
|
|
25
|
+
])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
results = adapters.map do |adapter|
|
|
29
|
+
dispatch_to(adapter, operation, args)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Omnitrack::MultiResult.new(results)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def dispatch_to(adapter, operation, args)
|
|
38
|
+
unless adapter.respond_to?(operation)
|
|
39
|
+
return Omnitrack::Result.skipped(
|
|
40
|
+
reason: "#{adapter.name} does not implement #{operation}",
|
|
41
|
+
adapter: adapter.name
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
adapter.public_send(operation, *args)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
# Adapter-level isolation — one adapter blowing up must not affect others
|
|
48
|
+
Omnitrack.logger.log_error(adapter: adapter.name,
|
|
49
|
+
event: operation.to_s,
|
|
50
|
+
error: e)
|
|
51
|
+
Omnitrack::Result.failure(error: e, adapter: adapter.name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|