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