UTA DevHub

User-Agent Headers

Implementation guide for custom User-Agent headers in API requests

User-Agent Headers

Overview

This document outlines our approach to implementing and managing custom User-Agent headers in API requests. The User-Agent header provides valuable information about the client application, device, and environment to backend services, supporting analytics, troubleshooting, and client-specific optimizations.

Our implementation builds upon the established Header Customization Architecture architecture, extending it with specific patterns for creating, standardizing, and managing User-Agent headers across all API clients.

Quick Start

To quickly implement custom User-Agent headers in your application, follow these steps:

  1. Define User-Agent constants:

    // core/shared/constants/headers.ts
    export const API_HEADERS = {
      // Other headers...
      USER_AGENT: 'User-Agent',
      DEVICE_TYPE: 'X-Device-Type'
    };
  2. Create a basic User-Agent provider:

    // core/shared/api/headers/providers/userAgentHeaderProvider.ts
    import { HeaderProvider } from '../types';
    import { API_HEADERS } from '@/core/shared/constants/headers';
    import DeviceInfo from 'react-native-device-info';
    import { Platform } from 'react-native';
    import { AppConfig } from '@/core/shared/config/appConfig';
     
    export class UserAgentHeaderProvider implements HeaderProvider {
      id = 'user-agent';
      priority = 95; // High priority
      
      async getHeaders() {
        const appName = AppConfig.APP_NAME;
        const appVersion = DeviceInfo.getVersion();
        const device = await DeviceInfo.getModel();
        const osName = Platform.OS;
        const osVersion = await DeviceInfo.getSystemVersion();
        
        const userAgent = `${appName}/${appVersion} (${device}; ${osName} ${osVersion})`;
        
        return {
          'User-Agent': userAgent,
          [API_HEADERS.DEVICE_TYPE]: Platform.OS
        };
      }
    }
  3. Register with your API client:

    // core/shared/api/client.ts
    import { UserAgentHeaderProvider } from './headers/providers/userAgentHeaderProvider';
     
    // Create and register the provider
    const userAgentProvider = new UserAgentHeaderProvider();
    apiClient.registerHeaderProvider(userAgentProvider);
  4. Verify implementation:

    // Anywhere in your app
    console.log('Current User-Agent:', 
      await apiClient.getLastRequestHeader('User-Agent'));

For more advanced features like extensions, caching, and domain-specific customizations, continue reading the sections below.

Purpose & Scope

This guide establishes:

  • A standardized format for User-Agent headers in our application
  • Implementation patterns for dynamic User-Agent generation
  • Methods for extending the base User-Agent with feature-specific information
  • Testing strategies for User-Agent implementations
  • Analytics and monitoring considerations

This document is intended for developers working on API integrations who need to implement proper client identification and versioning across API requests.

Prerequisites

To effectively use this guide, you should be familiar with:

User-Agent Format Specification

Our custom User-Agent follows a structured format that provides comprehensive client information while maintaining readability:

AppName/AppVersion (Device; OS OSVersion; Environment) Extension/ExtensionVersion

Where:

  • AppName: The name of our application (e.g., "MyApp")
  • AppVersion: Semantic version of the application (e.g., "1.2.3")
  • Device: Device model information (e.g., "iPhone13,4")
  • OS: Operating system name (e.g., "iOS")
  • OSVersion: Operating system version (e.g., "15.4.1")
  • Environment: Optional environment identifier (e.g., "DEV", "STAGING", "PROD")
  • Extension: Optional component or library identifier
  • ExtensionVersion: Version of the extension component

Examples:

MyApp/1.2.3 (iPhone13,4; iOS 15.4.1; PROD)
MyApp/1.0.0-beta.2 (SM-G998B; Android 12; DEV) Analytics/2.1.0

This structured format allows server-side systems to easily parse and extract client information for analytics, feature toggling, client-specific optimizations, and debugging.

Core Components

1. User-Agent Provider

Building on the HeaderProvider interface from the header-customization document, we implement a specialized provider for User-Agent headers:

