Fast, embedded User-Agent detection for Crystal.
Device Detector turns a raw User-Agent string into structured information about the client: browser, operating system, device type, vendor, model, applications, bots, TVs, consoles, cameras, and more.
It is designed for services that need local, predictable detection without a network call or an external database.
- Broad coverage — browsers, bots, mobile devices, desktop OSes, apps, libraries, TVs, consoles, cameras, and specialized clients.
- Self-contained — rules from Matomo Device Detector are embedded in the binary at compile time.
- Two parsing modes — detailed
fulldetection or a fasterlitebot/mobile path. - Parallel-safe — the request path has no shared writes or global parser lock.
- Optimized rule lookup — generated token indexes reduce the number of regular expressions evaluated for the largest rule sets.
User-Agent detection is heuristic. Treat its output as analytics or routing metadata, not as proof of identity or a security boundary.
Add the shard to your shard.yml:
dependencies:
device_detector:
github: creadone/device_detector
version: ~> 1.5Install dependencies:
shards installDevice Detector 1.5 requires Crystal 1.21 or newer.
require "device_detector"
user_agent = "Mozilla/5.0 (Linux; Android 13; Pixel 7 Build/TQ3A) " \
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 " \
"Mobile Safari/537.36"
response = DeviceDetector::Detector.new(user_agent).call
response.browser.name # => "Chrome Mobile"
response.browser.version # => "120.0.0.0"
response.os.name # => "Android"
response.os.version # => "13"
response.mobile.vendor # => "Google"
response.mobile.type # => "smartphone"
response.mobile.model # => "Pixel 7"
response.traffic_type # => "human"That is the complete integration: require the shard, create a detector, and parse the string.
Build the standalone executable:
shards build device-detector --releaseParse one User-Agent passed as an argument:
./bin/device-detector "Mozilla/5.0 (Linux; Android 13; Pixel 7 Build/TQ3A) ..."For batch processing, pass one User-Agent per line on stdin. The command uses the available CPU cores by default; --workers sets an explicit upper limit:
./bin/device-detector --workers 8 < user_agents.txtThe output is JSON Lines: one compact JSON object per input line, in the same order as the input.
{"user_agent":"Mozilla/5.0 (...)","traffic_type":"human","browser":{"name":"Chrome Mobile","version":"120.0.0.0"},"browser_engine":{"name":"Blink"},"mobile":{"vendor":"Google","type":"smartphone","model":"Pixel 7"},"os":{"name":"Android","version":"13"}}Each object contains the original user_agent, the derived traffic_type, and every non-empty section and field produced by the full parser. Empty input lines are ignored. Input can come from positional arguments, stdin, or - as the conventional stdin placeholder.
Usage: device-detector [options] [USER_AGENT ...]
device-detector [options] < user-agents.txt
-w N, --workers=N Parse with up to N workers
-v, --version Print version
-h, --help Show help
| Mode | Call | Parsers | Best for |
|---|---|---|---|
| Full | Detector#call |
All 16 parser groups | Analytics, enrichment, detailed device/client information |
| Lite | Detector#lite |
Bot and mobile device | Request routing, coarse traffic classification, hot paths |
detector = DeviceDetector::Detector.new(user_agent)
full = detector.call # Complete response
lite = detector.lite # Bot and mobile sections onlyBoth methods return DeviceDetector::Response. An unfamiliar User-Agent does not raise an error; section and field predicates return false when nothing was detected.
response = DeviceDetector::Detector.new(user_agent).call
if response.bot?
puts "bot: #{response.bot.name}"
else
puts "human"
endResponse#traffic_type returns "bot" when a bot or an HTTP client library is detected. All other traffic is reported as "human".
response.traffic_type # => "bot" | "human"Use the lite parser when bot and mobile detection are all you need:
response = DeviceDetector::Detector.new(user_agent).lite
if response.bot?
route_to_bot_pipeline
elsif response.mobile?
route_to_mobile_site
else
route_to_desktop_site
endField accessors have the type String?, while predicates also normalize the empty strings returned by unmatched built-in parsers. Use a field predicate when exporting an optional dimension:
response = DeviceDetector::Detector.new(user_agent).call
browser = response.browser
mobile = response.mobile
if browser.name?
puts "Browser: #{browser.name}"
end
if mobile.model?
puts "Device model: #{mobile.model}"
endFor a routing decision that only needs to identify Huawei devices:
DeviceDetector::Parser::Mobile.prepare_huawei
if DeviceDetector::Parser::Mobile.huawei?(user_agent)
route_to_huawei_flow
endThe classifier uses the same Huawei model rules as the complete mobile parser. Honor rules are evaluated first so Honor-branded devices are not misclassified as Huawei.
response = DeviceDetector::Detector.new(user_agent).call
pp response.rawResponse#raw returns an Array(Hash(String, Hash(String, String))). Prefer the object-style API for application code; raw output is most useful for debugging and generic integrations.
Every section provides:
- a predicate such as
response.browser?; - an object accessor such as
response.browser; - nullable field accessors such as
response.browser.name; - field predicates such as
response.browser.name?.
| Section | Predicate | Available fields |
|---|---|---|
| Bot | bot? |
bot.name |
| Browser | browser? |
browser.name, browser.version |
| Browser engine | browser_engine? |
browser_engine.name |
| Camera | camera? |
camera.device, camera.vendor |
| Car browser | car_browser? |
car_browser.model, car_browser.vendor |
| Console | console? |
console.model, console.vendor |
| Feed reader | feed_reader? |
feed_reader.name, feed_reader.version |
| HTTP client library | library? |
library.name, library.version |
| Media player | mediaplayer? |
mediaplayer.name, mediaplayer.version |
| Mobile app | mobile_app? |
mobile_app.name, mobile_app.version |
| Mobile device | mobile? |
mobile.vendor, mobile.type, mobile.model |
| Operating system | os? |
os.name, os.version |
| PIM client | pim? |
pim.name, pim.version |
| Portable media player | portable_media_player? |
portable_media_player.model, portable_media_player.vendor |
| TV | tv? |
tv.model, tv.vendor |
| Vendor fragment | vendorfragment? |
vendorfragment.vendor |
Object accessors are safe even when a section was not detected:
response.console? # => false
response.console.model # => ""
response.console.model? # => falseField accessors return String? because a key may be absent. Built-in parser sections generally use an empty string for a known field that was not detected. Use the section or field predicates when presence matters.
The legacy flat API remains available for compatibility:
response.browser_name
response.browser_version
response.mobile_device?
response.mobile_device_vendor
response.mobile_device_type
response.mobile_device_model
response.camera_modelNew code should prefer the object-style API.
flowchart LR
UA["User-Agent"] --> H["Fast client and device hints"]
H --> I["Generated token index"]
I --> C["Candidate rules"]
C --> R["Priority-preserving regex match"]
R --> O["Structured Response"]
- Regex catalogs derived from Matomo Device Detector are embedded into the compiled application. There are no rule files to deploy and no runtime downloads.
- The rule data is decoded into typed parser structures when the application starts.
- Bots, browsers, operating systems, and mobile devices use generated token indexes to narrow the candidate set.
- Candidate rules keep their original YAML priority. When an index cannot make a decision, the parser falls back to the remaining rules to preserve detection semantics.
- Compiled regular expressions are either read from an immutable prepared snapshot or created in a per-thread fallback cache. Parsing never mutates shared registry state.
The result is deterministic rule-based detection with predictable deployment and no external service dependency.
All built-in parser stacks can be called safely from Crystal concurrent and parallel execution contexts.
For latency-sensitive services, prepare the parser set before accepting traffic:
require "device_detector"
DeviceDetector.prepare # All parsers
# DeviceDetector.prepare(DeviceDetector::Setting::LITE) # Bot + mobile onlyPreparation compiles the selected regular expressions once and publishes an immutable snapshot shared by all workers. It is an optimization, not a correctness requirement: unprepared expressions use thread-local caches. Repeated and concurrent calls to prepare are safe.
Individual parser groups can also be prepared:
DeviceDetector::Parser::Bot.prepare
DeviceDetector::Parser::OS.prepare
DeviceDetector::Parser::Mobile.prepareExample batch processing on a dedicated parallel execution context:
require "device_detector"
require "fiber/execution_context"
DeviceDetector.prepare(DeviceDetector::Setting::LITE)
user_agents = ["curl/8.0", "Mozilla/5.0 (...)"]
results = Channel(Tuple(String, Bool, Bool)).new(user_agents.size)
context = Fiber::ExecutionContext::Parallel.new("ua-detection", 4)
user_agents.each do |user_agent|
context.spawn do
response = DeviceDetector::Detector.new(user_agent).lite
results.send({user_agent, response.bot?, response.mobile?})
end
end
user_agents.size.times do
user_agent, bot, mobile = results.receive
pp({user_agent: user_agent, bot: bot, mobile: mobile})
endRun the included benchmark in release mode:
crystal run --release bench/raw_response.crThe workload contains 10,000 deterministic, unique User-Agent strings across desktop browsers, Android, iOS, bots, libraries, applications, consoles, TVs, and PIM clients.
Reference result on Apple Silicon arm64 with Crystal 1.21.0, reported as the median of seven release runs:
| Mode | Throughput | Average time per User-Agent |
|---|---|---|
| Full | 7,911/s | ~126 μs |
| Lite | 29,334/s | ~34 μs |
A separate stress test used a fixed hot set of 12 representative User-Agents—including Android, iOS, desktop, bots, and Huawei—and 100,000 parses. Median throughput across three release runs was:
| Mode | One worker | Four workers | Scaling |
|---|---|---|---|
| Full | 8,202/s | 21,536/s | 2.63× |
| Lite | 23,220/s | 73,317/s | 3.16× |
These numbers are a reference point, not a latency guarantee. CPU, architecture, Crystal/LLVM versions, User-Agent distribution, and surrounding application work all affect results. Benchmark your real traffic before making capacity decisions.
- User-Agent strings can be missing, malformed, or intentionally spoofed.
- A positive detection is suitable for presentation, analytics, feature routing, and traffic segmentation—not authentication or authorization.
- Unknown fields may return
nilor an empty string; use section and field predicates instead of assuming every browser or device exposes a model or version. - Detection quality follows the embedded regex snapshot. Update the catalogs when upstream rules change.
- Client-side feature detection is usually a better choice when behavior depends on a specific browser capability.
Install dependencies and run the complete local check:
shards install
shards build device-detector
crystal spec
bin/ameba
crystal tool format --check src spec script benchRegex catalogs live in src/device_detector/regexes and are based on matomo-org/device-detector.
crystal run script/update_regexes.cr
crystal spec
bin/amebaThe update script mirrors upstream regexes/**/*.yml files and regenerates token indexes under src/device_detector/regexes/index. Review and commit the regex and generated-index diffs together.
Bug reports, rule corrections, performance investigations, and pull requests are welcome in GitHub Issues.
For a pull request:
- Add or update specs when behavior changes.
- Run the test, lint, and formatting commands above.
- Include benchmark results when changing a parser hot path or generated index.
- Explain whether detection priority or compatibility is affected.