Class: Sessions::Device

Inherits:
Object
  • Object
show all
Defined in:
lib/sessions/device.rb

Overview

Turns a raw user agent (+ optional client-hint / X-Client-* headers) into the parsed device columns a “your devices” page renders. This is a PROJECTION: the raw UA is always persisted alongside, so parsing can be re-run as parsers and conventions improve.

Three layers, in order (→ docs/research/07-device-detection.md):

1. Native matcher — Hotwire Native UAs (`/(Turbo|Hotwire) Native/`, the
   same contract as turbo-rails' `hotwire_native_app?`), the documented
   `AppName/1.2.3 (model; OS version; build N);` prefix convention,
   legacy shapes like "MyApp Android 1.0.5 (build 6; Android 14; sdk
   34; Pixel 7)", and validated X-Client-* headers. No third-party
   parser understands any of this; it's the layer that names a session
   "MyApp 2.4.1 on Pixel 8 (Android 16)".
2. Web parser — the `browser` gem by default (MIT, zero-dep, what
   Mastodon uses), auto-upgrading to `device_detector` when the host
   bundles it, or any `->(user_agent, headers) { ... }` lambda.
3. Honesty filter — 2026 web UAs are frozen husks ("Windows NT 10.0",
   "Intel Mac OS X 10_15_7", "Android 10; K"); we never present a
   frozen token as a fact. OS versions are kept only where they're
   real: iOS UAs, Android WebViews (exempt from UA reduction), and
   Chromium client hints. Everything else renders version-less
   ("Chrome on macOS") — accurate beats impressive.

Constant Summary collapse

UA_PARSE_LIMIT =

Hard input bound (GitLab’s SafeDeviceDetector precedent): parse at most this many characters. The FULL raw UA is still stored by the caller.

1024
NATIVE_MARKER =

Same contract as turbo-rails (app/controllers/turbo/native/navigation.rb).

/(?:Turbo|Hotwire) Native (iOS|Android)/i
BROWSER_PRODUCT_TOKENS =

The README’s recommended prefix convention:

MyApp/2.4.1 (iPhone15,2; iOS 19.5; build 241);

Product tokens that are part of every browser UA must never match as app names.

%w[
  Mozilla AppleWebKit Chrome CriOS Safari Version Firefox FxiOS Gecko
  KHTML Mobile OPR Edg\w* SamsungBrowser
].freeze
CONVENTION_DENYLIST =
/\A(?:#{BROWSER_PRODUCT_TOKENS.join("|")})\z/i
CONVENTION =
%r{(?<app>[A-Za-z][\w .-]*?)/(?<version>\d[\w.]*)\s*\((?<fields>[^)]*)\)}
LEGACY =

The legacy native-HTTP-client shape (apps declare the name via config.native_app_names):

MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)
/(?<app>[A-Za-z][\w .-]*?) (?<platform>iOS|Android) (?<version>\d[\w.]*)\s*\((?<fields>[^)]*)\)/
ANDROID_WEBVIEW =

The Android WebView segment of a Hotwire Native UA — exempt from Chrome’s UA reduction, so model + OS version here are REAL.

%r{\(Linux; Android (?<os_version>[\d.]+); (?<model>[^;)]+?)(?: Build/[^;)]*)?[;)]}
IOS_OS_VERSION =

Real iOS version from the WebKit UA (“CPU iPhone OS 19_5 like Mac OS X”).

/CPU (?:iPhone )?OS (?<version>\d+(?:_\d+)*)/
DEVICE_TYPES =
%w[desktop smartphone tablet native_ios native_android bot unknown].freeze
ATTRIBUTES =
%i[
  browser_name browser_version os_name os_version
  device_type device_model app_name app_version app_build
].freeze
CAPTURED_HEADERS =

The interesting request headers, normalized to their canonical names —used both as parser input and as the raw ‘client_hints` column value (so a future `sessions:reparse` can re-run parsing offline).

{
  "HTTP_SEC_CH_UA" => "Sec-CH-UA",
  "HTTP_SEC_CH_UA_MOBILE" => "Sec-CH-UA-Mobile",
  "HTTP_SEC_CH_UA_PLATFORM" => "Sec-CH-UA-Platform",
  "HTTP_SEC_CH_UA_PLATFORM_VERSION" => "Sec-CH-UA-Platform-Version",
  "HTTP_SEC_CH_UA_MODEL" => "Sec-CH-UA-Model",
  "HTTP_SEC_CH_UA_FULL_VERSION_LIST" => "Sec-CH-UA-Full-Version-List",
  "HTTP_X_CLIENT_PLATFORM" => "X-Client-Platform",
  "HTTP_X_CLIENT_VERSION" => "X-Client-Version",
  "HTTP_X_CLIENT_BUILD" => "X-Client-Build",
  "HTTP_X_CLIENT_OS" => "X-Client-OS"
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(user_agent, headers: {}) ⇒ Device

Returns a new instance of Device.



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/sessions/device.rb', line 106

def initialize(user_agent, headers: {})
  initialize_blank
  # Strip BEFORE the emptiness check: the browser gem's bot list treats
  # whitespace-only UAs as bots, but for a devices page they're just
  # unknown.
  @user_agent = user_agent.to_s.strip[0, UA_PARSE_LIMIT]
  @headers = headers || {}

  if native_platform
    parse_native
  elsif @user_agent.empty?
    @device_type = "unknown"
  else
    parse_web
  end

  freeze
end

Class Method Details

.headers_from(request) ⇒ Object



95
96
97
98
99
100
101
102
103
104
# File 'lib/sessions/device.rb', line 95

def self.headers_from(request)
  return {} unless request

  CAPTURED_HEADERS.each_with_object({}) do |(env_key, name), hints|
    value = request.get_header(env_key)
    hints[name] = value if value.respond_to?(:to_str) && !value.to_str.empty?
  end
rescue StandardError
  {}
end

.parse(user_agent, headers: {}) ⇒ Object

Parse a raw user agent string (and optional canonical headers hash from ‘Device.headers_from`). Never raises: a hostile or unparseable UA degrades to device_type “unknown” — tracking must never break login.



72
73
74
75
76
77
# File 'lib/sessions/device.rb', line 72

def self.parse(user_agent, headers: {})
  new(user_agent, headers: headers)
rescue StandardError => e
  Sessions.warn("device parsing failed: #{e.class}: #{e.message}")
  allocate.tap { |device| device.send(:initialize_blank) }
end

Instance Method Details

#bot?Boolean

Returns:

  • (Boolean)


129
130
131
# File 'lib/sessions/device.rb', line 129

def bot?
  device_type == "bot"
end

#native?Boolean

Returns:

  • (Boolean)


125
126
127
# File 'lib/sessions/device.rb', line 125

def native?
  device_type&.start_with?("native_")
end

#to_hObject

Only the attributes that map 1:1 onto session/event columns.



134
135
136
# File 'lib/sessions/device.rb', line 134

def to_h
  ATTRIBUTES.index_with { |attribute| public_send(attribute) }.compact
end