// core/shared/api/headers/providers/userAgentHeaderProvider.ts
import { HeaderProvider } from '../types';
import { API_HEADERS } from '@/core/shared/constants/headers';
import DeviceInfo from 'react-native-device-info';
import { Platform } from 'react-native';
import { AppConfig } from '@/core/shared/config/appConfig';
 
export class UserAgentHeaderProvider implements HeaderProvider {
  id = 'user-agent';
  priority = 95; // High priority - should be applied before most other headers
  
  // Optional extensions to the base User-Agent
  private extensions: Array<{name: string, version: string}> = [];
  
  /**
   * Registers an extension to be included in the User-Agent string
   * @param name Extension name
   * @param version Extension version
   */
  public registerExtension(name: string, version: string): void {
    this.extensions = this.extensions.filter(ext => ext.name !== name);
    this.extensions.push({ name, version });
  }
  
  /**
   * Builds the complete User-Agent string based on app, device, and extensions
   */
  private async buildUserAgent(): Promise<string> {
    // Get app information
    const appName = AppConfig.APP_NAME;
    const appVersion = DeviceInfo.getVersion();
    const buildNumber = DeviceInfo.getBuildNumber();
    
    // Get device and OS information
    const deviceId = DeviceInfo.getModel();
    const deviceType = Platform.OS;
    const osVersion = DeviceInfo.getSystemVersion();
    
    // Get environment
    const environment = AppConfig.ENVIRONMENT;
    
    // Build the core User-Agent string
    let userAgent = `${appName}/${appVersion}+${buildNumber} (${deviceId}; ${deviceType} ${osVersion}; ${environment})`;
    
    // Add extensions if present
    if (this.extensions.length > 0) {
      const extensionsString = this.extensions
        .map(ext => `${ext.name}/${ext.version}`)
        .join(' ');
      userAgent += ` ${extensionsString}`;
    }
    
    return userAgent;
  }
  
  async getHeaders(): Promise<Record<string, string | undefined>> {
    const userAgent = await this.buildUserAgent();
    
    return {
      'User-Agent': userAgent,
      // Optionally include a custom header as well
      [API_HEADERS.DEVICE_TYPE]: Platform.OS,
    };
  }
}
 
// Export a singleton instance for global use
export const userAgentProvider = new UserAgentHeaderProvider();

2. Adding User-Agent Headers to API Constants

Extend the existing header constants to include User-Agent related headers:

// core/shared/constants/headers.ts
export const API_HEADERS = {
  // ... existing headers from API Header Customization
 
  // User-Agent related headers
  USER_AGENT: 'User-Agent',
  CLIENT_VERSION: 'X-Client-Version',
  CLIENT_TYPE: 'X-Client-Type',
  CLIENT_CHANNEL: 'X-Client-Channel',
  
  // ... other headers
} as const;

3. Configuration Provider

Create a central configuration source for User-Agent components:

// core/shared/config/appConfig.ts
import { Platform } from 'react-native';
 
export enum Environment {
  DEVELOPMENT = 'DEV',
  STAGING = 'STAGING',
  PRODUCTION = 'PROD',
}
 
export const AppConfig = {
  APP_NAME: 'MyApp',
  APP_BUNDLE_ID: Platform.select({
    ios: 'com.company.myapp',
    android: 'com.company.myapp',
  }),
  ENVIRONMENT: __DEV__ ? Environment.DEVELOPMENT : Environment.PRODUCTION,
  
  // Function to override environment (useful for testing)
  setEnvironment: (env: Environment) => {
    AppConfig.ENVIRONMENT = env;
  },
} as const;

Implementation Patterns

1. Basic Implementation with API Clients

Register the User-Agent provider with API clients during initialization:

// core/shared/api/clients.ts
import { PublicApiClient, AuthenticatedApiClient } from '@/core/shared/api/client';
import { userAgentProvider } from '@/core/shared/api/headers/providers/userAgentHeaderProvider';
 
// Create API client instances
const apiConfig = {
  baseURL: process.env.API_BASE_URL,
  timeout: 30000,
};
 
