chore: Add marketing conversion tracking foundation (#14851)
## Summary Adds the minimal foundation for Cloud marketing conversion tracking: - locked internal installation config for conversion tracking credentials and event mappings - generic background job and service for uploading conversion events from stored attribution - focused coverage for Cloud gating, click-id selection, payload shape, and optional conversion value This PR intentionally does not wire signup or plan activation yet. The next step is to validate the service from Rails console against existing attributed accounts, then add the event hooks in a follow-up PR. ## Notes The config remains locked and is surfaced under the Internal settings group next to the existing Cloud plan configuration. The service assumes the locked config is present and valid; config mistakes should surface instead of being silently ignored.
This commit is contained in:
@@ -43,13 +43,18 @@
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- MVP focus: Least code change, happy-path only
|
||||
- No unnecessary defensive programming
|
||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||
- Prefer the smallest production-ready change that solves the current problem.
|
||||
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
|
||||
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
|
||||
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
|
||||
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
|
||||
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
|
||||
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
|
||||
- Remove dead/unreachable/unused code
|
||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
|
||||
@@ -253,6 +253,12 @@
|
||||
display_title: 'Cloud Plans'
|
||||
value:
|
||||
description: 'Config to store stripe plans for cloud'
|
||||
- name: MARKETING_CONVERSION_TRACKING_CONFIG
|
||||
value:
|
||||
display_title: 'Marketing Conversion Tracking Config'
|
||||
description: 'JSON config for Chatwoot Cloud signup and plan activation conversion tracking'
|
||||
locked: true
|
||||
type: code
|
||||
- name: CHATWOOT_CLOUD_PLAN_FEATURES
|
||||
display_title: 'Planwise Features List'
|
||||
value:
|
||||
|
||||
@@ -35,9 +35,9 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY CONTEXT_DEV_API_KEY DASHBOARD_SCRIPTS
|
||||
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
|
||||
CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
|
||||
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
|
||||
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS MARKETING_CONVERSION_TRACKING_CONFIG
|
||||
ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY
|
||||
CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
|
||||
end
|
||||
|
||||
def captain_config_options
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Internal::Accounts::MarketingConversionTrackingJob < ApplicationJob
|
||||
queue_as :purgable
|
||||
|
||||
def perform(account_id, event_name, occurred_at = nil, conversion_value = nil, currency_code = nil)
|
||||
Internal::Accounts::MarketingConversionTrackingService.new(
|
||||
account: Account.find(account_id),
|
||||
event_name: event_name,
|
||||
occurred_at: occurred_at,
|
||||
conversion_value: conversion_value,
|
||||
currency_code: currency_code
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'googleauth'
|
||||
|
||||
class Internal::Accounts::MarketingConversionTrackingService
|
||||
CONFIG_KEY = 'MARKETING_CONVERSION_TRACKING_CONFIG'
|
||||
# Expected config shape:
|
||||
# {
|
||||
# "customer_id": "123-456-7890",
|
||||
# "login_customer_id": "123-456-7890",
|
||||
# "service_account_credentials": { ... },
|
||||
# "events": {
|
||||
# "cloud_signup": { "conversion_action_id": "123456789" },
|
||||
# "cloud_plan_activation": { "conversion_action_id": "987654321" }
|
||||
# }
|
||||
# }
|
||||
TOKEN_SCOPES = ['https://www.googleapis.com/auth/datamanager'].freeze
|
||||
API_URL = 'https://datamanager.googleapis.com/v1/events:ingest'
|
||||
CLICK_ID_FIELDS = %w[gclid gbraid wbraid].freeze
|
||||
|
||||
pattr_initialize [:account!, :event_name!, :occurred_at, :conversion_value, :currency_code]
|
||||
|
||||
def perform
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
return if click_attributes.blank?
|
||||
|
||||
response = HTTParty.post(
|
||||
API_URL,
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
},
|
||||
body: {
|
||||
destinations: [destination_payload],
|
||||
events: [conversion_payload]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
raise "Marketing conversion upload failed: #{response.body}" unless response.success?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def destination_payload
|
||||
{
|
||||
operatingAccount: {
|
||||
accountType: 'GOOGLE_ADS',
|
||||
accountId: config['customer_id'].delete('-')
|
||||
},
|
||||
loginAccount: {
|
||||
accountType: 'GOOGLE_ADS',
|
||||
accountId: config['login_customer_id'].delete('-')
|
||||
},
|
||||
productDestinationId: config['events'][event_name]['conversion_action_id']
|
||||
}
|
||||
end
|
||||
|
||||
def conversion_payload
|
||||
payload = {
|
||||
transactionId: "#{event_name}-account-#{account.id}",
|
||||
eventTimestamp: (occurred_at.present? ? Time.zone.parse(occurred_at.to_s) : Time.current).iso8601,
|
||||
eventSource: 'WEB',
|
||||
adIdentifiers: click_attributes
|
||||
}
|
||||
|
||||
if conversion_value.present?
|
||||
payload[:conversionValue] = conversion_value.to_f
|
||||
payload[:currency] = currency_code.presence || 'USD'
|
||||
end
|
||||
|
||||
payload
|
||||
end
|
||||
|
||||
def click_attributes
|
||||
@click_attributes ||= CLICK_ID_FIELDS.filter_map do |field|
|
||||
value = attribution[field]
|
||||
[field.to_sym, value] if value.present?
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def attribution
|
||||
marketing_attribution = account.internal_attributes['marketing_attribution'] || {}
|
||||
[marketing_attribution['last_touch'], marketing_attribution['first_touch']].find do |touch|
|
||||
touch.present? && CLICK_ID_FIELDS.any? { |field| touch[field].present? }
|
||||
end || {}
|
||||
end
|
||||
|
||||
def access_token
|
||||
authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
|
||||
json_key_io: StringIO.new(config['service_account_credentials'].to_json),
|
||||
scope: TOKEN_SCOPES
|
||||
)
|
||||
authorizer.fetch_access_token!['access_token']
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= JSON.parse(InstallationConfig.find_by!(name: CONFIG_KEY).value)
|
||||
end
|
||||
end
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::Accounts::MarketingConversionTrackingService do
|
||||
let(:account) { create(:account) }
|
||||
let(:event_name) { 'cloud_signup' }
|
||||
let(:occurred_at) { '2026-06-23T10:30:00Z' }
|
||||
let(:private_key) { OpenSSL::PKey::RSA.new(2048).to_pem }
|
||||
let(:credentials) do
|
||||
instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'access-token' })
|
||||
end
|
||||
let(:config) do
|
||||
{
|
||||
'customer_id' => '852-320-2898',
|
||||
'login_customer_id' => '742-202-9198',
|
||||
'service_account_credentials' => {
|
||||
'client_email' => 'marketing-conversions@chatwoot-production.iam.gserviceaccount.com',
|
||||
'private_key' => private_key
|
||||
},
|
||||
'events' => {
|
||||
'cloud_signup' => {
|
||||
'conversion_action_id' => '123456789'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
let(:marketing_attribution) do
|
||||
{
|
||||
'first_touch' => { 'gclid' => 'first-click' },
|
||||
'last_touch' => { 'gclid' => 'last-click' }
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: described_class::CONFIG_KEY, value: config.to_json)
|
||||
account.update!(internal_attributes: { 'marketing_attribution' => marketing_attribution })
|
||||
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(credentials)
|
||||
end
|
||||
|
||||
it 'does nothing outside Chatwoot Cloud' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
|
||||
expect(HTTParty).not_to receive(:post)
|
||||
|
||||
described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform
|
||||
end
|
||||
|
||||
it 'uploads the last-touch click conversion', :aggregate_failures do
|
||||
upload_request = nil
|
||||
|
||||
allow(HTTParty).to receive(:post) do |url, options|
|
||||
upload_request = [url, options]
|
||||
instance_double(HTTParty::Response, success?: true, body: '{}')
|
||||
end
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
event_name: event_name,
|
||||
occurred_at: occurred_at,
|
||||
conversion_value: 199,
|
||||
currency_code: 'USD'
|
||||
).perform
|
||||
|
||||
url, options = upload_request
|
||||
body = JSON.parse(options[:body])
|
||||
|
||||
expect(url).to eq('https://datamanager.googleapis.com/v1/events:ingest')
|
||||
expect(Google::Auth::ServiceAccountCredentials).to have_received(:make_creds).with(
|
||||
json_key_io: kind_of(StringIO),
|
||||
scope: ['https://www.googleapis.com/auth/datamanager']
|
||||
)
|
||||
expect(options[:headers]).to include(
|
||||
'Authorization' => 'Bearer access-token'
|
||||
)
|
||||
expect(body['destinations'].first).to include(
|
||||
'operatingAccount' => {
|
||||
'accountType' => 'GOOGLE_ADS',
|
||||
'accountId' => '8523202898'
|
||||
},
|
||||
'loginAccount' => {
|
||||
'accountType' => 'GOOGLE_ADS',
|
||||
'accountId' => '7422029198'
|
||||
},
|
||||
'productDestinationId' => '123456789'
|
||||
)
|
||||
expect(body['events'].first).to include(
|
||||
'transactionId' => "cloud_signup-account-#{account.id}",
|
||||
'eventTimestamp' => '2026-06-23T10:30:00Z',
|
||||
'eventSource' => 'WEB',
|
||||
'adIdentifiers' => { 'gclid' => 'last-click' },
|
||||
'conversionValue' => 199.0,
|
||||
'currency' => 'USD'
|
||||
)
|
||||
end
|
||||
|
||||
it 'falls back to first-touch attribution when last-touch attribution has no click id' do
|
||||
account.update!(
|
||||
internal_attributes: {
|
||||
'marketing_attribution' => {
|
||||
'last_touch' => { 'source' => 'github' },
|
||||
'first_touch' => { 'gclid' => 'first-click' }
|
||||
}
|
||||
}
|
||||
)
|
||||
upload_body = nil
|
||||
|
||||
allow(HTTParty).to receive(:post) do |_url, options|
|
||||
upload_body = JSON.parse(options[:body])
|
||||
instance_double(HTTParty::Response, success?: true, body: '{}')
|
||||
end
|
||||
|
||||
described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform
|
||||
|
||||
expect(upload_body['events'].first['adIdentifiers']['gclid']).to eq('first-click')
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user