From e78562092185b7fe86a465f1b901dd0595f4899a Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 23 Jun 2026 20:08:27 +0530 Subject: [PATCH] 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. --- app/services/user_session_tracking_service.rb | 32 +++++ .../user_session_tracking_service_spec.rb | 122 ++++++++++++++++-- 2 files changed, 144 insertions(+), 10 deletions(-) diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb index d7e6f6efb..b84693b59 100644 --- a/app/services/user_session_tracking_service.rb +++ b/app/services/user_session_tracking_service.rb @@ -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' diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb index 2b98b95bf..fb233c48a 100644 --- a/spec/services/user_session_tracking_service_spec.rb +++ b/spec/services/user_session_tracking_service_spec.rb @@ -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