// Initialize API clients
export const publicApi = new PublicApiClient(apiConfig);
export const authenticatedApi = new AuthenticatedApiClient(apiConfig);
 
// Register the User-Agent provider with all API clients
// Casting to 'any' here because registerHeaderProvider is protected in BaseApiClient
(publicApi as any).registerHeaderProvider(userAgentProvider);
(authenticatedApi as any).registerHeaderProvider(userAgentProvider);

2. Feature-Specific Extensions

Extend the User-Agent with feature-specific information:

// features/analytics/services/analyticsService.ts
import { userAgentProvider } from '@/core/shared/api/headers/providers/userAgentHeaderProvider';
 
export class AnalyticsService {
  constructor() {
    // Register the analytics extension in the User-Agent
    userAgentProvider.registerExtension('Analytics', '2.1.0');
  }
  
  // Analytics service methods...
}
 
// Initialize the service
export const analyticsService = new AnalyticsService();

3. Dynamic User-Agent Composition

For User-Agent properties that change during app usage:

// core/domains/tenant/store.ts
import { create } from 'zustand';
import { userAgentProvider } from '@/core/shared/api/headers/providers/userAgentHeaderProvider';
 
interface TenantState {
  tenantId: string | null;
  workspace: string | null;
  setTenant: (id: string | null, workspace: string | null) => void;
}
 
export const useTenantStore = create<TenantState>((set) => ({
  tenantId: null,
  workspace: null,
  setTenant: (tenantId, workspace) => {
    set({ tenantId, workspace });
    
    // Update the User-Agent extension when tenant changes
    if (tenantId && workspace) {
      userAgentProvider.registerExtension('Tenant', `${workspace}@${tenantId}`);
    }
  },
}));

4. Enhanced User-Agent Provider with Caching

For performance optimization, especially on slower devices:

// Enhanced version with caching
export class CachedUserAgentHeaderProvider extends UserAgentHeaderProvider {
  private cachedUserAgent: string | null = null;
  private cacheExpiry: number = 0;
  private readonly CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
  
  async getHeaders(): Promise<Record<string, string | undefined>> {
    // Only rebuild the User-Agent string if the cache has expired
    if (!this.cachedUserAgent || Date.now() > this.cacheExpiry) {
      this.cachedUserAgent = await this.buildUserAgent();
      this.cacheExpiry = Date.now() + this.CACHE_DURATION;
    }
    
    return {
      'User-Agent': this.cachedUserAgent,
      [API_HEADERS.DEVICE_TYPE]: Platform.OS,
    };
  }
  
  // When an extension is registered, invalidate the cache
  public registerExtension(name: string, version: string): void {
    super.registerExtension(name, version);
    this.cachedUserAgent = null; // Force rebuild on next getHeaders call
  }
}

Testing User-Agent Implementation

// __tests__/core/shared/api/headers/providers/userAgentHeaderProvider.test.ts
import { UserAgentHeaderProvider } from '@/core/shared/api/headers/providers/userAgentHeaderProvider';
import DeviceInfo from 'react-native-device-info';
import { Platform } from 'react-native';
import { AppConfig, Environment } from '@/core/shared/config/appConfig';
 
// Mock external dependencies
jest.mock('react-native-device-info');
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
  OS: 'ios',
  select: jest.fn(obj => obj.ios)
}));
 
