Class: Sessions::Device
- Inherits:
-
Object
- Object
- Sessions::Device
- 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
- .headers_from(request) ⇒ Object
-
.parse(user_agent, headers: {}) ⇒ Object
Parse a raw user agent string (and optional canonical headers hash from ‘Device.headers_from`).
Instance Method Summary collapse
- #bot? ⇒ Boolean
-
#initialize(user_agent, headers: {}) ⇒ Device
constructor
A new instance of Device.
- #native? ⇒ Boolean
-
#to_h ⇒ Object
Only the attributes that map 1:1 onto session/event columns.
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.}") allocate.tap { |device| device.send(:initialize_blank) } end |
Instance Method Details
#bot? ⇒ Boolean
129 130 131 |
# File 'lib/sessions/device.rb', line 129 def bot? device_type == "bot" end |
#native? ⇒ Boolean
125 126 127 |
# File 'lib/sessions/device.rb', line 125 def native? device_type&.start_with?("native_") end |
#to_h ⇒ Object
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 |