feat(account): persist signup attribution (#14761)

This keeps Chatwoot-side attribution persistence intentionally small and
Enterprise-only.

The website owns attribution capture, normalization, and source
classification. Chatwoot Cloud only reads the already-shaped first-party
attribution cookies during the current web signup account creation path
and stores the decoded payload in internal account metadata. Self-hosted
installs remain unchanged in this repo.

## What changed

- Added Enterprise-only attribution persistence to the current Cloud web
signup account creation path.
- Stores attribution only when `ChatwootApp.chatwoot_cloud?` is true.
- Reads the existing first-touch and last-touch attribution cookies.
- Saves only the documented scalar attribution fields under account
internal metadata.
- Preserves raw attribution values and leaves escaping to display
boundaries.
- Bounds stored attribution values to the website field-size limit.
- Keeps OSS controller code unchanged.
- Keeps signup attribution request coverage in Enterprise specs.
- Avoids backend attribution derivation, referrer parsing, or fallback
classification.
- Skips authenticated add-workspace flows so additional workspaces are
not counted as signup attribution.
- Does not hook unused account creation paths or OmniAuth account
creation.

## How to test

- `bundle exec rubocop
spec/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb
enterprise/app/services/internal/accounts/marketing_attribution_service.rb
spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb`
- `bundle exec rspec spec/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb`
- On a cloud-like setup, create an account through the current web
signup path with attribution cookies and confirm account internal
metadata is populated.
- On a non-cloud setup or authenticated add-workspace flow, confirm
account-create behavior is unchanged and no attribution is stored.
This commit is contained in:
Sojan Jose
2026-06-19 11:01:12 -07:00
committed by GitHub
parent b02e732dd1
commit dfd656a7d9
5 changed files with 325 additions and 1 deletions
@@ -1,6 +1,20 @@
module Enterprise::Api::V1::AccountsSettings
def create
super
record_marketing_attribution
end
private
def record_marketing_attribution
return if current_user.present?
return if @account.blank?
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
# List of keys that can be managed through this service
# TODO: Add account_notes field in future
# This field can be used to store notes about account on Chatwoot cloud
VALID_KEYS = %w[manually_managed_features].freeze
VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze
def initialize(account)
@account = account
@@ -0,0 +1,82 @@
# frozen_string_literal: true
require 'base64'
class Internal::Accounts::MarketingAttributionService
FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution'
LAST_TOUCH_COOKIE = 'cw_last_touch_attribution'
FIELD_MAX_LENGTH = 500
ALLOWED_FIELDS = %w[
utm_source
utm_medium
utm_campaign
utm_term
utm_content
utm_id
gclid
gbraid
wbraid
dclid
fbclid
msclkid
ttclid
li_fat_id
twclid
rdt_cid
referrer
referrer_path
landing_page
source
source_type
captured_at
].freeze
pattr_initialize [:account!, :cookies!]
def perform
return unless ChatwootApp.chatwoot_cloud?
first_touch = attribution_cookie(FIRST_TOUCH_COOKIE)
last_touch = attribution_cookie(LAST_TOUCH_COOKIE)
return unless first_touch || last_touch
existing_attribution = internal_attributes_service.get('marketing_attribution') || {}
internal_attributes_service.set(
'marketing_attribution',
{
'first_touch' => first_touch || existing_attribution['first_touch'],
'last_touch' => last_touch || existing_attribution['last_touch'],
'captured_from' => 'cookie',
'stored_at' => Time.current.iso8601
}.compact
)
end
private
def attribution_cookie(cookie_name)
return if cookies[cookie_name].blank?
parse_cookie(cookies[cookie_name].to_s)
end
def parse_cookie(cookie_value)
validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value)))
rescue JSON::ParserError, ArgumentError
nil
end
def validate_payload(payload)
return unless payload.is_a?(Hash)
payload.slice(*ALLOWED_FIELDS).filter_map do |key, value|
next if value.blank? || value.is_a?(Array) || value.is_a?(Hash)
[key, value.to_s.first(FIELD_MAX_LENGTH)]
end.to_h.presence
end
def internal_attributes_service
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
end
end
@@ -0,0 +1,66 @@
require 'rails_helper'
require 'base64'
RSpec.describe 'Enterprise Accounts API', type: :request do
describe 'POST /api/v1/accounts' do
let(:email) { Faker::Internet.email }
let(:user_full_name) { Faker::Name.name_with_middle }
let(:first_touch_cookie) { Base64.urlsafe_encode64({ source: 'reddit', source_type: 'paid_social' }.to_json, padding: false) }
let(:last_touch_cookie) { Base64.urlsafe_encode64({ source: 'github', source_type: 'referral' }.to_json, padding: false) }
let(:attribution_cookie_header) do
{
'Cookie' => [
"#{Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE}=#{first_touch_cookie}",
"#{Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE}=#{last_touch_cookie}"
].join('; ')
}
end
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'records marketing attribution for unauthenticated signup requests' do
account_builder = double
account = create(:account)
user = create(:user, email: email, account: account, name: user_full_name)
allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform).and_return([user, account])
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: {
account_name: 'test',
email: email,
user: nil,
locale: nil,
user_full_name: user_full_name,
password: 'Password1!'
},
headers: attribution_cookie_header,
as: :json
end
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['captured_from']).to eq('cookie')
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
end
it 'does not record marketing attribution for authenticated add-workspace requests' do
existing_user = create(:user, password: 'Password1!')
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: { account_name: 'Second Account', email: existing_user.email,
user_full_name: existing_user.name, password: 'Password1!' },
headers: existing_user.create_new_auth_token.merge(attribution_cookie_header),
as: :json
end
account = Account.find(response.parsed_body.dig('data', 'account_id'))
expect(account.internal_attributes).not_to include('marketing_attribution')
end
end
end
@@ -0,0 +1,162 @@
# frozen_string_literal: true
require 'rails_helper'
require 'base64'
RSpec.describe Internal::Accounts::MarketingAttributionService do
let(:account) { create(:account) }
let(:cookies) { {} }
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'stores website attribution cookies on the account' do
cookies[described_class::FIRST_TOUCH_COOKIE] = encoded_cookie(
'source' => 'reddit',
'source_type' => 'paid_social',
'referrer' => 'https://reddit.com',
'referrer_path' => '/r/selfhosted/comments/123/chatwoot'
)
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
'source' => 'github',
'source_type' => 'referral'
)
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['captured_from']).to eq('cookie')
expect(attribution['first_touch']['source']).to eq('reddit')
expect(attribution['first_touch']['referrer_path']).to eq('/r/selfhosted/comments/123/chatwoot')
expect(attribution['last_touch']['source']).to eq('github')
end
it 'does not store attribution outside Chatwoot Cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'reddit')
described_class.new(account: account, cookies: cookies).perform
expect(account.reload.internal_attributes).not_to include('marketing_attribution')
end
it 'decodes base64url cookie values and preserves plus signs' do
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
'source' => 'google',
'utm_campaign' => 'C++ launch'
)
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['last_touch']['utm_campaign']).to eq('C++ launch')
end
it 'preserves an existing touch when the matching cookie is absent' do
account.update!(
internal_attributes: {
'marketing_attribution' => {
'first_touch' => { 'source' => 'reddit' },
'last_touch' => { 'source' => 'github' }
}
}
)
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['first_touch']['source']).to eq('reddit')
expect(attribution['last_touch']['source']).to eq('google')
end
it 'preserves other internal attributes' do
account.update!(internal_attributes: { 'manually_managed_features' => ['inbound_emails'] })
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
described_class.new(account: account, cookies: cookies).perform
expect(account.reload.internal_attributes['manually_managed_features']).to eq(['inbound_emails'])
end
it 'ignores parsed cookies that are not populated attribution objects' do
account.update!(
internal_attributes: {
'marketing_attribution' => {
'first_touch' => { 'source' => 'reddit' },
'last_touch' => { 'source' => 'github' }
}
}
)
cookies[described_class::FIRST_TOUCH_COOKIE] = {}.to_json
cookies[described_class::LAST_TOUCH_COOKIE] = [].to_json
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['first_touch']['source']).to eq('reddit')
expect(attribution['last_touch']['source']).to eq('github')
end
it 'stores only allowlisted scalar attribution fields' do
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
'source' => 'google',
'source_type' => 'paid_search',
'utm_campaign' => 'spring',
'unknown_field' => 'ignore me',
'nested' => { 'value' => 'ignore me' },
'array' => ['ignore me']
)
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['last_touch']).to eq(
'source' => 'google',
'source_type' => 'paid_search',
'utm_campaign' => 'spring'
)
end
it 'truncates oversized attribution values' do
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
'source' => 'google',
'utm_campaign' => 'a' * 600
)
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
end
it 'stores raw attribution values without escaping them' do
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
'source' => '<script>alert(1)</script>',
'utm_campaign' => 'launch & learn'
)
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['last_touch']['source']).to eq('<script>alert(1)</script>')
expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn')
end
it 'caps raw attribution values' do
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
'source' => 'google',
'utm_campaign' => '&' * 600
)
described_class.new(account: account, cookies: cookies).perform
attribution = account.reload.internal_attributes['marketing_attribution']
expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
end
def encoded_cookie(payload)
Base64.urlsafe_encode64(payload.to_json, padding: false)
end
end