User Agent Parser for Ruby

Paste the user agent string from a Rails log, a support ticket or a Rack request, read the browser, engine, platform and device it names, and take away the Ruby that reads the same header in a controller. The parsing happens in your browser.

All tools

The string to parse

What the string claims

Browser
Chrome 126.0.0.0
Engine
Blink
Operating system
macOS 10.15.7
Device
Desktop

The same header in a Rails controller

A concern that reads request.user_agent

module ClientDescription
  extend ActiveSupport::Concern

  private

  def client
    raw = request.user_agent.to_s
    return {} if raw.blank?

    agent = UserAgent.parse(raw)

    {
      browser:          [agent.browser, agent.version.to_s.presence].compact.join(" "),
      operating_system: agent.os.presence || agent.platform,
      device:           agent.mobile? ? :mobile : :desktop
    }
  end
end

The gem reports browser, version, platform and os, so there is no engine row to fill - the string names a frozen AppleWebKit version whatever engine is really rendering. Add the browser gem when you need the engine, bot detection or device predicates.

What this browser reports

This browser's user agent

Browser
—
Engine
—
Operating system
—
Device
—
Screen
—
Viewport
—
Pixel ratio
—
Colour scheme
—
Timezone
—
Language
—
Cookies
—
Storage
—

Everything runs in your browser. Nothing is uploaded.

What the parts of the string actually mean

Every string on this page opens with Mozilla/5.0, and none of the browsers sending it are Mozilla. That prefix is a fossil: servers once checked for it before serving frames, so everybody claimed it, and it has meant nothing since. What follows is a bracketed comment describing the machine - the platform, sometimes an architecture, on a phone often a device model - and then a run of product tokens, each a name and a version separated by a slash. The tokens are where the negotiation shows. Chrome on a desktop sends AppleWebKit/537.36, then Chrome/126.0.0.0, then Safari/537.36, naming two engines and a competitor, because pages written for Safari checked for Safari and pages written for WebKit checked for WebKit. Firefox carries Gecko in the same spirit. Read the tokens right to left and you are reading a history of what servers used to refuse, which is why the last token is almost never the browser that sent the request.

Reading the same header in Rails

In a controller the whole string is request.user_agent, and the first thing worth knowing is that it can be nil - the header is optional, and a health check or a bare Net::HTTP call will not send it. Coerce with to_s and guard on presence before parsing, because UserAgent.parse of an empty string returns a placeholder rather than nothing. The parser itself is probably already installed: useragent comes in as an actionpack dependency, which is why the snippet above needs no new gem, though code of your own that calls it should name it in the Gemfile rather than rely on somebody else's. It reports browser, version, platform and os, and stops there - no rendering engine, and no device type beyond mobile?. When you need bot detection or real device predicates, the browser gem is the one to add. Wherever the logic lands, put it in a concern and return a hash or a small object, so the view asks for a device and never sees the header.

Why the string keeps shrinking

The string is getting shorter on purpose, and code that depends on its detail is on a clock. Chrome's user agent reduction froze the minor parts of the version to 0.0.0 and cut the platform down to a coarse value, so a machine reporting macOS 10.15.7 may be running something years newer - the number is a compatibility floor now, not a fact. The replacement is Sec-CH-UA and its siblings, client hints the browser sends as separate headers and only expands when a site asks for the high-entropy parts, which makes the detail opt-in and auditable rather than broadcast to everybody. For most decisions neither is the right input: ask whether the feature exists rather than who the browser claims to be, and let CSS answer layout. And remember what a branch on this header costs even when it is justified - two responses at one URL means Vary: User-Agent, which fragments every cache between you and the reader, at a cache-key granularity that is effectively per-browser-build.

Questions Rails developers ask about user agent strings

Most Rails apps already have one without asking. The useragent gem arrives as an actionpack dependency, so UserAgent.parse is available in a stock Rails app with nothing added to the Gemfile - though if your own code calls it, list it in the Gemfile anyway, because an indirect dependency can be dropped by an upgrade you did not plan. It gives browser, version, platform and os. What it does not give is the rendering engine or a device type beyond a mobile? predicate. The browser gem is the usual next step: it adds bot detection, device predicates and version comparisons, at the cost of a real dependency and a table of patterns that needs updating as browsers change. For a row in an admin panel, useragent is enough. For anything that makes a decision, the browser gem is the one worth the entry.

Yes, and often. request.user_agent reads an optional header, so anything that omits it gets you nil - curl without a flag, a health check, a load balancer probe, a Ruby script using Net::HTTP with no headers set. It is also blank rather than absent often enough to matter, which a nil check alone will not catch. The trap is what happens next: UserAgent.parse("") does not raise and does not return nil. It returns a placeholder that reports the browser as Mozilla at version 4.0, which will sail into your database or your dashboard looking like a real answer. So guard on presence before you parse, not after, and return an empty result rather than a parsed one. The snippet above does exactly that with a blank? check on the first line.

Rarely, and almost never for layout. A responsive stylesheet and a media query answer the layout question on the client, where the actual viewport is known, and they keep answering it correctly when somebody resizes a window or turns a tablet sideways - none of which the header reports. Branching on the server also splits your cache: two different responses now live at one URL, so you owe a Vary: User-Agent header, and every cache in front of the app stores both. The cases that do justify it are the ones where the difference is not cosmetic: a download link pointing at the App Store rather than a .dmg, an SMS-based flow, a heavy interaction that is genuinely different on a touch screen. When you do branch, keep the decision in one place - a concern, or a single method on the controller - so the string is parsed once and the rest of the app reads a symbol.

By name, and only for the ones that announce themselves. Googlebot, Bingbot, GPTBot, ClaudeBot and the rest put a recognisable token in the header, so a substring match or the browser gem's bot? predicate finds them, and that is enough for the honest cases - keeping a crawler out of a session-heavy path, skipping an analytics write, choosing a lighter render. It is not enough for anything that matters. A scraper that wants your pages sends a Chrome string, and no parser can tell it from Chrome, because the header is whatever the client decided to type. So treat a positive match as a hint worth acting on and a negative one as no information at all. Rate limits, robots.txt and reverse DNS on the claimed crawler's address do the work that a name match cannot. If you branch on a bot check, add Vary: User-Agent for the same reason any other branch on that header needs it.

Because the string is negotiating compatibility, not describing itself. Chrome still writes Mozilla/5.0, AppleWebKit and Safari into its header because a generation of servers sniffed for those tokens, and dropping them broke pages - so they stayed, permanently. The engine is the clearest case: Chrome has used Blink since 2013, but the header names AppleWebKit/537.36, a version frozen the year it forked, and a parser that reports the engine as WebKit is reading the string correctly while telling you something untrue. Versions are being frozen deliberately now too. Chrome pins the minor parts of its version to 0.0.0, and reports a reduced platform version rather than the real one, so an operating system that reads as macOS 10.15.7 may be a machine several releases newer. Treat every number here as the most the client was willing to say.

Start creating your next app now