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:
Sojan Jose
2026-06-24 18:27:02 -07:00
committed by GitHub
parent f9cc702030
commit cd1a214d98
6 changed files with 250 additions and 6 deletions
+8 -3
View File
@@ -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
- Dont 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
+6
View File
@@ -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
@@ -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