feat: read structured X-Chatwoot-* headers for mobile session metadata (#14762)
Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1 UA pattern fallback, already merged). When the request carries an \`X-Chatwoot-Client-Name\` header, \`UserSessionTrackingService\` now prefers the five structured \`X-Chatwoot-*\` headers over the User-Agent for populating the \`user_sessions\` row: | Header | Mapped column | |---|---| | \`X-Chatwoot-Client-Name\` | \`browser_name\` | | \`X-Chatwoot-Client-Version\` | \`browser_version\` | | \`X-Chatwoot-Device-Model\` | \`platform_name\` | | \`X-Chatwoot-Platform-Version\` | \`platform_version\` | | \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` / \`iPad\` / \`Android\`) | When the header is absent or blank, the existing \`Browser.new\` path runs, with the Phase 1 legacy UA fallback still acting as the floor. Real browsers are untouched. A follow-up PR in [chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app) will start sending these headers from the React Native build. Until that ships, this PR is a no-op for production traffic, so it can land independently. Fixes INF-75.
This commit is contained in:
@@ -31,6 +31,14 @@ class UserSessionTrackingService
|
||||
private
|
||||
|
||||
def session_attributes
|
||||
client_headers = mobile_client_headers
|
||||
if client_headers
|
||||
return client_headers.merge(
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent
|
||||
)
|
||||
end
|
||||
|
||||
browser = Browser.new(@request.user_agent)
|
||||
|
||||
attrs = {
|
||||
@@ -46,6 +54,30 @@ class UserSessionTrackingService
|
||||
patch_for_legacy_mobile(attrs)
|
||||
end
|
||||
|
||||
def mobile_client_headers
|
||||
name = @request.headers['X-Chatwoot-Client-Name']
|
||||
return nil if name.blank?
|
||||
|
||||
platform = @request.headers['X-Chatwoot-Platform']
|
||||
model = @request.headers['X-Chatwoot-Device-Model']
|
||||
|
||||
{
|
||||
browser_name: name,
|
||||
browser_version: @request.headers['X-Chatwoot-Client-Version'],
|
||||
device_name: device_name_for_icon(platform, model),
|
||||
platform_name: model,
|
||||
platform_version: @request.headers['X-Chatwoot-Platform-Version']
|
||||
}
|
||||
end
|
||||
|
||||
def device_name_for_icon(platform, model)
|
||||
normalized_platform = platform.to_s.downcase
|
||||
return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad')
|
||||
return 'iPhone' if normalized_platform == 'ios'
|
||||
|
||||
'Android'
|
||||
end
|
||||
|
||||
def patch_for_legacy_mobile(attrs)
|
||||
return attrs unless attrs[:browser_name] == 'Unknown Browser'
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ require 'rails_helper'
|
||||
RSpec.describe UserSessionTrackingService do
|
||||
let(:user) { create(:user) }
|
||||
let(:client_id) { 'client-abc' }
|
||||
let(:ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
|
||||
let(:headers) { {} }
|
||||
let(:request) do
|
||||
instance_double(
|
||||
ActionDispatch::Request,
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15',
|
||||
remote_ip: '8.8.8.8'
|
||||
user_agent: ua,
|
||||
remote_ip: '8.8.8.8',
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
|
||||
@@ -49,14 +52,6 @@ RSpec.describe UserSessionTrackingService do
|
||||
end
|
||||
|
||||
context 'with a Chatwoot Mobile legacy User-Agent' do
|
||||
let(:request) do
|
||||
instance_double(
|
||||
ActionDispatch::Request,
|
||||
user_agent: ua,
|
||||
remote_ip: '8.8.8.8'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the UA is okhttp (Android Chatwoot Mobile)' do
|
||||
let(:ua) { 'okhttp/4.9.2' }
|
||||
|
||||
@@ -116,6 +111,113 @@ RSpec.describe UserSessionTrackingService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with X-Chatwoot-* structured headers' do
|
||||
let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' }
|
||||
|
||||
context 'when platform is ios and model is an iPhone' do
|
||||
let(:headers) do
|
||||
{
|
||||
'X-Chatwoot-Client-Name' => 'Chatwoot Mobile',
|
||||
'X-Chatwoot-Client-Version' => '4.7.0',
|
||||
'X-Chatwoot-Platform' => 'ios',
|
||||
'X-Chatwoot-Platform-Version' => '18.2',
|
||||
'X-Chatwoot-Device-Model' => 'iPhone 15 Pro'
|
||||
}
|
||||
end
|
||||
|
||||
it 'maps the headers into the session columns', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Chatwoot Mobile')
|
||||
expect(session.browser_version).to eq('4.7.0')
|
||||
expect(session.platform_name).to eq('iPhone 15 Pro')
|
||||
expect(session.platform_version).to eq('18.2')
|
||||
expect(session.device_name).to eq('iPhone')
|
||||
expect(session.user_agent).to eq(ua)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when platform is ios and model is an iPad' do
|
||||
let(:headers) do
|
||||
{
|
||||
'X-Chatwoot-Client-Name' => 'Chatwoot Mobile',
|
||||
'X-Chatwoot-Client-Version' => '4.7.0',
|
||||
'X-Chatwoot-Platform' => 'ios',
|
||||
'X-Chatwoot-Platform-Version' => '18.2',
|
||||
'X-Chatwoot-Device-Model' => 'iPad Pro 11-inch'
|
||||
}
|
||||
end
|
||||
|
||||
it 'sets device_name to iPad so the tablet icon renders', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Chatwoot Mobile')
|
||||
expect(session.platform_name).to eq('iPad Pro 11-inch')
|
||||
expect(session.device_name).to eq('iPad')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when platform is android' do
|
||||
let(:ua) { 'okhttp/4.9.2' }
|
||||
let(:headers) do
|
||||
{
|
||||
'X-Chatwoot-Client-Name' => 'Chatwoot Mobile',
|
||||
'X-Chatwoot-Client-Version' => '4.7.0',
|
||||
'X-Chatwoot-Platform' => 'android',
|
||||
'X-Chatwoot-Platform-Version' => '14',
|
||||
'X-Chatwoot-Device-Model' => 'Pixel 7 Pro'
|
||||
}
|
||||
end
|
||||
|
||||
it 'maps the headers into the session columns', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Chatwoot Mobile')
|
||||
expect(session.browser_version).to eq('4.7.0')
|
||||
expect(session.platform_name).to eq('Pixel 7 Pro')
|
||||
expect(session.platform_version).to eq('14')
|
||||
expect(session.device_name).to eq('Android')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when X-Chatwoot-Client-Name is blank' do
|
||||
let(:ua) { 'okhttp/4.9.2' }
|
||||
let(:headers) do
|
||||
{
|
||||
'X-Chatwoot-Client-Name' => '',
|
||||
'X-Chatwoot-Platform' => 'android',
|
||||
'X-Chatwoot-Device-Model' => 'Pixel 7 Pro'
|
||||
}
|
||||
end
|
||||
|
||||
it 'falls through to the legacy UA fallback', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Chatwoot Mobile')
|
||||
expect(session.platform_name).to eq('Android')
|
||||
expect(session.platform_version).to be_nil
|
||||
expect(session.device_name).to eq('Android')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no X-Chatwoot-* headers are sent (real browser)' do
|
||||
let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' }
|
||||
let(:headers) { {} }
|
||||
|
||||
it 'falls through to the Browser.new path', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Firefox')
|
||||
expect(session.platform_name).to eq('Generic Linux')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_activity!' do
|
||||
|
||||
Reference in New Issue
Block a user