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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Adapters
|
|
5
|
+
# Google Ads Conversion Tracking via the Google Ads API (REST).
|
|
6
|
+
#
|
|
7
|
+
# Required config keys:
|
|
8
|
+
# customer_id — Google Ads customer ID (without dashes), e.g. "1234567890"
|
|
9
|
+
# developer_token — developer token from Google Ads API Center
|
|
10
|
+
# access_token — OAuth2 access token (refresh externally or via oauth2 gem)
|
|
11
|
+
#
|
|
12
|
+
# Optional config keys:
|
|
13
|
+
# conversion_action_id — default conversion action ID
|
|
14
|
+
# login_customer_id — MCC (manager account) customer ID, if applicable
|
|
15
|
+
# api_version — default "v15"
|
|
16
|
+
#
|
|
17
|
+
# Example config:
|
|
18
|
+
# google_ads: {
|
|
19
|
+
# enabled: true,
|
|
20
|
+
# customer_id: ENV["GOOGLE_ADS_CUSTOMER_ID"],
|
|
21
|
+
# developer_token: ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
|
|
22
|
+
# access_token: ENV["GOOGLE_ADS_ACCESS_TOKEN"],
|
|
23
|
+
# conversion_action_id: ENV["GOOGLE_ADS_CONVERSION_ACTION_ID"]
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
class GoogleAds < Base
|
|
27
|
+
self.adapter_name = :google_ads
|
|
28
|
+
|
|
29
|
+
API_VERSION = "v15"
|
|
30
|
+
API_BASE = "https://googleads.googleapis.com"
|
|
31
|
+
|
|
32
|
+
# -------------------------------------------------------------------
|
|
33
|
+
# Interface implementation
|
|
34
|
+
# -------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# Track a named event.
|
|
37
|
+
# Maps to a Google Ads click conversion upload.
|
|
38
|
+
#
|
|
39
|
+
# @param event_name [String] e.g. "purchase", "lead"
|
|
40
|
+
# @param payload [Hash]
|
|
41
|
+
# :gclid — Google Click ID (required for click conversion)
|
|
42
|
+
# :conversion_value — monetary value
|
|
43
|
+
# :currency_code — ISO 4217, e.g. "USD"
|
|
44
|
+
# :order_id — deduplication key
|
|
45
|
+
# :conversion_time — ISO 8601 string (defaults to now)
|
|
46
|
+
# :__context__ — injected by Controller concern
|
|
47
|
+
def track_event(event_name, payload = {})
|
|
48
|
+
safe_execute(event_name) do
|
|
49
|
+
gclid = extract_gclid(payload)
|
|
50
|
+
action_id = payload[:conversion_action_id] || config[:conversion_action_id]
|
|
51
|
+
|
|
52
|
+
body = build_conversion_body(
|
|
53
|
+
gclid: gclid,
|
|
54
|
+
conversion_action: action_resource(action_id),
|
|
55
|
+
conversion_time: payload[:conversion_time] || iso_now,
|
|
56
|
+
conversion_value: payload[:conversion_value],
|
|
57
|
+
currency_code: payload[:currency_code] || "USD",
|
|
58
|
+
order_id: payload[:order_id]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
response = http_post(upload_url, body: body, headers: auth_headers)
|
|
62
|
+
Omnitrack::Result.success(
|
|
63
|
+
adapter: name,
|
|
64
|
+
data: parse_response(response)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Track a conversion (alias with sensible defaults for purchase events)
|
|
70
|
+
def track_conversion(data = {})
|
|
71
|
+
track_event("conversion", data)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Google Ads does not have a standalone identify endpoint;
|
|
75
|
+
# enhanced conversions use hashed PII attached to the conversion upload.
|
|
76
|
+
# This method stores user data on the context for later use.
|
|
77
|
+
def identify_user(user_data = {})
|
|
78
|
+
ctx = Omnitrack::Context.current
|
|
79
|
+
ctx&.merge!(omnitrack_user: normalize_user(user_data))
|
|
80
|
+
|
|
81
|
+
Omnitrack::Result.success(
|
|
82
|
+
adapter: name,
|
|
83
|
+
metadata: { note: "User data stored in context for enhanced conversions" }
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# -------------------------------------------------------------------
|
|
90
|
+
# Config validation
|
|
91
|
+
# -------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def validate_config
|
|
94
|
+
require_config!(:customer_id, hint: "Your Google Ads account ID (no dashes)")
|
|
95
|
+
require_config!(:developer_token, hint: "From the Google Ads API Center")
|
|
96
|
+
require_config!(:access_token, hint: "OAuth2 access token")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# -------------------------------------------------------------------
|
|
100
|
+
# Request building
|
|
101
|
+
# -------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def upload_url
|
|
104
|
+
version = config[:api_version] || API_VERSION
|
|
105
|
+
cid = config[:customer_id].to_s.tr("-", "")
|
|
106
|
+
"#{API_BASE}/#{version}/customers/#{cid}:uploadClickConversions"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def action_resource(action_id)
|
|
110
|
+
cid = config[:customer_id].to_s.tr("-", "")
|
|
111
|
+
"customers/#{cid}/conversionActions/#{action_id}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_conversion_body(gclid:, conversion_action:, conversion_time:,
|
|
115
|
+
conversion_value: nil, currency_code: "USD",
|
|
116
|
+
order_id: nil)
|
|
117
|
+
click_conversion = {
|
|
118
|
+
gclid: gclid,
|
|
119
|
+
conversion_action: conversion_action,
|
|
120
|
+
conversion_date_time: conversion_time
|
|
121
|
+
}
|
|
122
|
+
click_conversion[:conversion_value] = conversion_value.to_f if conversion_value
|
|
123
|
+
click_conversion[:currency_code] = currency_code if currency_code
|
|
124
|
+
click_conversion[:order_id] = order_id.to_s if order_id
|
|
125
|
+
|
|
126
|
+
{ conversions: [click_conversion], partial_failure: true }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def auth_headers
|
|
130
|
+
headers = {
|
|
131
|
+
"Authorization" => "Bearer #{config[:access_token]}",
|
|
132
|
+
"developer-token" => config[:developer_token]
|
|
133
|
+
}
|
|
134
|
+
if config[:login_customer_id]
|
|
135
|
+
headers["login-customer-id"] = config[:login_customer_id].to_s
|
|
136
|
+
end
|
|
137
|
+
headers
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# -------------------------------------------------------------------
|
|
141
|
+
# Helpers
|
|
142
|
+
# -------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def extract_gclid(payload)
|
|
145
|
+
payload[:gclid] ||
|
|
146
|
+
payload.dig(:__context__, :click_ids, :gclid) ||
|
|
147
|
+
Omnitrack::Context.current&.gclid
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def normalize_user(user_data)
|
|
151
|
+
{
|
|
152
|
+
hashed_email: sha256(user_data[:email]),
|
|
153
|
+
hashed_phone_number: sha256(user_data[:phone]),
|
|
154
|
+
address: {
|
|
155
|
+
hashed_first_name: sha256(user_data[:first_name]),
|
|
156
|
+
hashed_last_name: sha256(user_data[:last_name])
|
|
157
|
+
}.compact
|
|
158
|
+
}.compact
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_response(response)
|
|
162
|
+
JSON.parse(response.body)
|
|
163
|
+
rescue JSON::ParserError
|
|
164
|
+
{ raw: response.body }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def iso_now
|
|
168
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Adapters
|
|
5
|
+
# Google Analytics 4 — Measurement Protocol (server-side).
|
|
6
|
+
#
|
|
7
|
+
# Required config:
|
|
8
|
+
# measurement_id — e.g. "G-XXXXXXXXXX"
|
|
9
|
+
# api_secret — Measurement Protocol API secret (from GA4 admin)
|
|
10
|
+
#
|
|
11
|
+
# Optional config:
|
|
12
|
+
# debug_mode — when true, uses the /debug/mp/collect endpoint
|
|
13
|
+
#
|
|
14
|
+
class GoogleAnalytics < Base
|
|
15
|
+
self.adapter_name = :google_analytics
|
|
16
|
+
|
|
17
|
+
MP_ENDPOINT = "https://www.google-analytics.com/mp/collect"
|
|
18
|
+
MP_DEBUG_ENDPOINT = "https://www.google-analytics.com/debug/mp/collect"
|
|
19
|
+
|
|
20
|
+
def track_event(event_name, payload = {})
|
|
21
|
+
safe_execute(event_name) do
|
|
22
|
+
body = build_event_body(event_name, payload)
|
|
23
|
+
response = http_post(endpoint_url, body: body)
|
|
24
|
+
Omnitrack::Result.success(adapter: name, data: { sent: body })
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def track_conversion(data = {})
|
|
29
|
+
track_event(data.fetch(:event_name, "conversion"), data)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def identify_user(user_data = {})
|
|
33
|
+
# GA4 uses client_id / user_id rather than a separate identify call
|
|
34
|
+
ctx = Omnitrack::Context.current
|
|
35
|
+
ctx&.merge!(ga_user_id: user_data[:external_id] || user_data[:email])
|
|
36
|
+
|
|
37
|
+
Omnitrack::Result.success(
|
|
38
|
+
adapter: name,
|
|
39
|
+
metadata: { note: "GA4 user_id stored in context" }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate_config
|
|
46
|
+
require_config!(:measurement_id, hint: "e.g. G-XXXXXXXXXX")
|
|
47
|
+
require_config!(:api_secret, hint: "Measurement Protocol API secret from GA4 admin")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def endpoint_url
|
|
51
|
+
base = config[:debug_mode] ? MP_DEBUG_ENDPOINT : MP_ENDPOINT
|
|
52
|
+
"#{base}?measurement_id=#{config[:measurement_id]}&api_secret=#{config[:api_secret]}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_event_body(event_name, payload)
|
|
56
|
+
ctx = Omnitrack::Context.current
|
|
57
|
+
|
|
58
|
+
# GA4 requires a client_id; fall back to a pseudo-anonymous one
|
|
59
|
+
client_id = payload[:client_id] ||
|
|
60
|
+
ctx&.cookies&.dig("_ga")&.then { |ga| parse_ga_cookie(ga) } ||
|
|
61
|
+
SecureRandom.uuid
|
|
62
|
+
|
|
63
|
+
event_params = payload
|
|
64
|
+
.reject { |k, _| k.to_s.start_with?("__") }
|
|
65
|
+
.transform_keys(&:to_s)
|
|
66
|
+
|
|
67
|
+
body = {
|
|
68
|
+
client_id: client_id,
|
|
69
|
+
events: [
|
|
70
|
+
{
|
|
71
|
+
name: sanitize_event_name(event_name),
|
|
72
|
+
params: event_params
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Attach user_id if present in context
|
|
78
|
+
if (uid = ctx&.custom_data&.dig(:ga_user_id))
|
|
79
|
+
body[:user_id] = uid.to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
body
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sanitize_event_name(name)
|
|
86
|
+
# GA4 event names: alphanumeric + underscore, max 40 chars
|
|
87
|
+
name.to_s.downcase.gsub(/[^a-z0-9_]/, "_")[0, 40]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_ga_cookie(cookie_value)
|
|
91
|
+
# _ga cookie format: GA1.2.XXXXXXXXXX.XXXXXXXXXX
|
|
92
|
+
parts = cookie_value.to_s.split(".")
|
|
93
|
+
parts.length >= 4 ? "#{parts[2]}.#{parts[3]}" : cookie_value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Adapters
|
|
5
|
+
# Meta Conversions API (server-side events).
|
|
6
|
+
# Docs: https://developers.facebook.com/docs/marketing-api/conversions-api
|
|
7
|
+
#
|
|
8
|
+
# Required config:
|
|
9
|
+
# pixel_id — Meta Pixel ID
|
|
10
|
+
# access_token — System User access token
|
|
11
|
+
#
|
|
12
|
+
# Optional config:
|
|
13
|
+
# test_event_code — for use with the Test Events tool in Events Manager
|
|
14
|
+
# api_version — default "v18.0"
|
|
15
|
+
#
|
|
16
|
+
class Meta < Base
|
|
17
|
+
self.adapter_name = :meta
|
|
18
|
+
|
|
19
|
+
API_BASE = "https://graph.facebook.com"
|
|
20
|
+
API_VERSION = "v18.0"
|
|
21
|
+
|
|
22
|
+
# Standard Meta event names — used for validation/mapping
|
|
23
|
+
STANDARD_EVENTS = %w[
|
|
24
|
+
Purchase Lead CompleteRegistration AddToCart ViewContent
|
|
25
|
+
Search InitiateCheckout AddPaymentInfo Subscribe
|
|
26
|
+
Contact CustomizeProduct Donate FindLocation Schedule
|
|
27
|
+
StartTrial SubmitApplication
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def track_event(event_name, payload = {})
|
|
31
|
+
safe_execute(event_name) do
|
|
32
|
+
body = build_event_body(event_name, payload)
|
|
33
|
+
response = http_post(endpoint_url, body: body)
|
|
34
|
+
result = JSON.parse(response.body)
|
|
35
|
+
|
|
36
|
+
Omnitrack::Result.success(adapter: name, data: result)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def track_conversion(data = {})
|
|
41
|
+
# Map to "Purchase" standard event by default
|
|
42
|
+
track_event("Purchase", data)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def identify_user(user_data = {})
|
|
46
|
+
# Store normalised PII; will be sent with next track_event call
|
|
47
|
+
ctx = Omnitrack::Context.current
|
|
48
|
+
ctx&.merge!(meta_user_data: normalize_user(user_data))
|
|
49
|
+
|
|
50
|
+
Omnitrack::Result.success(
|
|
51
|
+
adapter: name,
|
|
52
|
+
metadata: { note: "Meta user data stored in context" }
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def validate_config
|
|
59
|
+
require_config!(:pixel_id, hint: "Your Meta Pixel ID")
|
|
60
|
+
require_config!(:access_token, hint: "System User access token from Meta Business")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def endpoint_url
|
|
64
|
+
version = config[:api_version] || API_VERSION
|
|
65
|
+
"#{API_BASE}/#{version}/#{config[:pixel_id]}/events?access_token=#{config[:access_token]}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_event_body(event_name, payload)
|
|
69
|
+
ctx = Omnitrack::Context.current
|
|
70
|
+
clean = payload.reject { |k, _| k.to_s.start_with?("__") }
|
|
71
|
+
|
|
72
|
+
event = {
|
|
73
|
+
event_name: map_event_name(event_name),
|
|
74
|
+
event_time: Time.now.to_i,
|
|
75
|
+
action_source: "website",
|
|
76
|
+
event_source_url: payload[:event_source_url],
|
|
77
|
+
event_id: payload[:event_id] || SecureRandom.uuid,
|
|
78
|
+
user_data: build_user_data(ctx, payload),
|
|
79
|
+
custom_data: build_custom_data(clean)
|
|
80
|
+
}.compact
|
|
81
|
+
|
|
82
|
+
body = { data: [event] }
|
|
83
|
+
body[:test_event_code] = config[:test_event_code] if config[:test_event_code]
|
|
84
|
+
body
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_user_data(ctx, payload)
|
|
88
|
+
# Merge pre-identified user data + request context
|
|
89
|
+
pre_identified = ctx&.custom_data&.dig(:meta_user_data) || {}
|
|
90
|
+
|
|
91
|
+
data = {
|
|
92
|
+
client_ip_address: ctx&.ip,
|
|
93
|
+
client_user_agent: ctx&.user_agent,
|
|
94
|
+
fbc: fbc_from_context(ctx, payload),
|
|
95
|
+
fbp: ctx&.cookies&.dig("_fbp")
|
|
96
|
+
}.compact
|
|
97
|
+
|
|
98
|
+
data.merge!(pre_identified)
|
|
99
|
+
|
|
100
|
+
# Also accept direct PII in payload
|
|
101
|
+
if payload[:email]
|
|
102
|
+
data[:em] = sha256(payload[:email])
|
|
103
|
+
end
|
|
104
|
+
if payload[:phone]
|
|
105
|
+
data[:ph] = sha256(payload[:phone].to_s.gsub(/\D/, ""))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
data
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_custom_data(payload)
|
|
112
|
+
cd = {}
|
|
113
|
+
cd[:value] = payload[:value].to_f if payload[:value]
|
|
114
|
+
cd[:currency] = payload[:currency] || "USD"
|
|
115
|
+
cd[:content_ids] = Array(payload[:content_ids]) if payload[:content_ids]
|
|
116
|
+
cd[:content_type]= payload[:content_type] if payload[:content_type]
|
|
117
|
+
cd[:order_id] = payload[:order_id].to_s if payload[:order_id]
|
|
118
|
+
cd[:num_items] = payload[:num_items].to_i if payload[:num_items]
|
|
119
|
+
cd
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def map_event_name(name)
|
|
123
|
+
# Attempt to find standard Meta event by case-insensitive match
|
|
124
|
+
standard = STANDARD_EVENTS.find { |e| e.downcase == name.to_s.downcase }
|
|
125
|
+
standard || name.to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fbc_from_context(ctx, payload)
|
|
129
|
+
fbclid = payload[:fbclid] ||
|
|
130
|
+
ctx&.click_ids&.dig(:fbclid)
|
|
131
|
+
return unless fbclid
|
|
132
|
+
|
|
133
|
+
ts = Time.now.to_i
|
|
134
|
+
"fb.1.#{ts}.#{fbclid}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def normalize_user(user_data)
|
|
138
|
+
{
|
|
139
|
+
em: sha256(user_data[:email]),
|
|
140
|
+
ph: sha256(user_data[:phone].to_s.gsub(/\D/, "")),
|
|
141
|
+
fn: sha256(user_data[:first_name]),
|
|
142
|
+
ln: sha256(user_data[:last_name]),
|
|
143
|
+
ct: sha256(user_data[:city]),
|
|
144
|
+
st: sha256(user_data[:state]),
|
|
145
|
+
zp: sha256(user_data[:zip]),
|
|
146
|
+
country: sha256(user_data[:country])
|
|
147
|
+
}.compact.reject { |_, v| v == sha256("") }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Adapters
|
|
5
|
+
# Snapchat Conversions API (server-side).
|
|
6
|
+
# Docs: https://marketingapi.snapchat.com/docs/conversion.html
|
|
7
|
+
#
|
|
8
|
+
# Required config:
|
|
9
|
+
# pixel_id — Snap Pixel ID
|
|
10
|
+
# access_token — long-lived access token
|
|
11
|
+
#
|
|
12
|
+
class Snapchat < Base
|
|
13
|
+
self.adapter_name = :snapchat
|
|
14
|
+
|
|
15
|
+
API_ENDPOINT = "https://tr.snapchat.com/v2/conversion"
|
|
16
|
+
|
|
17
|
+
STANDARD_EVENTS = %w[
|
|
18
|
+
PURCHASE ADD_CART VIEW_CONTENT ADD_BILLING SIGN_UP LOGIN
|
|
19
|
+
START_CHECKOUT PAGE_VIEW SUBSCRIBE SEARCH CUSTOM_EVENT_1
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def track_event(event_name, payload = {})
|
|
23
|
+
safe_execute(event_name) do
|
|
24
|
+
body = build_event_body(event_name, payload)
|
|
25
|
+
response = http_post(API_ENDPOINT, body: body, headers: auth_headers)
|
|
26
|
+
result = JSON.parse(response.body)
|
|
27
|
+
|
|
28
|
+
Omnitrack::Result.success(adapter: name, data: result)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def track_conversion(data = {})
|
|
33
|
+
track_event("PURCHASE", data)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def identify_user(user_data = {})
|
|
37
|
+
ctx = Omnitrack::Context.current
|
|
38
|
+
ctx&.merge!(snapchat_user_data: normalize_user(user_data))
|
|
39
|
+
|
|
40
|
+
Omnitrack::Result.success(
|
|
41
|
+
adapter: name,
|
|
42
|
+
metadata: { note: "Snapchat user data stored in context" }
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_config
|
|
49
|
+
require_config!(:pixel_id, hint: "Snap Pixel ID")
|
|
50
|
+
require_config!(:access_token, hint: "Long-lived access token")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def auth_headers
|
|
54
|
+
{ "Authorization" => "Bearer #{config[:access_token]}" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_event_body(event_name, payload)
|
|
58
|
+
ctx = Omnitrack::Context.current
|
|
59
|
+
clean = payload.reject { |k, _| k.to_s.start_with?("__") }
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
pixel_id: config[:pixel_id],
|
|
63
|
+
test_mode: config[:test_mode] ? 1 : 0,
|
|
64
|
+
data: [
|
|
65
|
+
{
|
|
66
|
+
event_type: map_event_name(event_name),
|
|
67
|
+
event_conversion_type: "WEB",
|
|
68
|
+
event_tag: payload[:event_tag],
|
|
69
|
+
timestamp: (Time.now.to_f * 1000).to_i,
|
|
70
|
+
hashed_email: payload[:email] ? sha256(payload[:email]) : nil,
|
|
71
|
+
hashed_phone: payload[:phone] ? sha256(payload[:phone].to_s.gsub(/\D/, "")) : nil,
|
|
72
|
+
hashed_ip_address: ctx&.ip ? sha256(ctx.ip) : nil,
|
|
73
|
+
user_agent: ctx&.user_agent,
|
|
74
|
+
page_url: payload[:page_url],
|
|
75
|
+
client_dedup_id: payload[:event_id] || SecureRandom.uuid,
|
|
76
|
+
price: clean[:value]&.to_f,
|
|
77
|
+
currency: clean[:currency] || "USD",
|
|
78
|
+
item_ids: Array(clean[:item_ids]).join(","),
|
|
79
|
+
number_items: clean[:quantity]&.to_i,
|
|
80
|
+
order_id: clean[:order_id]&.to_s,
|
|
81
|
+
click_id: ctx&.click_ids&.dig(:sclid)
|
|
82
|
+
}.compact
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def map_event_name(name)
|
|
88
|
+
upper = name.to_s.upcase
|
|
89
|
+
STANDARD_EVENTS.include?(upper) ? upper : "CUSTOM_EVENT_1"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_user(user_data)
|
|
93
|
+
{
|
|
94
|
+
hashed_email: sha256(user_data[:email]),
|
|
95
|
+
hashed_phone: sha256(user_data[:phone].to_s.gsub(/\D/, ""))
|
|
96
|
+
}.compact
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Adapters
|
|
5
|
+
# TikTok Events API (server-side).
|
|
6
|
+
# Docs: https://ads.tiktok.com/marketing_api/docs?id=1741601162187777
|
|
7
|
+
#
|
|
8
|
+
# Required config:
|
|
9
|
+
# pixel_id — TikTok Pixel ID
|
|
10
|
+
# access_token — Events API access token
|
|
11
|
+
#
|
|
12
|
+
class TikTok < Base
|
|
13
|
+
self.adapter_name = :tiktok
|
|
14
|
+
|
|
15
|
+
API_ENDPOINT = "https://business-api.tiktok.com/open_api/v1.3/pixel/track/"
|
|
16
|
+
|
|
17
|
+
STANDARD_EVENTS = %w[
|
|
18
|
+
ViewContent ClickButton Search AddToWishlist AddToCart
|
|
19
|
+
InitiateCheckout AddPaymentInfo PlaceAnOrder CompletePayment
|
|
20
|
+
Download Register Subscribe Contact Submit
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def track_event(event_name, payload = {})
|
|
24
|
+
safe_execute(event_name) do
|
|
25
|
+
body = build_event_body(event_name, payload)
|
|
26
|
+
response = http_post(API_ENDPOINT, body: body, headers: auth_headers)
|
|
27
|
+
result = JSON.parse(response.body)
|
|
28
|
+
|
|
29
|
+
Omnitrack::Result.success(adapter: name, data: result)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def track_conversion(data = {})
|
|
34
|
+
track_event("CompletePayment", data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def identify_user(user_data = {})
|
|
38
|
+
ctx = Omnitrack::Context.current
|
|
39
|
+
ctx&.merge!(tiktok_user_data: normalize_user(user_data))
|
|
40
|
+
|
|
41
|
+
Omnitrack::Result.success(
|
|
42
|
+
adapter: name,
|
|
43
|
+
metadata: { note: "TikTok user data stored in context" }
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_config
|
|
50
|
+
require_config!(:pixel_id, hint: "TikTok Pixel ID")
|
|
51
|
+
require_config!(:access_token, hint: "Events API access token")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def auth_headers
|
|
55
|
+
{ "Access-Token" => config[:access_token] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_event_body(event_name, payload)
|
|
59
|
+
ctx = Omnitrack::Context.current
|
|
60
|
+
clean = payload.reject { |k, _| k.to_s.start_with?("__") }
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
pixel_code: config[:pixel_id],
|
|
64
|
+
event: map_event_name(event_name),
|
|
65
|
+
event_id: payload[:event_id] || SecureRandom.uuid,
|
|
66
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
67
|
+
context: {
|
|
68
|
+
user_agent: ctx&.user_agent,
|
|
69
|
+
ip: ctx&.ip,
|
|
70
|
+
user: build_user_data(ctx, payload),
|
|
71
|
+
ad: {
|
|
72
|
+
callback: payload[:ttclid] || ctx&.click_ids&.dig(:ttclid)
|
|
73
|
+
}.compact,
|
|
74
|
+
page: {
|
|
75
|
+
url: payload[:page_url],
|
|
76
|
+
referrer: payload[:referrer]
|
|
77
|
+
}.compact
|
|
78
|
+
}.compact,
|
|
79
|
+
properties: build_properties(clean)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_user_data(ctx, payload)
|
|
84
|
+
pre = ctx&.custom_data&.dig(:tiktok_user_data) || {}
|
|
85
|
+
data = pre.dup
|
|
86
|
+
data[:email] = sha256(payload[:email]) if payload[:email]
|
|
87
|
+
data[:phone] = sha256(payload[:phone].to_s.gsub(/\D/, "")) if payload[:phone]
|
|
88
|
+
data
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_properties(payload)
|
|
92
|
+
props = {}
|
|
93
|
+
props[:value] = payload[:value].to_f if payload[:value]
|
|
94
|
+
props[:currency] = payload[:currency] || "USD"
|
|
95
|
+
props[:content_id] = payload[:content_id] if payload[:content_id]
|
|
96
|
+
props[:content_type] = payload[:content_type] if payload[:content_type]
|
|
97
|
+
props[:quantity] = payload[:quantity].to_i if payload[:quantity]
|
|
98
|
+
props[:order_id] = payload[:order_id].to_s if payload[:order_id]
|
|
99
|
+
props
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def map_event_name(name)
|
|
103
|
+
STANDARD_EVENTS.find { |e| e.downcase == name.to_s.downcase } || name.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_user(user_data)
|
|
107
|
+
{
|
|
108
|
+
email: sha256(user_data[:email]),
|
|
109
|
+
phone: sha256(user_data[:phone].to_s.gsub(/\D/, ""))
|
|
110
|
+
}.compact
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omnitrack
|
|
4
|
+
module Concerns
|
|
5
|
+
# Include this concern in any controller to get convenient tracking helpers
|
|
6
|
+
# and automatic context enrichment.
|
|
7
|
+
#
|
|
8
|
+
# class ApplicationController < ActionController::Base
|
|
9
|
+
# include Omnitrack::Controller
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
module Controller
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
helper_method :omnitrack_context if respond_to?(:helper_method)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Access the current request context (already built by middleware)
|
|
20
|
+
def omnitrack_context
|
|
21
|
+
Omnitrack::Context.current || Omnitrack::Context.from_request(request)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Track an event from a controller action.
|
|
25
|
+
#
|
|
26
|
+
# def create
|
|
27
|
+
# @order = Order.create!(order_params)
|
|
28
|
+
# omnitrack_event("purchase", value: @order.total, currency: "USD")
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
def omnitrack_event(event_name, payload = {})
|
|
32
|
+
enriched = merge_context(payload)
|
|
33
|
+
Omnitrack.track(event_name, enriched)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Track a conversion from a controller action.
|
|
37
|
+
def omnitrack_conversion(data = {})
|
|
38
|
+
enriched = merge_context(data)
|
|
39
|
+
Omnitrack.track_conversion(enriched)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Identify the current user.
|
|
43
|
+
def omnitrack_identify(user_data = {})
|
|
44
|
+
Omnitrack.identify(user_data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Attach extra data to the current context (persists for this request only).
|
|
48
|
+
def omnitrack_set(data = {})
|
|
49
|
+
omnitrack_context.merge!(data)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Merge context data into payload so adapters have full picture
|
|
55
|
+
def merge_context(payload)
|
|
56
|
+
ctx = omnitrack_context
|
|
57
|
+
payload.merge(
|
|
58
|
+
__context__: ctx.to_h
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Shorthand for +Omnitrack::Concerns::Controller+
|
|
65
|
+
Controller = Concerns::Controller
|
|
66
|
+
end
|