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:
@@ -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
|
||||
Reference in New Issue
Block a user