describe('UserAgentHeaderProvider', () => {
  beforeEach(() => {
    // Setup mocks with default test values
    (DeviceInfo.getVersion as jest.Mock).mockReturnValue('1.2.3');
    (DeviceInfo.getBuildNumber as jest.Mock).mockReturnValue('100');
    (DeviceInfo.getModel as jest.Mock).mockReturnValue('iPhone13,4');
    (DeviceInfo.getSystemVersion as jest.Mock).mockReturnValue('15.4.1');
    
    // Reset AppConfig to default test state
    AppConfig.ENVIRONMENT = Environment.DEVELOPMENT;
  });
  
  it('should generate properly formatted User-Agent string', async () => {
    const provider = new UserAgentHeaderProvider();
    const headers = await provider.getHeaders();
    
    expect(headers['User-Agent']).toMatch(/^MyApp\/1\.2\.3\+100 \(iPhone13,4; ios 15\.4\.1; DEV\)$/);
  });
  
  it('should include registered extensions in User-Agent', async () => {
    const provider = new UserAgentHeaderProvider();
    provider.registerExtension('Testing', '1.0.0');
    
    const headers = await provider.getHeaders();
    
    expect(headers['User-Agent']).toMatch(/Testing\/1\.0\.0$/);
  });
  
  it('should include multiple extensions in order', async () => {
    const provider = new UserAgentHeaderProvider();
    provider.registerExtension('Extension1', '1.0.0');
    provider.registerExtension('Extension2', '2.0.0');
    
    const headers = await provider.getHeaders();
    
    expect(headers['User-Agent']).toMatch(/Extension1\/1\.0\.0 Extension2\/2\.0\.0$/);
  });
  
  it('should replace extension with same name', async () => {
    const provider = new UserAgentHeaderProvider();
    provider.registerExtension('Extension', '1.0.0');
    provider.registerExtension('Extension', '1.1.0');
    
    const headers = await provider.getHeaders();
    
    expect(headers['User-Agent']).toMatch(/Extension\/1\.1\.0$/);
    expect(headers['User-Agent']).not.toMatch(/Extension\/1\.0\.0/);
  });
  
  it('should use production environment in User-Agent when set', async () => {
    AppConfig.ENVIRONMENT = Environment.PRODUCTION;
    
    const provider = new UserAgentHeaderProvider();
    const headers = await provider.getHeaders();
    
    expect(headers['User-Agent']).toMatch(/; PROD\)/);
  });
});

Server-Side User-Agent Handling

While server-side implementation is beyond the scope of this document, understanding how your User-Agent is processed is valuable for client-side developers.

Expected Server-Side Processing

// Server-side pseudocode for User-Agent parsing
function parseUserAgent(userAgentString) {
  // Regular expression for our custom format
  const regex = /^([^\/]+)\/([^\s]+)\s+\(([^;]+);\s*([^;]+)\s+([^;]+);\s*([^\)]+)\)(?:\s+(.+))?$/;
  const match = userAgentString.match(regex);
  
  if (!match) return null;
  
  return {
    app: {
      name: match[1],
      version: match[2],
    },
    device: {
      model: match[3],
      os: match[4],
      osVersion: match[5],
    },
    environment: match[6],
    extensions: match[7] ? parseExtensions(match[7]) : [],
  };
}
 
function parseExtensions(extensionsString) {
  // Split space-separated extensions and parse each one
  return extensionsString.split(' ').map(extension => {
    const [name, version] = extension.split('/');
    return { name, version };
  });
}

Best Practices

