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