User-Agent Construction

  1. (Do ✅) Follow the Standardized Format: Adhere to the specified format for consistency and reliable parsing.

  2. (Do ✅) Include Essential Information: Always include app name, version, device model, OS, and OS version.

  3. (Do ✅) Use Semantic Versioning: Follow semantic versioning for all version strings (X.Y.Z format).

  4. (Consider 🤔) Include Build Number: For more precise version tracking, include build number after the semantic version.

  5. (Don't ❌) Include Sensitive Information: Never include user identifiers, authentication tokens, or other sensitive data.

Feature Extensions

  1. (Do ✅) Use Descriptive Extension Names: Choose clear, concise names that identify the feature or module.

  2. (Do ✅) Version Extensions Separately: Each extension should have its own version that can evolve independently.

  3. (Do ✅) Register Extensions Early: Register extensions during module initialization to ensure they're included in all requests.

  4. (Don't ❌) Over-extend: Limit extensions to important modules that require tracking. Too many extensions make the User-Agent unwieldy.

Performance Considerations

  1. (Consider 🤔) Implement Caching: For better performance, cache the User-Agent string and rebuild only when necessary.

  2. (Do ✅) Lazy-load Device Information: Some device information is expensive to collect; consider lazy-loading these values.

  3. (Consider 🤔) Use Async Factory Pattern: If initialization is complex, consider an async factory pattern for the provider.

Analytics and Monitoring

Tracking Client Distribution

The User-Agent header can be utilized by analytics systems to track client distribution metrics:

  • App version adoption rates
  • OS version distribution
  • Device type popularity
  • Environment usage (useful for pre-release testing)

Error Correlation

User-Agents can help correlate errors with specific client configurations:

  • Identify version-specific bugs
  • Discover device-specific issues
  • Monitor error rates across different OS versions

Design Principles

Core Architectural Principles

We've designed our User-Agent implementation around these key principles to ensure it meets both technical and business needs:

  1. Standardization

    • Consistent User-Agent format across all API clients helps create a unified approach
    • Predictable extension mechanism makes it easy for teams to add their components
    • Reliable parsing patterns enable straightforward server-side analysis
  2. Extensibility

    • Modules can register their own extensions, allowing feature teams to work independently
    • Format accommodates future additions without requiring architectural changes
    • Dynamic composition based on application state provides contextual information
  3. Performance

    • Efficient generation with caching minimizes impact on application responsiveness
    • Minimal impact on API request overhead keeps network operations fast
    • Optimized for mobile environments where resources and battery life matter

Trade-offs and Design Decisions

DecisionBenefitsDrawbacksRationale
Custom format vs. standard browser formatTailored for mobile app needs, structured for easy parsingLess familiar to developers used to browser User-AgentsMobile apps have different identification needs than browsers
Extension mechanismModular, allows features to self-identifyIncreases User-Agent lengthThe benefits of feature-specific tracking outweigh the minimal size increase
Singleton provider instanceConsistent User-Agent across all clients, simpler state managementLess flexibility for different clientsConsistency in client identification is more important than per-client customization
Caching User-Agent stringImproved performance, especially on slower devicesPotential staleness if device properties changeDevice properties rarely change during app execution; performance gains justify minimal risk

Migration Considerations

If you're transitioning to this structured User-Agent approach, consider the following migration paths:

From Default User-Agent

If you're currently using the default React Native User-Agent or no User-Agent at all:

  1. Document current backend dependencies - Identify any server-side code that may depend on existing User-Agent patterns
  2. Create a mapping - Develop a mapping between existing User-Agent data and your new format
  3. Implement dual processing - Configure backend services to handle both formats during transition
  4. Roll out incrementally - Deploy to a subset of users first to verify backend compatibility
  5. Monitor for errors - Watch for unexpected behaviors related to User-Agent parsing

From Unstructured Custom User-Agent

If you already use a custom User-Agent but want to standardize it:

  1. Parse and map - Analyze your current format and map fields to the new structure
  2. Server-side updates - Update any server-side parsing logic to handle the new format
  3. Maintain compatibility - Consider including legacy identifiers alongside new format initially
  4. Version your format - Include format version markers if making significant structural changes
  5. Staggered deployment - Deploy to internal users first, then beta users, then production

From Multiple User-Agent Strategies

If different API clients currently use different User-Agent strategies:

  1. Inventory current approaches - Document all existing User-Agent implementations
  2. Unified provider - Implement the central UserAgentHeaderProvider
  3. Progressive migration - Convert one client at a time to the new provider
  4. Validate each migration - Test each API client thoroughly after conversion
  5. Remove legacy code - Clean up old User-Agent code after full migration

Summary

Implementing a standardized User-Agent format provides valuable insights for analytics, debugging, and client-specific optimizations. By following the patterns in this document, you can ensure consistent, informative client identification across all API requests while maintaining flexibility for feature-specific extensions.

Key takeaways:

  • Use the structured User-Agent format to provide comprehensive client information
  • Implement the UserAgentHeaderProvider to generate and manage User-Agent headers
  • Register the provider with all API clients to ensure consistent application
  • Allow features to extend the User-Agent with their own identifiers
  • Consider performance optimizations like caching for mobile environments
  • Leverage User-Agent data for analytics and error correlation

This consistent approach to User-Agent implementation enhances the overall API integration architecture by providing valuable context to backend services while maintaining the flexibility needed in a complex mobile application.