diff --git a/docs/changelog/144384.yaml b/docs/changelog/144384.yaml
new file mode 100644
index 0000000000000..8905c611ed028
--- /dev/null
+++ b/docs/changelog/144384.yaml
@@ -0,0 +1,6 @@
+area: ES|QL
+issues:
+ - 134886
+pr: 144384
+summary: Adding ES|QL USER_AGENT command
+type: feature
diff --git a/docs/reference/query-languages/esql/_snippets/commands/examples/user_agent.csv-spec/basic.md b/docs/reference/query-languages/esql/_snippets/commands/examples/user_agent.csv-spec/basic.md
new file mode 100644
index 0000000000000..64cde8266f7ed
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/commands/examples/user_agent.csv-spec/basic.md
@@ -0,0 +1,11 @@
+% This is generated by ESQL's CommandDocsTests. Do not edit it. See ../README.md for how to regenerate it.
+
+```esql
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT ua = input WITH { "extract_device_type": true }
+| KEEP ua.*
+```
+
+| ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword | ua.device.type:keyword |
+| --- | --- | --- | --- | --- | --- | --- |
+| Chrome | 33.0.1750.149 | Mac OS X | 10.9.2 | Mac OS X 10.9.2 | Mac | Desktop |
diff --git a/docs/reference/query-languages/esql/_snippets/commands/layout/user_agent.md b/docs/reference/query-languages/esql/_snippets/commands/layout/user_agent.md
new file mode 100644
index 0000000000000..44703b3edfb09
--- /dev/null
+++ b/docs/reference/query-languages/esql/_snippets/commands/layout/user_agent.md
@@ -0,0 +1,117 @@
+```yaml {applies_to}
+serverless: preview
+stack: preview 9.4
+```
+
+The `USER_AGENT` processing command parses a user-agent string and extracts its components (name, version, OS, device) into new columns.
+
+::::{note}
+This command doesn't support multi-value inputs.
+::::
+
+
+## Syntax
+
+```esql
+USER_AGENT prefix = expression [WITH { option = value [, ...] }]
+```
+
+## Parameters
+
+`prefix`
+: The prefix for the output columns. The extracted components are available as `prefix.component`.
+
+`expression`
+: The string expression containing the user-agent string to parse.
+
+## WITH options
+
+`regex_file`
+: The name of the parser configuration to use. Default: `_default_`, which uses the built-in regexes from [uap-core](https://github.com/ua-parser/uap-core). To use a custom regex file, place a `.yml` file in the `config/user-agent` directory on each node before starting Elasticsearch. The file must be present at node startup; changes or new files added while the node is running have no effect. Pass the filename (including the `.yml` extension) as the value. Custom regex files are typically variants of the default, either a more recent uap-core release or a customized version.
+
+`extract_device_type`
+: When `true`, extracts device type (e.g., Desktop, Phone, Tablet) on a best-effort basis and includes `prefix.device.type` in the output. Default: `false`.
+
+`properties`
+: List of property groups to include in the output. Each value expands to one or more columns: `name` → `prefix.name`; `version` → `prefix.version`; `os` → `prefix.os.name`, `prefix.os.version`, `prefix.os.full`; `device` → `prefix.device.name` (and `prefix.device.type` when `extract_device_type` is `true`). Default: `["name", "version", "os", "device"]`. You can pass a subset to reduce output columns.
+
+## Using a custom regex file
+
+To use a custom regex file instead of the built-in uap-core patterns:
+
+1. Place a `.yml` file in the `config/user-agent` directory on each node.
+2. Create the directory and file before starting Elasticsearch.
+3. Pass the filename (including the `.yml` extension) as the `regex_file` option.
+
+Files must be present at node startup. Changes to existing files or new files added while the node is running have no effect until the node is restarted.
+
+::::{note}
+Before version 9.4, this directory was named `config/ingest-user-agent`. The old directory name is still supported as a fallback but is deprecated.
+::::
+
+Custom regex files are typically variants of the default [uap-core regexes.yaml](https://github.com/ua-parser/uap-core/blob/master/regexes.yaml), either a more recent release or a customized version for specific user-agent patterns. Use a custom file when you need to support newer user-agent formats before they are available in the built-in patterns, or to parse specialized or non-standard user-agent strings.
+
+## Description
+
+The `USER_AGENT` command parses a user-agent string and extracts its parts into new columns.
+The new columns are prefixed with the specified `prefix` followed by a dot (`.`).
+
+This command is the query-time equivalent of the [User-Agent ingest processor](/reference/enrich-processor/user-agent-processor.md).
+
+The following columns may be created (depending on `properties` and `extract_device_type`):
+
+`prefix.name`
+: The user-agent name (e.g., Chrome, Firefox).
+
+`prefix.version`
+: The user-agent version.
+
+`prefix.os.name`
+: The operating system name.
+
+`prefix.os.version`
+: The operating system version.
+
+`prefix.os.full`
+: The full operating system string.
+
+`prefix.device.name`
+: The device name.
+
+`prefix.device.type`
+: The device type (e.g., Desktop, Phone). Only present when `extract_device_type` is `true`.
+
+If a component is missing or the input is not a valid user-agent string, the corresponding column contains `null`.
+If the expression evaluates to `null` or blank, all output columns are `null`.
+
+## Examples
+
+The following example parses a user-agent string and extracts its parts:
+
+:::{include} ../examples/user_agent.csv-spec/basic.md
+:::
+
+To limit output to specific properties or include device type, use the `properties` and `extract_device_type` options:
+
+```esql
+ROW ua_str = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15"
+| USER_AGENT ua = ua_str WITH { "properties": ["name", "version", "device"], "extract_device_type": true }
+| KEEP ua.*
+```
+
+To use a custom regex file (e.g. `my-regexes.yml` in `config/user-agent`), pass the filename including the extension:
+
+```esql
+FROM web_logs
+| USER_AGENT ua = user_agent WITH { "regex_file": "my-regexes.yml" }
+| KEEP ua.name, ua.version
+```
+
+You can use the extracted parts in subsequent commands, for example to filter by browser:
+
+```esql
+FROM web_logs
+| USER_AGENT ua = user_agent
+| WHERE ua.name == "Firefox"
+| STATS COUNT(*) BY ua.version
+```
diff --git a/docs/reference/query-languages/esql/_snippets/lists/processing-commands.md b/docs/reference/query-languages/esql/_snippets/lists/processing-commands.md
index 4a85eedc3334c..eb28554c7d8a0 100644
--- a/docs/reference/query-languages/esql/_snippets/lists/processing-commands.md
+++ b/docs/reference/query-languages/esql/_snippets/lists/processing-commands.md
@@ -23,5 +23,6 @@
* [`SAMPLE`](/reference/query-languages/esql/commands/sample.md) {applies_to}`stack: preview 9.1` {applies_to}`serverless: preview`
* [`SORT`](/reference/query-languages/esql/commands/sort.md)
* [`STATS`](/reference/query-languages/esql/commands/stats-by.md)
+% * [`USER_AGENT`](/reference/query-languages/esql/commands/user-agent.md) {applies_to}`stack: preview 9.4` {applies_to}`serverless: preview`
* [`URI_PARTS`](/reference/query-languages/esql/commands/uri-parts.md) {applies_to}`stack: preview 9.4` {applies_to}`serverless: preview`
* [`WHERE`](/reference/query-languages/esql/commands/where.md)
diff --git a/docs/reference/query-languages/esql/commands/user-agent.md b/docs/reference/query-languages/esql/commands/user-agent.md
new file mode 100644
index 0000000000000..11c329f9c4254
--- /dev/null
+++ b/docs/reference/query-languages/esql/commands/user-agent.md
@@ -0,0 +1,10 @@
+---
+navigation_title: "USER_AGENT"
+mapped_pages:
+ - https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-user_agent
+---
+
+# {{esql}} `USER_AGENT` command [esql-user_agent]
+
+:::{include} ../_snippets/commands/layout/user_agent.md
+:::
diff --git a/docs/reference/query-languages/esql/kibana/definition/commands/user_agent.json b/docs/reference/query-languages/esql/kibana/definition/commands/user_agent.json
new file mode 100644
index 0000000000000..36c2a2032d911
--- /dev/null
+++ b/docs/reference/query-languages/esql/kibana/definition/commands/user_agent.json
@@ -0,0 +1,5 @@
+{
+ "comment" : "This is generated by ESQL’s DocsV3Support. Do not edit it. See ../README.md for how to regenerate it.",
+ "type" : "command",
+ "name" : "user_agent"
+}
diff --git a/docs/reference/query-languages/toc.yml b/docs/reference/query-languages/toc.yml
index f2c4e705f7030..7dc8bfe6b6837 100644
--- a/docs/reference/query-languages/toc.yml
+++ b/docs/reference/query-languages/toc.yml
@@ -131,6 +131,7 @@ toc:
- file: esql/commands/sample.md
- file: esql/commands/sort.md
- file: esql/commands/stats-by.md
+ - hidden: esql/commands/user-agent.md
- file: esql/commands/uri-parts.md
- file: esql/commands/where.md
- file: esql/esql-functions-operators.md
diff --git a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentCache.java b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentCache.java
index 0f60ede01c3fe..51bb7512910a1 100644
--- a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentCache.java
+++ b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentCache.java
@@ -20,13 +20,13 @@ class UserAgentCache {
cache = CacheBuilder.builder().setMaximumWeight(cacheSize).build();
}
- public Details get(String parserName, String userAgent) {
- return cache.get(new CompositeCacheKey(parserName, userAgent));
+ public Details get(String parserName, String userAgent, boolean extractDeviceType) {
+ return cache.get(new CompositeCacheKey(parserName, userAgent, extractDeviceType));
}
- public void put(String parserName, String userAgent, Details details) {
- cache.put(new CompositeCacheKey(parserName, userAgent), details);
+ public void put(String parserName, String userAgent, Details details, boolean extractDeviceType) {
+ cache.put(new CompositeCacheKey(parserName, userAgent, extractDeviceType), details);
}
- private record CompositeCacheKey(String parserName, String userAgent) {}
+ private record CompositeCacheKey(String parserName, String userAgent, boolean extractDeviceType) {}
}
diff --git a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserImpl.java b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserImpl.java
index 60d10d2e27f4f..4eca7dcbdb165 100644
--- a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserImpl.java
+++ b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserImpl.java
@@ -183,7 +183,7 @@ String getName() {
@Override
public Details parseUserAgentInfo(String agentString, boolean extractDeviceType) {
- Details details = cache.get(name, agentString);
+ Details details = cache.get(name, agentString, extractDeviceType);
if (details == null) {
VersionedName userAgent = findMatch(uaPatterns, agentString);
VersionedName operatingSystem = findMatch(osPatterns, agentString);
@@ -199,7 +199,7 @@ public Details parseUserAgentInfo(String agentString, boolean extractDeviceType)
VersionedName dev = (device != null && device.name() != null) ? device : null;
details = new Details(uaName, uaVersion, os, osFull, dev, deviceType);
- cache.put(name, agentString, details);
+ cache.put(name, agentString, details, extractDeviceType);
}
return details;
}
diff --git a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserRegistry.java b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserRegistryImpl.java
similarity index 94%
rename from modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserRegistry.java
rename to modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserRegistryImpl.java
index 139dff248fe26..8de5edc3c391c 100644
--- a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserRegistry.java
+++ b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentParserRegistryImpl.java
@@ -23,15 +23,15 @@
import java.util.Map;
import java.util.stream.Stream;
-public class UserAgentParserRegistry implements org.elasticsearch.useragent.api.UserAgentParserRegistry {
+class UserAgentParserRegistryImpl implements org.elasticsearch.useragent.api.UserAgentParserRegistry {
- private static final Logger logger = LogManager.getLogger(UserAgentParserRegistry.class);
+ private static final Logger logger = LogManager.getLogger(UserAgentParserRegistryImpl.class);
static final String DEFAULT_PARSER_NAME = org.elasticsearch.useragent.api.UserAgentParserRegistry.DEFAULT_PARSER_NAME;
private final Map registry;
- UserAgentParserRegistry(UserAgentCache cache, Path... regexFileDirectories) {
+ UserAgentParserRegistryImpl(UserAgentCache cache, Path... regexFileDirectories) {
registry = createParsersMap(cache, regexFileDirectories);
}
diff --git a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentPlugin.java b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentPlugin.java
index 3c8b3e05f3741..bf2827889752b 100644
--- a/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentPlugin.java
+++ b/modules/user-agent/src/main/java/org/elasticsearch/useragent/UserAgentPlugin.java
@@ -15,6 +15,7 @@
import org.elasticsearch.env.Environment;
import org.elasticsearch.plugins.Plugin;
import org.elasticsearch.plugins.UserAgentParserRegistryProvider;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import java.nio.file.Path;
import java.util.List;
@@ -37,7 +38,7 @@ public class UserAgentPlugin extends Plugin implements UserAgentParserRegistryPr
);
@Override
- public org.elasticsearch.useragent.api.UserAgentParserRegistry createUserAgentParserRegistry(Environment env) {
+ public UserAgentParserRegistry createRegistry(Environment env) {
return createRegistry(env, env.settings());
}
@@ -56,6 +57,6 @@ public static UserAgentParserRegistry createRegistry(Environment env, Settings s
Path ingestUserAgentConfigDirectory = env.configDir().resolve("ingest-user-agent");
long cacheSize = CACHE_SIZE_SETTING.get(settings);
UserAgentCache cache = new UserAgentCache(cacheSize);
- return new UserAgentParserRegistry(cache, userAgentConfigDirectory, ingestUserAgentConfigDirectory);
+ return new UserAgentParserRegistryImpl(cache, userAgentConfigDirectory, ingestUserAgentConfigDirectory);
}
}
diff --git a/modules/user-agent/src/test/java/org/elasticsearch/useragent/UserAgentParserImplTests.java b/modules/user-agent/src/test/java/org/elasticsearch/useragent/UserAgentParserImplTests.java
new file mode 100644
index 0000000000000..81fede89e6663
--- /dev/null
+++ b/modules/user-agent/src/test/java/org/elasticsearch/useragent/UserAgentParserImplTests.java
@@ -0,0 +1,354 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.useragent;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.useragent.api.Details;
+import org.junit.BeforeClass;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+@SuppressWarnings("DataFlowIssue")
+public class UserAgentParserImplTests extends ESTestCase {
+
+ private static UserAgentParserImpl parser;
+
+ @BeforeClass
+ public static void setupParser() {
+ InputStream regexStream = UserAgentPlugin.class.getResourceAsStream("/regexes.yml");
+ InputStream deviceTypeRegexStream = UserAgentPlugin.class.getResourceAsStream("/device_type_regexes.yml");
+ assertNotNull(regexStream);
+ assertNotNull(deviceTypeRegexStream);
+ parser = new UserAgentParserImpl("_default", regexStream, deviceTypeRegexStream, new UserAgentCache(1000));
+ }
+
+ public void testChromeMacOs() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36",
+ true
+ );
+
+ assertThat(details.name(), equalTo("Chrome"));
+ assertThat(details.version(), equalTo("33.0.1750.149"));
+
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Mac OS X"));
+ assertThat(details.os().version(), equalTo("10.9.2"));
+ assertThat(details.osFull(), equalTo("Mac OS X 10.9.2"));
+
+ assertThat(details.device(), notNullValue());
+ assertThat(details.device().name(), equalTo("Mac"));
+
+ assertThat(details.deviceType(), equalTo("Desktop"));
+ }
+
+ public void testChromeWindows() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36",
+ true
+ );
+
+ assertThat(details.name(), equalTo("Chrome"));
+ assertThat(details.version(), equalTo("87.0.4280.141"));
+
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Windows"));
+ assertThat(details.os().version(), equalTo("10"));
+ assertThat(details.osFull(), equalTo("Windows 10"));
+
+ assertThat(details.device(), nullValue());
+
+ assertThat(details.deviceType(), equalTo("Desktop"));
+ }
+
+ public void testFirefox() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
+ true
+ );
+
+ assertThat(details.name(), equalTo("Firefox"));
+ assertThat(details.version(), equalTo("128.0"));
+
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Windows"));
+
+ assertThat(details.deviceType(), equalTo("Desktop"));
+ }
+
+ public void testAndroidMobile() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/525.10+ "
+ + "(KHTML, like Gecko) Version/3.0.4 Mobile Safari/523.12.2",
+ true
+ );
+
+ assertThat(details.name(), equalTo("Android"));
+ assertThat(details.version(), equalTo("3.0"));
+
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Android"));
+ assertThat(details.os().version(), equalTo("3.0"));
+
+ assertThat(details.device(), notNullValue());
+ assertThat(details.device().name(), equalTo("Motorola Xoom"));
+
+ assertThat(details.deviceType(), equalTo("Phone"));
+ }
+
+ public void testIPadTablet() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) "
+ + "Version/12.1 Mobile/15E148 Safari/604.1",
+ true
+ );
+
+ assertThat(details.name(), equalTo("Mobile Safari"));
+ assertThat(details.version(), equalTo("12.1"));
+
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("iOS"));
+ assertThat(details.os().version(), equalTo("12.2"));
+
+ assertThat(details.device(), notNullValue());
+ assertThat(details.device().name(), equalTo("iPad"));
+
+ assertThat(details.deviceType(), equalTo("Tablet"));
+ }
+
+ public void testSpider() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (compatible; EasouSpider; +http://www.easou.com/search/spider.html)",
+ true
+ );
+
+ assertThat(details.name(), equalTo("EasouSpider"));
+ assertThat(details.version(), nullValue());
+
+ assertThat(details.os(), nullValue());
+ assertThat(details.osFull(), nullValue());
+
+ assertThat(details.device(), notNullValue());
+ assertThat(details.device().name(), equalTo("Spider"));
+
+ assertThat(details.deviceType(), equalTo("Robot"));
+ }
+
+ public void testUnknownAgent() {
+ Details details = parser.parseUserAgentInfo("Something I made up v42.0.1", true);
+
+ assertThat(details.name(), nullValue());
+ assertThat(details.version(), nullValue());
+ assertThat(details.os(), nullValue());
+ assertThat(details.osFull(), nullValue());
+ assertThat(details.device(), nullValue());
+ assertThat(details.deviceType(), equalTo("Other"));
+ }
+
+ public void testExtractDeviceTypeDisabled() {
+ Details details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ false
+ );
+
+ assertThat(details.name(), equalTo("Chrome"));
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Mac OS X"));
+ assertThat(details.deviceType(), nullValue());
+ }
+
+ public void testCachedResultsAreReturned() {
+ String ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36";
+ Details first = parser.parseUserAgentInfo(ua, true);
+ Details second = parser.parseUserAgentInfo(ua, true);
+ assertSame(first, second);
+ }
+
+ public void testCacheIncludingDeviceTypes() {
+ String agentString = "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/525.10+ (KHTML, like Gecko) "
+ + "Version/3.0.5 Mobile Safari/523.12.2";
+ Details first = parser.parseUserAgentInfo(agentString, false);
+ assertNull(first.deviceType());
+ Details second = parser.parseUserAgentInfo(agentString, true);
+ assertThat(second.deviceType(), equalTo("Phone"));
+ assertNotSame(first, second);
+ Details third = parser.parseUserAgentInfo(agentString, false);
+ assertSame(first, third);
+ }
+
+ public void testVersionToStringMajorOnly() {
+ assertThat(UserAgentParserImpl.versionToString("10", null, null, null), equalTo("10"));
+ }
+
+ public void testVersionToStringMajorMinor() {
+ assertThat(UserAgentParserImpl.versionToString("10", "9", null, null), equalTo("10.9"));
+ }
+
+ public void testVersionToStringMajorMinorPatch() {
+ assertThat(UserAgentParserImpl.versionToString("10", "9", "2", null), equalTo("10.9.2"));
+ }
+
+ public void testVersionToStringFull() {
+ assertThat(UserAgentParserImpl.versionToString("33", "0", "1750", "149"), equalTo("33.0.1750.149"));
+ }
+
+ public void testVersionToStringNullMajor() {
+ assertThat(UserAgentParserImpl.versionToString(null, "1", "2", "3"), nullValue());
+ }
+
+ public void testVersionToStringEmptyMajor() {
+ assertThat(UserAgentParserImpl.versionToString("", "1", "2", "3"), nullValue());
+ }
+
+ public void testVersionToStringSkipsPatchWhenMinorMissing() {
+ assertThat(UserAgentParserImpl.versionToString("10", null, "2", null), equalTo("10"));
+ }
+
+ public void testVersionToStringSkipsBuildWhenPatchMissing() {
+ assertThat(UserAgentParserImpl.versionToString("10", "9", null, "5"), equalTo("10.9"));
+ }
+
+ public void testInvalidRegexFileThrows() {
+ byte[] invalidYaml = "not_a_valid_regex_file: true\n".getBytes(StandardCharsets.UTF_8);
+ expectThrows(
+ ElasticsearchParseException.class,
+ () -> new UserAgentParserImpl("bad", new ByteArrayInputStream(invalidYaml), null, new UserAgentCache(10))
+ );
+ }
+
+ public void testNullDeviceTypeRegexStream() {
+ InputStream regexStream = UserAgentPlugin.class.getResourceAsStream("/regexes.yml");
+ assertNotNull(regexStream);
+ UserAgentParserImpl parserWithoutDeviceType = new UserAgentParserImpl("no-device", regexStream, null, new UserAgentCache(10));
+
+ Details details = parserWithoutDeviceType.parseUserAgentInfo(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36",
+ true
+ );
+
+ assertThat(details.name(), equalTo("Chrome"));
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Mac OS X"));
+ assertThat(details.deviceType(), nullValue());
+ }
+
+ public void testOsMatchedWithoutVersion() {
+ Details details = parser.parseUserAgentInfo("Wget/1.21 (Ubuntu)", true);
+
+ assertThat(details.name(), equalTo("Wget"));
+
+ assertThat(details.os(), notNullValue());
+ assertThat(details.os().name(), equalTo("Ubuntu"));
+ assertThat(details.os().version(), nullValue());
+ assertThat(details.osFull(), nullValue());
+ }
+
+ public void testSubpatternMatchWithReplacements() {
+ var pattern = new UserAgentParserImpl.UserAgentSubpattern(
+ java.util.regex.Pattern.compile("(MyBrowser)/(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)"),
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ var result = pattern.match("MyBrowser/1.2.3.4");
+ assertThat(result, notNullValue());
+ assertThat(result.name(), equalTo("MyBrowser"));
+ assertThat(result.version(), equalTo("1.2.3.4"));
+ }
+
+ public void testSubpatternMatchWithNameReplacement() {
+ var pattern = new UserAgentParserImpl.UserAgentSubpattern(
+ java.util.regex.Pattern.compile("(MyBrowser)/(\\d+)"),
+ "ReplacedName",
+ null,
+ null,
+ null,
+ null
+ );
+ var result = pattern.match("MyBrowser/42");
+ assertThat(result, notNullValue());
+ assertThat(result.name(), equalTo("ReplacedName"));
+ assertThat(result.version(), equalTo("42"));
+ }
+
+ public void testSubpatternMatchWithNameReplacementContainingGroupRef() {
+ var pattern = new UserAgentParserImpl.UserAgentSubpattern(
+ java.util.regex.Pattern.compile("(MyBrowser)/(\\d+)"),
+ "$1 Mobile",
+ null,
+ null,
+ null,
+ null
+ );
+ var result = pattern.match("MyBrowser/42");
+ assertThat(result, notNullValue());
+ assertThat(result.name(), equalTo("MyBrowser Mobile"));
+ }
+
+ public void testSubpatternMatchWithVersionReplacements() {
+ var pattern = new UserAgentParserImpl.UserAgentSubpattern(
+ java.util.regex.Pattern.compile("(MyBrowser)"),
+ null,
+ "99",
+ "88",
+ "77",
+ "66"
+ );
+ var result = pattern.match("MyBrowser");
+ assertThat(result, notNullValue());
+ assertThat(result.name(), equalTo("MyBrowser"));
+ assertThat(result.version(), equalTo("99.88.77.66"));
+ }
+
+ public void testSubpatternNoMatch() {
+ var pattern = new UserAgentParserImpl.UserAgentSubpattern(
+ java.util.regex.Pattern.compile("(NoSuchBrowser)"),
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ assertThat(pattern.match("Chrome/1.0"), nullValue());
+ }
+
+ public void testSubpatternCaseInsensitiveFlag() {
+ var pattern = new UserAgentParserImpl.UserAgentSubpattern(
+ java.util.regex.Pattern.compile("(mybrowser)", java.util.regex.Pattern.CASE_INSENSITIVE),
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ var result = pattern.match("MyBrowser/1.0");
+ assertThat(result, notNullValue());
+ assertThat(result.name(), equalTo("MyBrowser"));
+ }
+
+ public void testGetName() {
+ assertThat(parser.getName(), equalTo("_default"));
+ }
+
+ public void testPatternsLoadedFromRegexFile() {
+ assertThat(parser.getUaPatterns().isEmpty(), equalTo(false));
+ assertThat(parser.getOsPatterns().isEmpty(), equalTo(false));
+ assertThat(parser.getDevicePatterns().isEmpty(), equalTo(false));
+ }
+}
diff --git a/modules/user-agent/src/test/java/org/elasticsearch/useragent/UserAgentParserRegistryImplTests.java b/modules/user-agent/src/test/java/org/elasticsearch/useragent/UserAgentParserRegistryImplTests.java
new file mode 100644
index 0000000000000..b76236ad283c5
--- /dev/null
+++ b/modules/user-agent/src/test/java/org/elasticsearch/useragent/UserAgentParserRegistryImplTests.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+package org.elasticsearch.useragent;
+
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.useragent.api.UserAgentParser;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+public class UserAgentParserRegistryImplTests extends ESTestCase {
+
+ public void testDefaultParserAlwaysAvailable() {
+ UserAgentCache cache = new UserAgentCache(100);
+ Map parsers = UserAgentParserRegistryImpl.createParsersMap(cache);
+ assertThat(parsers.get(UserAgentParserRegistry.DEFAULT_PARSER_NAME), notNullValue());
+ }
+
+ public void testCustomRegexFileRegisteredWithYmlExtension() throws IOException {
+ Path configDir = createTempDir();
+ copyBuiltinRegexesTo(configDir, "custom-regexes.yml");
+
+ UserAgentCache cache = new UserAgentCache(100);
+ Map parsers = UserAgentParserRegistryImpl.createParsersMap(cache, configDir);
+
+ // The registry key includes the .yml extension
+ assertThat(parsers.get("custom-regexes.yml"), notNullValue());
+
+ // Without extension does NOT match
+ assertThat(parsers.get("custom-regexes"), nullValue());
+ }
+
+ public void testCustomRegexFileProducesValidParser() throws IOException {
+ Path configDir = createTempDir();
+ copyBuiltinRegexesTo(configDir, "my-regexes.yml");
+
+ UserAgentCache cache = new UserAgentCache(100);
+ Map parsers = UserAgentParserRegistryImpl.createParsersMap(cache, configDir);
+
+ UserAgentParser parser = parsers.get("my-regexes.yml");
+ assertThat(parser, notNullValue());
+
+ // Verify the custom parser can actually parse a user-agent string
+ var details = parser.parseUserAgentInfo(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36",
+ false
+ );
+ assertThat(details, notNullValue());
+ assertEquals("Chrome", details.name());
+ }
+
+ public void testNonYmlFilesIgnored() throws IOException {
+ Path configDir = createTempDir();
+ Files.writeString(configDir.resolve("not-a-regex.txt"), "this is not yaml");
+
+ UserAgentCache cache = new UserAgentCache(100);
+ Map parsers = UserAgentParserRegistryImpl.createParsersMap(cache, configDir);
+
+ // Only the default parser should exist
+ assertEquals(1, parsers.size());
+ assertThat(parsers.get(UserAgentParserRegistry.DEFAULT_PARSER_NAME), notNullValue());
+ }
+
+ public void testMultipleConfigDirectories() throws IOException {
+ Path configDir1 = createTempDir();
+ Path configDir2 = createTempDir();
+ copyBuiltinRegexesTo(configDir1, "regexes-a.yml");
+ copyBuiltinRegexesTo(configDir2, "regexes-b.yml");
+
+ UserAgentCache cache = new UserAgentCache(100);
+ Map parsers = UserAgentParserRegistryImpl.createParsersMap(cache, configDir1, configDir2);
+
+ assertThat(parsers.get(UserAgentParserRegistry.DEFAULT_PARSER_NAME), notNullValue());
+ assertThat(parsers.get("regexes-a.yml"), notNullValue());
+ assertThat(parsers.get("regexes-b.yml"), notNullValue());
+ }
+
+ public void testNonExistentDirectoryIgnored() {
+ Path nonExistent = createTempDir().resolve("does-not-exist");
+ UserAgentCache cache = new UserAgentCache(100);
+ Map parsers = UserAgentParserRegistryImpl.createParsersMap(cache, nonExistent);
+
+ // Only the default parser should exist
+ assertEquals(1, parsers.size());
+ assertThat(parsers.get(UserAgentParserRegistry.DEFAULT_PARSER_NAME), notNullValue());
+ }
+
+ /**
+ * Copies the builtin regexes.yml into the given directory with the specified filename.
+ */
+ private static void copyBuiltinRegexesTo(Path directory, String filename) throws IOException {
+ try (InputStream builtinRegexes = UserAgentPlugin.class.getResourceAsStream("/regexes.yml")) {
+ assertNotNull("builtin regexes.yml not found", builtinRegexes);
+ Files.copy(builtinRegexes, directory.resolve(filename));
+ }
+ }
+}
diff --git a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java
index 611861ff0289f..2405f89b33531 100644
--- a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java
+++ b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java
@@ -752,7 +752,7 @@ private void construct(
FailureStoreMetrics failureStoreMetrics = new FailureStoreMetrics(telemetryProvider.getMeterRegistry());
MatcherWatchdog matcherWatchdog = IngestService.createGrokThreadWatchdog(environment, threadPool);
UserAgentParserRegistry userAgentParserRegistry = getSinglePlugin(UserAgentParserRegistryProvider.class).map(
- p -> p.createUserAgentParserRegistry(environment)
+ p -> p.createRegistry(environment)
).orElse(UserAgentParserRegistry.NOOP);
final IngestService ingestService = new IngestService(
clusterService,
@@ -1370,6 +1370,7 @@ public void sendRequest(
b.bind(BigArrays.class).toInstance(bigArrays);
b.bind(PageCacheRecycler.class).toInstance(pageCacheRecycler);
b.bind(IngestService.class).toInstance(ingestService);
+ b.bind(UserAgentParserRegistry.class).toInstance(userAgentParserRegistry);
b.bind(IndexingPressure.class).toInstance(indexingLimits);
b.bind(IncrementalBulkService.class).toInstance(incrementalBulkService);
b.bind(AggregationUsageService.class).toInstance(searchModule.getValuesSourceRegistry().getUsageService());
diff --git a/server/src/main/java/org/elasticsearch/plugins/UserAgentParserRegistryProvider.java b/server/src/main/java/org/elasticsearch/plugins/UserAgentParserRegistryProvider.java
index b46468c761b76..6abaf9772b9f8 100644
--- a/server/src/main/java/org/elasticsearch/plugins/UserAgentParserRegistryProvider.java
+++ b/server/src/main/java/org/elasticsearch/plugins/UserAgentParserRegistryProvider.java
@@ -22,5 +22,5 @@ public interface UserAgentParserRegistryProvider {
/**
* Creates a {@link UserAgentParserRegistry} for the given environment.
*/
- UserAgentParserRegistry createUserAgentParserRegistry(Environment env);
+ UserAgentParserRegistry createRegistry(Environment env);
}
diff --git a/x-pack/plugin/esql/build.gradle b/x-pack/plugin/esql/build.gradle
index 183a2d9aba415..275602d72d080 100644
--- a/x-pack/plugin/esql/build.gradle
+++ b/x-pack/plugin/esql/build.gradle
@@ -44,6 +44,7 @@ dependencies {
implementation project(':libs:dissect')
implementation project(':libs:grok')
implementation project(':libs:web-utils')
+ implementation project(':libs:user-agent-api')
implementation project(':libs:exponential-histogram')
api "org.apache.lucene:lucene-spatial3d:${versions.lucene}"
api "org.antlr:antlr4-runtime:${versions.antlr4}"
@@ -83,11 +84,13 @@ dependencies {
testImplementation project(path: ':modules:legacy-geo')
testImplementation project(path: ':modules:data-streams')
testImplementation project(path: ':modules:mapper-extras')
+ testImplementation project(path: ':modules:user-agent')
testImplementation project(xpackModule('esql:compute:test'))
testImplementation('ch.obermuhlner:big-math:2.3.2')
testImplementation('net.nextencia:rrdiagram:0.9.4')
testImplementation('org.webjars.npm:fontsource__roboto-mono:4.5.7')
+ internalClusterTestImplementation project(":modules:user-agent")
internalClusterTestImplementation project(":modules:mapper-extras")
internalClusterTestImplementation project(":plugins:mapper-size")
internalClusterTestImplementation project(xpackModule('inference:qa:test-service-plugin'))
diff --git a/x-pack/plugin/esql/qa/server/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/mixed/Clusters.java b/x-pack/plugin/esql/qa/server/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/mixed/Clusters.java
index 1f5858738747f..1f83ac19e35ea 100644
--- a/x-pack/plugin/esql/qa/server/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/mixed/Clusters.java
+++ b/x-pack/plugin/esql/qa/server/mixed-cluster/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/mixed/Clusters.java
@@ -10,6 +10,7 @@
import org.elasticsearch.test.cluster.ElasticsearchCluster;
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
import org.elasticsearch.test.cluster.util.Version;
+import org.elasticsearch.test.cluster.util.resource.Resource;
import org.elasticsearch.xpack.esql.CsvTestUtils;
import java.nio.file.Path;
@@ -31,7 +32,8 @@ public static ElasticsearchCluster mixedVersionCluster(Path csvDataPath) {
.withNode(node -> node.version(Version.CURRENT))
.setting("xpack.security.enabled", "false")
.setting("xpack.license.self_generated.type", "trial")
- .setting("path.repo", csvDataPath::toString);
+ .setting("path.repo", csvDataPath::toString)
+ .configFile("user-agent/custom-regexes.yml", Resource.fromClasspath("custom-regexes.yml"));
if (supportRetryOnShardFailures(oldVersion) == false) {
cluster.setting("cluster.routing.rebalance.enable", "none");
}
diff --git a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/Clusters.java b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/Clusters.java
index 89f2e9f8afb83..9e3f82fca0fc2 100644
--- a/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/Clusters.java
+++ b/x-pack/plugin/esql/qa/server/multi-clusters/src/javaRestTest/java/org/elasticsearch/xpack/esql/ccq/Clusters.java
@@ -11,6 +11,7 @@
import org.elasticsearch.test.cluster.FeatureFlag;
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
import org.elasticsearch.test.cluster.util.Version;
+import org.elasticsearch.test.cluster.util.resource.Resource;
import org.elasticsearch.xpack.esql.CsvTestUtils;
import java.nio.file.Path;
@@ -34,6 +35,7 @@ static ElasticsearchCluster remoteCluster(Path csvDataPath, Map
.setting("xpack.security.enabled", "false")
.setting("xpack.license.self_generated.type", "trial")
.setting("path.repo", csvDataPath::toString)
+ .configFile("user-agent/custom-regexes.yml", Resource.fromClasspath("custom-regexes.yml"))
.feature(FeatureFlag.ESQL_VIEWS)
.shared(true);
if (supportRetryOnShardFailures(version) == false) {
@@ -100,6 +102,7 @@ public static ElasticsearchCluster localCluster(
.setting("cluster.remote.connections_per_cluster", "1")
.setting("cluster.remote." + REMOTE_CLUSTER_NAME + ".skip_unavailable", skipUnavailable.toString())
.setting("path.repo", csvDataPath::toString)
+ .configFile("user-agent/custom-regexes.yml", Resource.fromClasspath("custom-regexes.yml"))
.feature(FeatureFlag.ESQL_VIEWS)
.shared(true);
if (supportRetryOnShardFailures(version) == false) {
diff --git a/x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/Clusters.java b/x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/Clusters.java
index 23e731af45b6b..54fbe720371fd 100644
--- a/x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/Clusters.java
+++ b/x-pack/plugin/esql/qa/server/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/multi_node/Clusters.java
@@ -10,6 +10,7 @@
import org.elasticsearch.test.cluster.ElasticsearchCluster;
import org.elasticsearch.test.cluster.local.LocalClusterConfigProvider;
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
+import org.elasticsearch.test.cluster.util.resource.Resource;
import org.elasticsearch.xpack.esql.CsvTestUtils;
import java.nio.file.Path;
@@ -26,6 +27,7 @@ public static ElasticsearchCluster testCluster(Path csvDataPath, LocalClusterCon
.setting("xpack.security.enabled", "false")
.setting("xpack.license.self_generated.type", "trial")
.setting("path.repo", csvDataPath::toString)
+ .configFile("user-agent/custom-regexes.yml", Resource.fromClasspath("custom-regexes.yml"))
.apply(() -> configProvider)
.build();
}
diff --git a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/Clusters.java b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/Clusters.java
index 8e0a8726db481..108492528c96a 100644
--- a/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/Clusters.java
+++ b/x-pack/plugin/esql/qa/server/single-node/src/javaRestTest/java/org/elasticsearch/xpack/esql/qa/single_node/Clusters.java
@@ -11,6 +11,7 @@
import org.elasticsearch.test.cluster.FeatureFlag;
import org.elasticsearch.test.cluster.local.LocalClusterConfigProvider;
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
+import org.elasticsearch.test.cluster.util.resource.Resource;
import org.elasticsearch.xpack.esql.CsvTestUtils;
import java.nio.file.Path;
@@ -32,6 +33,7 @@ public static ElasticsearchCluster testCluster(Path csvDataPath, LocalClusterCon
.setting("xpack.license.self_generated.type", "trial")
.setting("path.repo", csvDataPath::toString)
.shared(true)
+ .configFile("user-agent/custom-regexes.yml", Resource.fromClasspath("custom-regexes.yml"))
.apply(() -> configProvider)
.feature(FeatureFlag.EXTENDED_DOC_VALUES_PARAMS)
.build();
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/EsqlTestUtils.java b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/EsqlTestUtils.java
index f91d4b4bf8e02..539f431c1e124 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/EsqlTestUtils.java
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/EsqlTestUtils.java
@@ -77,6 +77,7 @@
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.transport.RemoteTransportException;
import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xcontent.json.JsonXContent;
import org.elasticsearch.xpack.core.analytics.mapper.EncodedTDigest;
@@ -723,6 +724,7 @@ public static LogicalOptimizerContext unboundLogicalOptimizerContext() {
mock(IndexNameExpressionResolver.class),
null,
new InferenceService(mock(Client.class), clusterService),
+ UserAgentParserRegistry.NOOP,
new BlockFactoryProvider(PlannerUtils.NON_BREAKING_BLOCK_FACTORY),
new PlannerSettings.Holder(clusterService),
CrossProjectModeDecider.NOOP
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/EsqlQueryGenerator.java b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/EsqlQueryGenerator.java
index 0bd06e3b05cab..435465583ff5f 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/EsqlQueryGenerator.java
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/EsqlQueryGenerator.java
@@ -29,6 +29,7 @@
import org.elasticsearch.xpack.esql.generator.command.pipe.StatsGenerator;
import org.elasticsearch.xpack.esql.generator.command.pipe.TimeSeriesStatsGenerator;
import org.elasticsearch.xpack.esql.generator.command.pipe.UriPartsGenerator;
+import org.elasticsearch.xpack.esql.generator.command.pipe.UserAgentGenerator;
import org.elasticsearch.xpack.esql.generator.command.pipe.WhereGenerator;
import org.elasticsearch.xpack.esql.generator.command.source.FromGenerator;
import org.elasticsearch.xpack.esql.generator.command.source.PromQLGenerator;
@@ -121,6 +122,7 @@ public class EsqlQueryGenerator {
SortGenerator.INSTANCE,
StatsGenerator.INSTANCE,
UriPartsGenerator.INSTANCE,
+ UserAgentGenerator.INSTANCE,
RegisteredDomainGenerator.INSTANCE,
WhereGenerator.INSTANCE
);
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/command/pipe/UserAgentGenerator.java b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/command/pipe/UserAgentGenerator.java
new file mode 100644
index 0000000000000..fbfd168378037
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/generator/command/pipe/UserAgentGenerator.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.generator.command.pipe;
+
+import org.elasticsearch.xpack.esql.generator.Column;
+import org.elasticsearch.xpack.esql.generator.EsqlQueryGenerator;
+import org.elasticsearch.xpack.esql.generator.QueryExecutor;
+import org.elasticsearch.xpack.esql.generator.command.CommandGenerator;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.elasticsearch.test.ESTestCase.randomBoolean;
+import static org.elasticsearch.test.ESTestCase.randomFrom;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.DEVICE_NAME;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.NAME;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.OS_FULL;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.OS_NAME;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.OS_VERSION;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.VERSION;
+
+/**
+ * Generator for the USER_AGENT pipe command. Produces {@code | user_agent = }
+ * with no WITH clause (default properties and extract_device_type=false).
+ */
+public class UserAgentGenerator implements CommandGenerator {
+
+ public static final CommandGenerator INSTANCE = new UserAgentGenerator();
+
+ public static final String USER_AGENT = "user_agent";
+
+ private static final String PREFIX = "prefix";
+
+ /**
+ * Default output fields when USER_AGENT is used without WITH clause: extractDeviceType=false,
+ * properties=["name", "version", "os", "device"] → device.name but not device.type.
+ */
+ private static final LinkedHashMap USER_AGENT_DEFAULT_OUTPUT_FIELDS = new LinkedHashMap<>();
+ static {
+ USER_AGENT_DEFAULT_OUTPUT_FIELDS.put(NAME, "keyword");
+ USER_AGENT_DEFAULT_OUTPUT_FIELDS.put(VERSION, "keyword");
+ USER_AGENT_DEFAULT_OUTPUT_FIELDS.put(OS_NAME, "keyword");
+ USER_AGENT_DEFAULT_OUTPUT_FIELDS.put(OS_VERSION, "keyword");
+ USER_AGENT_DEFAULT_OUTPUT_FIELDS.put(OS_FULL, "keyword");
+ USER_AGENT_DEFAULT_OUTPUT_FIELDS.put(DEVICE_NAME, "keyword");
+ }
+
+ private static final String[] LITERAL_USER_AGENTS = new String[] {
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+ "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0",
+ "curl/7.68.0",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
+ "FileZilla/3.57.0",
+ "Go-http-client/1.1",
+ "Uptime-Robot/1.0" };
+
+ private static final Set USER_AGENT_LIKE_FIELD_NAMES = Set.of("user_agent", "useragent", "ua");
+
+ @Override
+ public CommandDescription generate(
+ List previousCommands,
+ List previousOutput,
+ QuerySchema schema,
+ QueryExecutor executor
+ ) {
+ String inputExpression = pickUserAgentInput(previousOutput);
+ if (inputExpression == null) {
+ return EMPTY_DESCRIPTION;
+ }
+ String prefixRaw = EsqlQueryGenerator.randomIdentifier();
+ String prefixForCmd = EsqlQueryGenerator.needsQuoting(prefixRaw) ? EsqlQueryGenerator.quote(prefixRaw) : prefixRaw;
+ String cmdString = " | user_agent " + prefixForCmd + " = " + inputExpression;
+ return new CommandDescription(USER_AGENT, this, cmdString, Map.of(PREFIX, prefixRaw));
+ }
+
+ private static String pickUserAgentInput(List previousOutput) {
+ if (randomBoolean()) {
+ return "\"" + randomFrom(LITERAL_USER_AGENTS) + "\"";
+ }
+ return userAgentLikeFieldOrRandomString(previousOutput);
+ }
+
+ private static String userAgentLikeFieldOrRandomString(List previousOutput) {
+ List stringColumns = previousOutput.stream()
+ .filter(c -> "keyword".equals(c.type()) || "text".equals(c.type()))
+ .filter(EsqlQueryGenerator::fieldCanBeUsed)
+ .toList();
+ if (stringColumns.isEmpty()) {
+ return null;
+ }
+ for (Column c : stringColumns) {
+ String name = c.name();
+ if (USER_AGENT_LIKE_FIELD_NAMES.contains(EsqlQueryGenerator.unquote(name))) {
+ return EsqlQueryGenerator.needsQuoting(name) ? EsqlQueryGenerator.quote(name) : name;
+ }
+ }
+ Column chosen = randomFrom(stringColumns);
+ String name = chosen.name();
+ return EsqlQueryGenerator.needsQuoting(name) ? EsqlQueryGenerator.quote(name) : name;
+ }
+
+ @Override
+ public ValidationResult validateOutput(
+ List previousCommands,
+ CommandDescription commandDescription,
+ List previousColumns,
+ List> previousOutput,
+ List columns,
+ List> output
+ ) {
+ if (commandDescription == EMPTY_DESCRIPTION) {
+ return VALIDATION_OK;
+ }
+
+ String prefix = (String) commandDescription.context().get(PREFIX);
+ if (prefix == null) {
+ return new ValidationResult(false, "Missing prefix in command context");
+ }
+
+ int expectedColumns = USER_AGENT_DEFAULT_OUTPUT_FIELDS.size();
+ int expectedTotal = previousColumns.size() + expectedColumns;
+ if (columns.size() != expectedTotal) {
+ return new ValidationResult(
+ false,
+ "Expecting ["
+ + expectedTotal
+ + "] columns ("
+ + previousColumns.size()
+ + " previous + "
+ + expectedColumns
+ + " USER_AGENT), got ["
+ + columns.size()
+ + "]"
+ );
+ }
+
+ var it = columns.iterator();
+ int pos = 0;
+
+ for (Column prev : previousColumns) {
+ if (it.hasNext() == false) {
+ return new ValidationResult(false, "Missing previous column [" + prev.name() + "] in output");
+ }
+ Column actual = it.next();
+ pos++;
+ if (actual.name().equals(prev.name()) == false) {
+ return new ValidationResult(
+ false,
+ "At position " + pos + ": expected column [" + prev.name() + "], got [" + actual.name() + "]"
+ );
+ }
+ if (actual.type().equals(prev.type()) == false) {
+ return new ValidationResult(
+ false,
+ "Column [" + prev.name() + "] type changed from [" + prev.type() + "] to [" + actual.type() + "]"
+ );
+ }
+ }
+
+ for (Map.Entry e : USER_AGENT_DEFAULT_OUTPUT_FIELDS.entrySet()) {
+ if (it.hasNext() == false) {
+ return new ValidationResult(
+ false,
+ "Missing USER_AGENT column [" + prefix + "." + e.getKey() + "] (expected type [" + e.getValue() + "])"
+ );
+ }
+ Column actual = it.next();
+ pos++;
+ String expectedName = prefix + "." + e.getKey();
+ String expectedType = e.getValue();
+ if (actual.name().equals(expectedName) == false) {
+ return new ValidationResult(
+ false,
+ "At position " + pos + ": expected USER_AGENT column [" + expectedName + "], got [" + actual.name() + "]"
+ );
+ }
+ if (actual.type().equals(expectedType) == false) {
+ return new ValidationResult(
+ false,
+ "USER_AGENT column [" + expectedName + "] expected type [" + expectedType + "], got [" + actual.type() + "]"
+ );
+ }
+ }
+
+ return CommandGenerator.expectSameRowCount(previousCommands, previousOutput, output);
+ }
+}
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/custom-regexes.yml b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/custom-regexes.yml
new file mode 100644
index 0000000000000..0607bfaf96571
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/custom-regexes.yml
@@ -0,0 +1,18 @@
+# Minimal custom regex file for testing the USER_AGENT command's regex_file option.
+# This file intentionally uses a simplified set of patterns that produce different
+# results than the default uap-core regexes, so tests can verify the custom file
+# was actually loaded and used.
+#
+# Differences from the default parser for the Chrome UA string used in tests:
+# - name: "Custom Chrome" instead of "Chrome"
+
+user_agent_parsers:
+ - regex: '(Chrome)/(\d+)\.(\d+)\.(\d+)\.(\d+)'
+ family_replacement: 'Custom Chrome'
+
+os_parsers:
+ - regex: 'Intel (Mac OS X) (\d+)[_.](\d+)(?:[_.](\d+)|)'
+
+device_parsers:
+ - regex: '(Macintosh)'
+ device_replacement: 'Mac'
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/user_agent.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/user_agent.csv-spec
new file mode 100644
index 0000000000000..1979c0936abd3
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/user_agent.csv-spec
@@ -0,0 +1,210 @@
+basic
+required_capability: user_agent_command
+
+// tag::basic[]
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT ua = input WITH { "extract_device_type": true }
+| KEEP ua.*
+// end::basic[]
+;
+
+// tag::basic-result[]
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword | ua.device.type:keyword
+Chrome | 33.0.1750.149 | Mac OS X | 10.9.2 | Mac OS X 10.9.2 | Mac | Desktop
+// end::basic-result[]
+;
+
+
+withoutDeviceType
+required_capability: user_agent_command
+
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT ua = input
+| KEEP ua.*
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword
+Chrome | 33.0.1750.149 | Mac OS X | 10.9.2 | Mac OS X 10.9.2 | Mac
+;
+
+
+emptyOptions
+required_capability: user_agent_command
+
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT ua = input WITH {}
+| KEEP ua.*
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword
+Chrome | 33.0.1750.149 | Mac OS X | 10.9.2 | Mac OS X 10.9.2 | Mac
+;
+
+
+propertiesFilter
+required_capability: user_agent_command
+
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT ua = input WITH { "properties": ["name", "os"] }
+| KEEP ua.*
+;
+
+ua.name:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword
+Chrome | Mac OS X | 10.9.2 | Mac OS X 10.9.2
+;
+
+
+dottedPrefix
+required_capability: user_agent_command
+
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT a.b.c = input
+| KEEP a.b.c.name, a.b.c.version
+;
+
+a.b.c.name:keyword | a.b.c.version:keyword
+Chrome | 33.0.1750.149
+;
+
+
+testAfterFiltering
+required_capability: user_agent_command
+
+FROM web_logs
+| WHERE domain == "www.elastic.co"
+| USER_AGENT ua = user_agent
+| KEEP ua.name, ua.version
+| SORT ua.name
+;
+
+ua.name:keyword | ua.version:keyword
+Firefox | 119.0
+null | null
+;
+
+
+testBeforeFiltering
+required_capability: user_agent_command
+
+FROM web_logs
+| USER_AGENT ua = user_agent
+| WHERE ua.name == "Firefox"
+| KEEP ua.name, ua.version, ua.os.name
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword
+Firefox | 119.0 | Ubuntu
+;
+
+
+prefixSameAsInputFieldName
+required_capability: user_agent_command
+
+FROM web_logs
+| WHERE timestamp == "2024-01-10T10:07:22.111Z"
+| USER_AGENT user_agent = user_agent
+| KEEP user_agent.name, user_agent.version
+;
+
+user_agent.name:keyword | user_agent.version:keyword
+Firefox | 119.0
+;
+
+
+testNonBrowserAgent
+required_capability: user_agent_command
+
+FROM web_logs
+| WHERE domain == "app.example.com"
+| USER_AGENT ua = user_agent
+| KEEP ua.name, ua.version, ua.os.name
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword
+curl | 7.68.0 | null
+;
+
+
+multiValue
+required_capability: user_agent_command
+
+ROW input = ["Mozilla/5.0 Chrome/33.0", "curl/7.68.0"]
+| USER_AGENT ua = input
+| KEEP ua.name
+;
+warningregex: Line 2:3: java.lang.IllegalArgumentException: This command doesn't support multi-value input
+warningregex: Line 2:3: evaluation of \[USER_AGENT.* ua = input\] failed, treating result as null
+
+ua.name:keyword
+null
+;
+
+
+emptyStringInput
+required_capability: user_agent_command
+
+ROW input = ""
+| USER_AGENT ua = input
+| KEEP ua.*
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword
+null | null | null | null | null | null
+;
+
+
+emptyStringInputWithDeviceType
+required_capability: user_agent_command
+
+ROW input = ""
+| USER_AGENT ua = input WITH { "extract_device_type": true }
+| KEEP ua.*
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword | ua.device.type:keyword
+null | null | null | null | null | null | null
+;
+
+
+nullInputRow
+required_capability: user_agent_command
+required_capability: str_commands_accept_null
+
+ROW n = null
+| USER_AGENT ua = n
+;
+
+n:null | ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword
+ null | null | null | null | null | null | null
+;
+
+
+nullInput
+required_capability: user_agent_command
+required_capability: str_commands_accept_null
+
+FROM web_logs
+| EVAL n = null
+| USER_AGENT ua = n
+| KEEP n, ua.*
+| LIMIT 3
+;
+
+n:null | ua.name:keyword | ua.version:keyword | ua.os.name:keyword | ua.os.version:keyword | ua.os.full:keyword | ua.device.name:keyword
+ null | null | null | null | null | null | null
+ null | null | null | null | null | null | null
+ null | null | null | null | null | null | null
+;
+
+
+customRegexFile
+required_capability: user_agent_command
+
+ROW input = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36"
+| USER_AGENT ua = input WITH { "regex_file": "custom-regexes.yml" }
+| KEEP ua.name, ua.version, ua.os.name
+;
+
+ua.name:keyword | ua.version:keyword | ua.os.name:keyword
+Custom Chrome | 33.0.1750.149 | Mac OS X
+;
diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/CsvIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/CsvIT.java
index 73c2b59b20d4b..1a0965781d249 100644
--- a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/CsvIT.java
+++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/CsvIT.java
@@ -43,6 +43,7 @@
import org.elasticsearch.transport.TransportRequest;
import org.elasticsearch.transport.TransportRequestHandler;
import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.useragent.UserAgentPlugin;
import org.elasticsearch.xcontent.XContentParserConfiguration;
import org.elasticsearch.xcontent.XContentType;
import org.elasticsearch.xcontent.json.JsonXContent;
@@ -83,7 +84,10 @@
import org.junit.BeforeClass;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
@@ -154,8 +158,11 @@ public static List
*/
@Override public T visitRegisteredDomainCommand(EsqlBaseParser.RegisteredDomainCommandContext ctx) { return visitChildren(ctx); }
+ /**
+ * {@inheritDoc}
+ *
+ * The default implementation returns the result of calling
+ * {@link #visitChildren} on {@code ctx}.
+ */
+ @Override public T visitUserAgentCommand(EsqlBaseParser.UserAgentCommandContext ctx) { return visitChildren(ctx); }
/**
* {@inheritDoc}
*
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
index 3750514caf755..87c01ff523afe 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
@@ -845,6 +845,16 @@ public interface EsqlBaseParserListener extends ParseTreeListener {
* @param ctx the parse tree
*/
void exitRegisteredDomainCommand(EsqlBaseParser.RegisteredDomainCommandContext ctx);
+ /**
+ * Enter a parse tree produced by {@link EsqlBaseParser#userAgentCommand}.
+ * @param ctx the parse tree
+ */
+ void enterUserAgentCommand(EsqlBaseParser.UserAgentCommandContext ctx);
+ /**
+ * Exit a parse tree produced by {@link EsqlBaseParser#userAgentCommand}.
+ * @param ctx the parse tree
+ */
+ void exitUserAgentCommand(EsqlBaseParser.UserAgentCommandContext ctx);
/**
* Enter a parse tree produced by {@link EsqlBaseParser#setCommand}.
* @param ctx the parse tree
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
index 896b3dafa8753..1fb92354b5add 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
@@ -514,6 +514,12 @@ public interface EsqlBaseParserVisitor extends ParseTreeVisitor {
* @return the visitor result
*/
T visitRegisteredDomainCommand(EsqlBaseParser.RegisteredDomainCommandContext ctx);
+ /**
+ * Visit a parse tree produced by {@link EsqlBaseParser#userAgentCommand}.
+ * @param ctx the parse tree
+ * @return the visitor result
+ */
+ T visitUserAgentCommand(EsqlBaseParser.UserAgentCommandContext ctx);
/**
* Visit a parse tree produced by {@link EsqlBaseParser#setCommand}.
* @param ctx the parse tree
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java
index 0721e326b82ac..c1beeb337b469 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java
@@ -21,6 +21,8 @@
import org.elasticsearch.index.IndexMode;
import org.elasticsearch.index.mapper.IdFieldMapper;
import org.elasticsearch.transport.RemoteClusterAware;
+import org.elasticsearch.useragent.api.UserAgentParsedInfo;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
@@ -84,6 +86,7 @@
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedExternalRelation;
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation;
import org.elasticsearch.xpack.esql.plan.logical.UriParts;
+import org.elasticsearch.xpack.esql.plan.logical.UserAgent;
import org.elasticsearch.xpack.esql.plan.logical.fuse.Fuse;
import org.elasticsearch.xpack.esql.plan.logical.inference.Completion;
import org.elasticsearch.xpack.esql.plan.logical.inference.InferencePlan;
@@ -104,6 +107,7 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.SequencedMap;
import java.util.Set;
import static java.util.Collections.emptyList;
@@ -479,6 +483,123 @@ public PlanFactory visitRegisteredDomainCommand(EsqlBaseParser.RegisteredDomainC
return child -> RegisteredDomain.createInitialInstance(source, child, input, outputPrefix);
}
+ @Override
+ public PlanFactory visitUserAgentCommand(EsqlBaseParser.UserAgentCommandContext ctx) {
+ Source source = source(ctx);
+
+ Attribute outputPrefix = visitQualifiedName(ctx.qualifiedName());
+ if (outputPrefix == null) {
+ throw new ParsingException(source, "USER_AGENT command requires an output field prefix");
+ }
+
+ Expression input = expression(ctx.primaryExpression());
+ if (input == null) {
+ throw new ParsingException(source, "USER_AGENT command requires an input expression");
+ }
+
+ return applyUserAgentOptions(source, input, outputPrefix, ctx.commandNamedParameters());
+ }
+
+ private PlanFactory applyUserAgentOptions(
+ Source source,
+ Expression input,
+ Attribute outputPrefix,
+ EsqlBaseParser.CommandNamedParametersContext ctx
+ ) {
+ MapExpression optionsExpression = ctx == null ? null : visitCommandNamedParameters(ctx);
+
+ String regexFile = UserAgentParserRegistry.DEFAULT_PARSER_NAME;
+ boolean extractDeviceType = false;
+ List properties = List.of("name", "version", "os", "device");
+
+ if (optionsExpression != null) {
+ Map optionsMap = optionsExpression.keyFoldedMap();
+
+ Expression regexFileExpr = optionsMap.remove("regex_file");
+ if (regexFileExpr != null) {
+ if ((regexFileExpr instanceof Literal && DataType.isString(regexFileExpr.dataType())) == false) {
+ throw new ParsingException(regexFileExpr.source(), "Option [regex_file] must be a string literal");
+ }
+ regexFile = BytesRefs.toString(((Literal) regexFileExpr).value());
+ }
+
+ Expression extractDeviceTypeExpr = optionsMap.remove("extract_device_type");
+ if (extractDeviceTypeExpr != null) {
+ if ((extractDeviceTypeExpr instanceof Literal lit && lit.dataType() == DataType.BOOLEAN) == false) {
+ throw new ParsingException(extractDeviceTypeExpr.source(), "Option [extract_device_type] must be a boolean literal");
+ }
+ extractDeviceType = (Boolean) ((Literal) extractDeviceTypeExpr).value();
+ }
+
+ Expression propertiesExpr = optionsMap.remove("properties");
+ if (propertiesExpr != null) {
+ if (propertiesExpr instanceof Literal propLit && propLit.value() instanceof List> propList) {
+ properties = new ArrayList<>();
+ for (Object item : propList) {
+ if (item instanceof BytesRef) {
+ properties.add(BytesRefs.toString(item));
+ } else {
+ throw new ParsingException(propertiesExpr.source(), "Option [properties] must be a list of string literals");
+ }
+ }
+ } else {
+ throw new ParsingException(propertiesExpr.source(), "Option [properties] must be a list of string literals");
+ }
+ }
+
+ // The ingest processor supports an "original" property that includes the raw user-agent string in the output.
+ // In ES|QL this is unnecessary since the input expression is already available as a column. Silently ignore it
+ // for compatibility with ingest processor configurations.
+ optionsMap.remove("original");
+
+ if (optionsMap.isEmpty() == false) {
+ throw new ParsingException(
+ source,
+ "Invalid option{} {} in USER_AGENT, expected one of [regex_file, extract_device_type, properties]",
+ optionsMap.size() > 1 ? "s" : "",
+ optionsMap.keySet()
+ );
+ }
+ }
+
+ SequencedMap> allFields = UserAgentParsedInfo.getUserAgentInfoFields();
+ LinkedHashMap> filteredFields = new LinkedHashMap<>();
+ boolean finalExtractDeviceType = extractDeviceType;
+ for (String property : properties) {
+ switch (property) {
+ case "name" -> filteredFields.put(UserAgentParsedInfo.NAME, allFields.get(UserAgentParsedInfo.NAME));
+ case "version" -> filteredFields.put(UserAgentParsedInfo.VERSION, allFields.get(UserAgentParsedInfo.VERSION));
+ case "os" -> {
+ filteredFields.put(UserAgentParsedInfo.OS_NAME, allFields.get(UserAgentParsedInfo.OS_NAME));
+ filteredFields.put(UserAgentParsedInfo.OS_VERSION, allFields.get(UserAgentParsedInfo.OS_VERSION));
+ filteredFields.put(UserAgentParsedInfo.OS_FULL, allFields.get(UserAgentParsedInfo.OS_FULL));
+ }
+ case "device" -> {
+ filteredFields.put(UserAgentParsedInfo.DEVICE_NAME, allFields.get(UserAgentParsedInfo.DEVICE_NAME));
+ if (finalExtractDeviceType) {
+ filteredFields.put(UserAgentParsedInfo.DEVICE_TYPE, allFields.get(UserAgentParsedInfo.DEVICE_TYPE));
+ }
+ }
+ default -> throw new ParsingException(
+ source,
+ "Unknown property [{}] in USER_AGENT, expected one of [name, version, os, device]",
+ property
+ );
+ }
+ }
+
+ String finalRegexFile = regexFile;
+ return child -> UserAgent.createInitialInstance(
+ source,
+ child,
+ input,
+ outputPrefix,
+ finalExtractDeviceType,
+ finalRegexFile,
+ filteredFields
+ );
+ }
+
@Override
public PlanFactory visitStatsCommand(EsqlBaseParser.StatsCommandContext ctx) {
final ParserUtils.Stats stats = stats(source(ctx), ctx.grouping, ctx.stats);
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/PlanWritables.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/PlanWritables.java
index af82b6cabb704..9cc137c96d6cd 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/PlanWritables.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/PlanWritables.java
@@ -34,6 +34,7 @@
import org.elasticsearch.xpack.esql.plan.logical.TopNBy;
import org.elasticsearch.xpack.esql.plan.logical.TsInfo;
import org.elasticsearch.xpack.esql.plan.logical.UriParts;
+import org.elasticsearch.xpack.esql.plan.logical.UserAgent;
import org.elasticsearch.xpack.esql.plan.logical.inference.Completion;
import org.elasticsearch.xpack.esql.plan.logical.inference.Rerank;
import org.elasticsearch.xpack.esql.plan.logical.join.InlineJoin;
@@ -72,6 +73,7 @@
import org.elasticsearch.xpack.esql.plan.physical.TopNExec;
import org.elasticsearch.xpack.esql.plan.physical.TsInfoExec;
import org.elasticsearch.xpack.esql.plan.physical.UriPartsExec;
+import org.elasticsearch.xpack.esql.plan.physical.UserAgentExec;
import org.elasticsearch.xpack.esql.plan.physical.inference.CompletionExec;
import org.elasticsearch.xpack.esql.plan.physical.inference.RerankExec;
@@ -121,7 +123,8 @@ public static List logical() {
UriParts.ENTRY,
MetricsInfo.ENTRY,
RegisteredDomain.ENTRY,
- TsInfo.ENTRY
+ TsInfo.ENTRY,
+ UserAgent.ENTRY
);
}
@@ -158,7 +161,8 @@ public static List physical() {
UriPartsExec.ENTRY,
MetricsInfoExec.ENTRY,
RegisteredDomainExec.ENTRY,
- TsInfoExec.ENTRY
+ TsInfoExec.ENTRY,
+ UserAgentExec.ENTRY
);
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/UserAgent.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/UserAgent.java
new file mode 100644
index 0000000000000..1edb73f853b88
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/UserAgent.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.plan.logical;
+
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xpack.esql.common.Failures;
+import org.elasticsearch.xpack.esql.core.expression.Attribute;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+import java.util.SequencedMap;
+
+import static org.elasticsearch.xpack.esql.common.Failure.fail;
+
+/**
+ * The logical plan for the {@code USER_AGENT} command.
+ */
+public class UserAgent extends CompoundOutputEval {
+
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+ LogicalPlan.class,
+ "UserAgent",
+ UserAgent::new
+ );
+
+ private final boolean extractDeviceType;
+ private final String regexFile;
+
+ /**
+ * Creates the initial logical plan instance, computing output attributes from the filtered output fields.
+ * The caller (LogicalPlanBuilder) resolves properties and extractDeviceType into the filtered output fields map.
+ */
+ public static UserAgent createInitialInstance(
+ Source source,
+ LogicalPlan child,
+ Expression input,
+ Attribute outputFieldPrefix,
+ boolean extractDeviceType,
+ String regexFile,
+ SequencedMap> filteredOutputFields
+ ) {
+ List outputFieldNames = filteredOutputFields.keySet().stream().toList();
+ List outputFieldAttributes = computeOutputAttributes(filteredOutputFields, outputFieldPrefix.name(), source);
+ return new UserAgent(source, child, input, outputFieldNames, outputFieldAttributes, extractDeviceType, regexFile);
+ }
+
+ public UserAgent(
+ Source source,
+ LogicalPlan child,
+ Expression input,
+ List outputFieldNames,
+ List outputFieldAttributes,
+ boolean extractDeviceType,
+ String regexFile
+ ) {
+ super(source, child, input, outputFieldNames, outputFieldAttributes);
+ this.extractDeviceType = extractDeviceType;
+ this.regexFile = regexFile;
+ }
+
+ public UserAgent(StreamInput in) throws IOException {
+ super(in);
+ this.extractDeviceType = in.readBoolean();
+ this.regexFile = in.readString();
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ super.writeTo(out);
+ out.writeBoolean(extractDeviceType);
+ out.writeString(regexFile);
+ }
+
+ @Override
+ public UserAgent createNewInstance(
+ Source source,
+ LogicalPlan child,
+ Expression input,
+ List outputFieldNames,
+ List outputFieldAttributes
+ ) {
+ return new UserAgent(source, child, input, outputFieldNames, outputFieldAttributes, this.extractDeviceType, this.regexFile);
+ }
+
+ @Override
+ protected NodeInfo extends LogicalPlan> info() {
+ return NodeInfo.create(
+ this,
+ UserAgent::new,
+ child(),
+ input,
+ outputFieldNames(),
+ generatedAttributes(),
+ extractDeviceType,
+ regexFile
+ );
+ }
+
+ @Override
+ protected int innerHashCode() {
+ return Objects.hash(extractDeviceType, regexFile);
+ }
+
+ @Override
+ protected boolean innerEquals(CompoundOutputEval> other) {
+ return other instanceof UserAgent ua && extractDeviceType == ua.extractDeviceType && Objects.equals(regexFile, ua.regexFile);
+ }
+
+ @Override
+ public String getWriteableName() {
+ return ENTRY.name;
+ }
+
+ @Override
+ public String telemetryLabel() {
+ return "USER_AGENT";
+ }
+
+ @Override
+ public void postAnalysisVerification(Failures failures) {
+ if (input.resolved()) {
+ DataType type = input.dataType();
+ if (DataType.isNull(type) == false && DataType.isString(type) == false) {
+ failures.add(fail(input, "Input for USER_AGENT must be of type [string] but is [{}]", type.typeName()));
+ }
+ }
+ }
+
+ public boolean extractDeviceType() {
+ return extractDeviceType;
+ }
+
+ public String regexFile() {
+ return regexFile;
+ }
+}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/CompoundOutputEvalExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/CompoundOutputEvalExec.java
index 9eb18f2e3ea21..a16b93d9897e4 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/CompoundOutputEvalExec.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/CompoundOutputEvalExec.java
@@ -14,7 +14,6 @@
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
import org.elasticsearch.xpack.esql.core.tree.Source;
-import org.elasticsearch.xpack.esql.evaluator.command.CompoundOutputEvaluator;
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
import java.io.IOException;
@@ -26,10 +25,7 @@
/**
* Abstract base class for physical plans that produce compound outputs from a single input.
*/
-public abstract class CompoundOutputEvalExec extends UnaryExec
- implements
- EstimatesRowSize,
- CompoundOutputEvaluator.OutputFieldsCollectorProvider {
+public abstract class CompoundOutputEvalExec extends UnaryExec implements EstimatesRowSize {
/**
* The input by which the evaluation is performed.
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/RegisteredDomainExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/RegisteredDomainExec.java
index 221deac124bbd..210cdbba7acae 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/RegisteredDomainExec.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/RegisteredDomainExec.java
@@ -21,7 +21,7 @@
/**
* Physical plan for the REGISTERED_DOMAIN command.
*/
-public class RegisteredDomainExec extends CompoundOutputEvalExec {
+public class RegisteredDomainExec extends CompoundOutputEvalExec implements CompoundOutputEvaluator.OutputFieldsCollectorProvider {
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
PhysicalPlan.class,
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UriPartsExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UriPartsExec.java
index bc472b83017cf..db50274d4b4e7 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UriPartsExec.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UriPartsExec.java
@@ -21,7 +21,7 @@
/**
* Physical plan for the URI_PARTS command.
*/
-public class UriPartsExec extends CompoundOutputEvalExec {
+public class UriPartsExec extends CompoundOutputEvalExec implements CompoundOutputEvaluator.OutputFieldsCollectorProvider {
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
PhysicalPlan.class,
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UserAgentExec.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UserAgentExec.java
new file mode 100644
index 0000000000000..2473e4096eeea
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/physical/UserAgentExec.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.plan.physical;
+
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xpack.esql.core.expression.Attribute;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Physical plan for the USER_AGENT command.
+ */
+public class UserAgentExec extends CompoundOutputEvalExec {
+
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+ PhysicalPlan.class,
+ "UserAgentExec",
+ UserAgentExec::new
+ );
+
+ private final boolean extractDeviceType;
+ private final String regexFile;
+
+ public UserAgentExec(
+ Source source,
+ PhysicalPlan child,
+ Expression input,
+ List outputFieldNames,
+ List outputFieldAttributes,
+ boolean extractDeviceType,
+ String regexFile
+ ) {
+ super(source, child, input, outputFieldNames, outputFieldAttributes);
+ this.extractDeviceType = extractDeviceType;
+ this.regexFile = regexFile;
+ }
+
+ public UserAgentExec(StreamInput in) throws IOException {
+ super(in);
+ this.extractDeviceType = in.readBoolean();
+ this.regexFile = in.readString();
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ super.writeTo(out);
+ out.writeBoolean(extractDeviceType);
+ out.writeString(regexFile);
+ }
+
+ @Override
+ public String getWriteableName() {
+ return ENTRY.name;
+ }
+
+ @Override
+ protected NodeInfo extends PhysicalPlan> info() {
+ return NodeInfo.create(
+ this,
+ UserAgentExec::new,
+ child(),
+ input,
+ outputFieldNames(),
+ outputFieldAttributes(),
+ extractDeviceType,
+ regexFile
+ );
+ }
+
+ @Override
+ public CompoundOutputEvalExec createNewInstance(
+ Source source,
+ PhysicalPlan child,
+ Expression input,
+ List outputFieldNames,
+ List outputFieldAttributes
+ ) {
+ return new UserAgentExec(source, child, input, outputFieldNames, outputFieldAttributes, this.extractDeviceType, this.regexFile);
+ }
+
+ @Override
+ protected boolean innerEquals(CompoundOutputEvalExec other) {
+ return other instanceof UserAgentExec ua && extractDeviceType == ua.extractDeviceType && Objects.equals(regexFile, ua.regexFile);
+ }
+
+ @Override
+ protected int innerHashCode() {
+ return Objects.hash(extractDeviceType, regexFile);
+ }
+
+ public boolean extractDeviceType() {
+ return extractDeviceType;
+ }
+
+ public String regexFile() {
+ return regexFile;
+ }
+}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java
index 4714a37bc32a1..c143e28dd2313 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/LocalExecutionPlanner.java
@@ -88,6 +88,8 @@
import org.elasticsearch.tasks.CancellableTask;
import org.elasticsearch.transport.RemoteClusterAware;
import org.elasticsearch.transport.Transport;
+import org.elasticsearch.useragent.api.UserAgentParser;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
@@ -122,6 +124,7 @@
import org.elasticsearch.xpack.esql.evaluator.EvalMapper;
import org.elasticsearch.xpack.esql.evaluator.command.CompoundOutputEvaluator;
import org.elasticsearch.xpack.esql.evaluator.command.GrokEvaluatorExtracter;
+import org.elasticsearch.xpack.esql.evaluator.command.UserAgentFunctionBridge;
import org.elasticsearch.xpack.esql.expression.Foldables;
import org.elasticsearch.xpack.esql.expression.Order;
import org.elasticsearch.xpack.esql.inference.InferenceService;
@@ -157,6 +160,7 @@
import org.elasticsearch.xpack.esql.plan.physical.OutputExec;
import org.elasticsearch.xpack.esql.plan.physical.PhysicalPlan;
import org.elasticsearch.xpack.esql.plan.physical.ProjectExec;
+import org.elasticsearch.xpack.esql.plan.physical.RegisteredDomainExec;
import org.elasticsearch.xpack.esql.plan.physical.SampleExec;
import org.elasticsearch.xpack.esql.plan.physical.ShowExec;
import org.elasticsearch.xpack.esql.plan.physical.SparklineGenerateEmptyBucketsExec;
@@ -164,6 +168,8 @@
import org.elasticsearch.xpack.esql.plan.physical.TopNByExec;
import org.elasticsearch.xpack.esql.plan.physical.TopNExec;
import org.elasticsearch.xpack.esql.plan.physical.TsInfoExec;
+import org.elasticsearch.xpack.esql.plan.physical.UriPartsExec;
+import org.elasticsearch.xpack.esql.plan.physical.UserAgentExec;
import org.elasticsearch.xpack.esql.plan.physical.inference.CompletionExec;
import org.elasticsearch.xpack.esql.plan.physical.inference.RerankExec;
import org.elasticsearch.xpack.esql.planner.EsPhysicalOperationProviders.ShardContext;
@@ -208,6 +214,7 @@ public class LocalExecutionPlanner {
private final EnrichLookupService enrichLookupService;
private final LookupFromIndexService lookupFromIndexService;
private final InferenceService inferenceService;
+ private final UserAgentParserRegistry userAgentParserRegistry;
private final PhysicalOperationProviders physicalOperationProviders;
private final OperatorFactoryRegistry operatorFactoryRegistry;
@@ -224,6 +231,7 @@ public LocalExecutionPlanner(
EnrichLookupService enrichLookupService,
LookupFromIndexService lookupFromIndexService,
InferenceService inferenceService,
+ UserAgentParserRegistry userAgentParserRegistry,
PhysicalOperationProviders physicalOperationProviders,
OperatorFactoryRegistry operatorFactoryRegistry
) {
@@ -240,6 +248,7 @@ public LocalExecutionPlanner(
this.enrichLookupService = enrichLookupService;
this.lookupFromIndexService = lookupFromIndexService;
this.inferenceService = inferenceService;
+ this.userAgentParserRegistry = userAgentParserRegistry;
this.physicalOperationProviders = physicalOperationProviders;
this.operatorFactoryRegistry = operatorFactoryRegistry;
}
@@ -332,8 +341,12 @@ private PhysicalOperation plan(PhysicalPlan node, LocalExecutionPlannerContext c
return planCompletion(completion, context);
} else if (node instanceof SampleExec Sample) {
return planSample(Sample, context);
- } else if (node instanceof CompoundOutputEvalExec coe) {
- return planCompoundOutputEval(coe, context);
+ } else if (node instanceof UserAgentExec userAgent) {
+ return planUserAgent(userAgent, context);
+ } else if (node instanceof UriPartsExec uriParts) {
+ return planUriParts(uriParts, context);
+ } else if (node instanceof RegisteredDomainExec rd) {
+ return planRegisteredDomain(rd, context);
} else if (node instanceof MetricsInfoExec metricsInfo) {
return planMetricsInfo(metricsInfo, context);
} else if (node instanceof TsInfoExec tsInfo) {
@@ -393,7 +406,38 @@ private PhysicalOperation planMMR(MMRExec mmr, LocalExecutionPlannerContext cont
return source.with(new MMROperator.Factory(diversifyField, diversifyFieldChannel, limit, queryVector, lambdaValue), source.layout);
}
- private PhysicalOperation planCompoundOutputEval(final CompoundOutputEvalExec coe, LocalExecutionPlannerContext context) {
+ private PhysicalOperation planUserAgent(UserAgentExec exec, LocalExecutionPlannerContext context) {
+ UserAgentParser parser = userAgentParserRegistry.getParser(exec.regexFile());
+ if (parser == null) {
+ throw new EsqlIllegalArgumentException("Unknown user-agent regex file [" + exec.regexFile() + "]");
+ }
+ CompoundOutputEvaluator.OutputFieldsCollectorProvider provider = new CompoundOutputEvaluator.OutputFieldsCollectorProvider() {
+ @Override
+ public CompoundOutputEvaluator.OutputFieldsCollector createOutputFieldsCollector() {
+ return new UserAgentFunctionBridge.UserAgentCollectorImpl(exec.outputFieldNames(), parser, exec.extractDeviceType());
+ }
+
+ @Override
+ public String collectorSimpleName() {
+ return UserAgentFunctionBridge.UserAgentCollectorImpl.class.getSimpleName();
+ }
+ };
+ return planCompoundOutputEval(exec, provider, context);
+ }
+
+ private PhysicalOperation planUriParts(UriPartsExec uriParts, LocalExecutionPlannerContext context) {
+ return planCompoundOutputEval(uriParts, uriParts, context);
+ }
+
+ private PhysicalOperation planRegisteredDomain(RegisteredDomainExec rd, LocalExecutionPlannerContext context) {
+ return planCompoundOutputEval(rd, rd, context);
+ }
+
+ private PhysicalOperation planCompoundOutputEval(
+ final CompoundOutputEvalExec coe,
+ CompoundOutputEvaluator.OutputFieldsCollectorProvider provider,
+ LocalExecutionPlannerContext context
+ ) {
PhysicalOperation source = plan(coe.child(), context);
Layout.Builder layoutBuilder = source.layout.builder();
layoutBuilder.append(coe.outputFieldAttributes());
@@ -409,7 +453,7 @@ private PhysicalOperation planCompoundOutputEval(final CompoundOutputEvalExec co
new ColumnExtractOperator.Factory(
types,
EvalMapper.toEvaluator(context.foldCtx(), coe.input(), layout),
- new CompoundOutputEvaluator.Factory(coe.input().dataType(), coe.source(), coe)
+ new CompoundOutputEvaluator.Factory(coe.input().dataType(), coe.source(), provider)
),
layout
);
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/MapperUtils.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/MapperUtils.java
index 587f5ec0c10be..4ea81f66d72e9 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/MapperUtils.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/mapper/MapperUtils.java
@@ -31,6 +31,7 @@
import org.elasticsearch.xpack.esql.plan.logical.TimeSeriesAggregate;
import org.elasticsearch.xpack.esql.plan.logical.UnaryPlan;
import org.elasticsearch.xpack.esql.plan.logical.UriParts;
+import org.elasticsearch.xpack.esql.plan.logical.UserAgent;
import org.elasticsearch.xpack.esql.plan.logical.fuse.FuseScoreEval;
import org.elasticsearch.xpack.esql.plan.logical.inference.Completion;
import org.elasticsearch.xpack.esql.plan.logical.inference.Rerank;
@@ -56,6 +57,7 @@
import org.elasticsearch.xpack.esql.plan.physical.SparklineGenerateEmptyBucketsExec;
import org.elasticsearch.xpack.esql.plan.physical.TimeSeriesAggregateExec;
import org.elasticsearch.xpack.esql.plan.physical.UriPartsExec;
+import org.elasticsearch.xpack.esql.plan.physical.UserAgentExec;
import org.elasticsearch.xpack.esql.plan.physical.inference.CompletionExec;
import org.elasticsearch.xpack.esql.plan.physical.inference.RerankExec;
import org.elasticsearch.xpack.esql.planner.AbstractPhysicalOperationProviders;
@@ -198,6 +200,18 @@ static PhysicalPlan mapUnary(UnaryPlan p, PhysicalPlan child) {
return new RegisteredDomainExec(rd.source(), child, rd.getInput(), rd.outputFieldNames(), rd.generatedAttributes());
}
+ if (p instanceof UserAgent ua) {
+ return new UserAgentExec(
+ ua.source(),
+ child,
+ ua.getInput(),
+ ua.outputFieldNames(),
+ ua.generatedAttributes(),
+ ua.extractDeviceType(),
+ ua.regexFile()
+ );
+ }
+
return unsupported(p);
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
index 4b2613aea7193..a81675569fa7a 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/ComputeService.java
@@ -50,6 +50,7 @@
import org.elasticsearch.transport.RemoteClusterAware;
import org.elasticsearch.transport.TransportException;
import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xpack.esql.action.EsqlExecutionInfo;
import org.elasticsearch.xpack.esql.action.EsqlQueryAction;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
@@ -163,6 +164,7 @@ public class ComputeService {
private final EnrichLookupService enrichLookupService;
private final LookupFromIndexService lookupFromIndexService;
private final InferenceService inferenceService;
+ private final UserAgentParserRegistry userAgentParserRegistry;
private final ClusterService clusterService;
private final ProjectResolver projectResolver;
private final AtomicLong childSessionIdGenerator = new AtomicLong();
@@ -196,6 +198,7 @@ public ComputeService(
this.enrichLookupService = enrichLookupService;
this.lookupFromIndexService = lookupFromIndexService;
this.inferenceService = transportActionServices.inferenceService();
+ this.userAgentParserRegistry = transportActionServices.userAgentParserRegistry();
this.clusterService = transportActionServices.clusterService();
this.projectResolver = transportActionServices.projectResolver();
this.dataNodeComputeHandler = new DataNodeComputeHandler(
@@ -1073,6 +1076,7 @@ void runCompute(
enrichLookupService,
lookupFromIndexService,
inferenceService,
+ userAgentParserRegistry,
physicalOperationProviders,
operatorFactoryRegistry
);
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportActionServices.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportActionServices.java
index d93b27473ec38..3811d780a2a6c 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportActionServices.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportActionServices.java
@@ -16,6 +16,7 @@
import org.elasticsearch.search.crossproject.CrossProjectModeDecider;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.usage.UsageService;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xpack.esql.inference.InferenceService;
import org.elasticsearch.xpack.esql.planner.PlannerSettings;
@@ -28,6 +29,7 @@ public record TransportActionServices(
IndexNameExpressionResolver indexNameExpressionResolver,
UsageService usageService,
InferenceService inferenceService,
+ UserAgentParserRegistry userAgentParserRegistry,
BlockFactoryProvider blockFactoryProvider,
PlannerSettings.Holder plannerSettings,
CrossProjectModeDecider crossProjectModeDecider
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java
index 8e5d085fbe8be..7354d9454ffea 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/TransportEsqlQueryAction.java
@@ -46,6 +46,7 @@
import org.elasticsearch.transport.RemoteClusterService;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.usage.UsageService;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xpack.core.XPackPlugin;
import org.elasticsearch.xpack.core.async.AsyncExecutionId;
import org.elasticsearch.xpack.esql.VerificationException;
@@ -134,6 +135,7 @@ public TransportEsqlQueryAction(
NamedWriteableRegistry registry,
IndexNameExpressionResolver indexNameExpressionResolver,
UsageService usageService,
+ UserAgentParserRegistry userAgentParserRegistry,
ActionLoggingFieldsProvider fieldProvider,
ActivityLogWriterProvider logWriterProvider,
CrossProjectModeDecider crossProjectModeDecider
@@ -205,6 +207,7 @@ public TransportEsqlQueryAction(
indexNameExpressionResolver,
usageService,
new InferenceService(client, clusterService),
+ userAgentParserRegistry,
blockFactoryProvider,
new PlannerSettings.Holder(clusterService),
crossProjectModeDecider
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/telemetry/FeatureMetric.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/telemetry/FeatureMetric.java
index 9f8e1fdda20de..5013163447ad8 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/telemetry/FeatureMetric.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/telemetry/FeatureMetric.java
@@ -41,6 +41,7 @@
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedExternalRelation;
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation;
import org.elasticsearch.xpack.esql.plan.logical.UriParts;
+import org.elasticsearch.xpack.esql.plan.logical.UserAgent;
import org.elasticsearch.xpack.esql.plan.logical.fuse.Fuse;
import org.elasticsearch.xpack.esql.plan.logical.fuse.FuseScoreEval;
import org.elasticsearch.xpack.esql.plan.logical.inference.Completion;
@@ -110,7 +111,8 @@ public enum FeatureMetric {
URI_PARTS(UriParts.class::isInstance),
METRICS_INFO(MetricsInfo.class::isInstance),
REGISTERED_DOMAIN(RegisteredDomain.class::isInstance),
- TS_INFO(TsInfo.class::isInstance);
+ TS_INFO(TsInfo.class::isInstance),
+ USER_AGENT(UserAgent.class::isInstance);
/**
* List here plans we want to exclude from telemetry
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
index 5ab90f89bbc85..d5d13100d37eb 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
@@ -42,6 +42,8 @@
import org.elasticsearch.core.Releasables;
import org.elasticsearch.core.TimeValue;
import org.elasticsearch.core.Tuple;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.TestEnvironment;
import org.elasticsearch.index.IndexMode;
import org.elasticsearch.logging.LogManager;
import org.elasticsearch.logging.Logger;
@@ -52,6 +54,8 @@
import org.elasticsearch.threadpool.TestThreadPool;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.transport.RemoteClusterService;
+import org.elasticsearch.useragent.UserAgentPlugin;
+import org.elasticsearch.useragent.api.UserAgentParserRegistry;
import org.elasticsearch.xcontent.XContentParserConfiguration;
import org.elasticsearch.xcontent.json.JsonXContent;
import org.elasticsearch.xpack.core.enrich.EnrichPolicy;
@@ -143,6 +147,7 @@
import org.junit.Before;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
@@ -962,6 +967,13 @@ void executeSubPlan(
ExchangeSourceHandler exchangeSource = new ExchangeSourceHandler(between(1, 64), executor);
ExchangeSinkHandler exchangeSink = new ExchangeSinkHandler(blockFactory, between(1, 64), threadPool::relativeTimeInMillis);
+ UserAgentParserRegistry userAgentRegistry;
+ try {
+ userAgentRegistry = createUserAgentRegistry();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to create UserAgentParserRegistry", e);
+ }
+
LocalExecutionPlanner executionPlanner = new LocalExecutionPlanner(
getTestName(),
"",
@@ -975,6 +987,7 @@ void executeSubPlan(
mock(EnrichLookupService.class),
mock(LookupFromIndexService.class),
mock(InferenceService.class),
+ userAgentRegistry,
physicalOperationProviders,
operatorFactoryRegistry
);
@@ -1087,4 +1100,22 @@ private static List coordinatorSplits(OperatorFactoryRegistry ope
}
return coordinatorSplits;
}
+
+ /**
+ * Creates a {@link UserAgentParserRegistry} with a config directory
+ * containing the custom-regexes.yml test resource, so csv-spec tests can exercise the {@code regex_file} option.
+ */
+ private static UserAgentParserRegistry createUserAgentRegistry() throws IOException {
+ Path homeDir = createTempDir();
+ Path userAgentConfigDir = homeDir.resolve("config").resolve("user-agent");
+ Files.createDirectories(userAgentConfigDir);
+ try (InputStream is = CsvTests.class.getResourceAsStream("/custom-regexes.yml")) {
+ assert is != null : "custom-regexes.yml not found on classpath";
+ Files.copy(is, userAgentConfigDir.resolve("custom-regexes.yml"));
+ }
+ return UserAgentPlugin.createRegistry(
+ TestEnvironment.newEnvironment(Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), homeDir).build()),
+ Settings.EMPTY
+ );
+ }
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java
index 14bbeeafb0f33..10a8aa06d6e20 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java
@@ -111,6 +111,7 @@
import org.elasticsearch.xpack.esql.plan.logical.UnionAll;
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation;
import org.elasticsearch.xpack.esql.plan.logical.UriParts;
+import org.elasticsearch.xpack.esql.plan.logical.UserAgent;
import org.elasticsearch.xpack.esql.plan.logical.ViewUnionAll;
import org.elasticsearch.xpack.esql.plan.logical.fuse.FuseScoreEval;
import org.elasticsearch.xpack.esql.plan.logical.inference.Completion;
@@ -6376,6 +6377,32 @@ public void testRegisteredDomain() {
);
}
+ public void testUserAgent() {
+ assumeTrue("requires user_agent command capability", EsqlCapabilities.Cap.USER_AGENT_COMMAND.isEnabled());
+ LogicalPlan plan = basic().query("ROW ua=\"Mozilla/5.0\" | user_agent p = ua WITH { \"extract_device_type\": true }");
+
+ Limit limit = as(plan, Limit.class);
+ UserAgent userAgent = as(limit.child(), UserAgent.class);
+
+ final List attributes = userAgent.generatedAttributes();
+
+ assertThrows(UnsupportedOperationException.class, () -> attributes.add(new UnresolvedAttribute(EMPTY, "test")));
+
+ assertContainsAttribute(attributes, "p.name", DataType.KEYWORD);
+ assertContainsAttribute(attributes, "p.version", DataType.KEYWORD);
+ assertContainsAttribute(attributes, "p.os.name", DataType.KEYWORD);
+ assertContainsAttribute(attributes, "p.os.version", DataType.KEYWORD);
+ assertContainsAttribute(attributes, "p.os.full", DataType.KEYWORD);
+ assertContainsAttribute(attributes, "p.device.name", DataType.KEYWORD);
+ assertContainsAttribute(attributes, "p.device.type", DataType.KEYWORD);
+ assertEquals(7, attributes.size());
+
+ basic().error(
+ "ROW ua=123 | user_agent p = ua WITH { \"extract_device_type\": true }",
+ containsString("Input for USER_AGENT must be of type [string] but is [integer]")
+ );
+ }
+
private void assertContainsAttribute(List attributes, String expectedName, DataType expectedType) {
Attribute attr = attributes.stream().filter(a -> a.name().equals(expectedName)).findFirst().orElse(null);
assertNotNull("Expected attribute " + expectedName + " not found", attr);
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/evaluator/command/UserAgentFunctionBridgeTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/evaluator/command/UserAgentFunctionBridgeTests.java
new file mode 100644
index 0000000000000..ea6f3a0e39dd0
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/evaluator/command/UserAgentFunctionBridgeTests.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.evaluator.command;
+
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.WarningSourceLocation;
+import org.elasticsearch.compute.operator.Warnings;
+import org.elasticsearch.useragent.api.Details;
+import org.elasticsearch.useragent.api.UserAgentParser;
+import org.elasticsearch.useragent.api.VersionedName;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.DEVICE_NAME;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.DEVICE_TYPE;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.NAME;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.OS_FULL;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.OS_NAME;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.OS_VERSION;
+import static org.elasticsearch.useragent.api.UserAgentParsedInfo.VERSION;
+
+public class UserAgentFunctionBridgeTests extends AbstractCompoundOutputEvaluatorTests {
+
+ private static final String CHROME_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 "
+ + "(KHTML, like Gecko) Chrome/33.0.1750.149 Safari/537.36";
+ private static final String FIREFOX_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0";
+ private static final String CURL_UA = "curl/7.68.0";
+
+ /**
+ * A test parser that returns known Details for known user-agent strings.
+ */
+ private static final UserAgentParser TEST_PARSER = (agentString, extractDeviceType) -> {
+ if (CHROME_UA.equals(agentString)) {
+ return new Details(
+ "Chrome",
+ "33.0.1750",
+ new VersionedName("Mac OS X", "10.9.2"),
+ "Mac OS X 10.9.2",
+ new VersionedName("Mac", null),
+ extractDeviceType ? "Desktop" : null
+ );
+ } else if (FIREFOX_UA.equals(agentString)) {
+ return new Details(
+ "Firefox",
+ "115.0",
+ new VersionedName("Linux", null),
+ "Linux",
+ new VersionedName("Other", null),
+ extractDeviceType ? "Desktop" : null
+ );
+ } else if (CURL_UA.equals(agentString)) {
+ return new Details("curl", "7.68.0", null, null, null, null);
+ }
+ return new Details(null, null, null, null, null, null);
+ };
+
+ private boolean extractDeviceType = false;
+
+ private final Warnings WARNINGS = Warnings.createWarnings(DriverContext.WarningsMode.COLLECT, new WarningSourceLocation() {
+ @Override
+ public int lineNumber() {
+ return 1;
+ }
+
+ @Override
+ public int columnNumber() {
+ return 2;
+ }
+
+ @Override
+ public String viewName() {
+ return null;
+ }
+
+ @Override
+ public String text() {
+ return "invalid_input";
+ }
+ });
+
+ @Override
+ protected CompoundOutputEvaluator.OutputFieldsCollector createOutputFieldsCollector(List requestedFields) {
+ return new UserAgentFunctionBridge.UserAgentCollectorImpl(requestedFields, TEST_PARSER, extractDeviceType);
+ }
+
+ @Override
+ protected String collectorSimpleName() {
+ return UserAgentFunctionBridge.UserAgentCollectorImpl.class.getSimpleName();
+ }
+
+ @Override
+ protected Map> getSupportedOutputFieldMappings() {
+ return UserAgentFunctionBridge.getAllOutputFields();
+ }
+
+ public void testFullOutput() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, VERSION, OS_NAME, OS_VERSION, OS_FULL, DEVICE_NAME);
+ List input = List.of(CHROME_UA);
+ List> expected = List.of("Chrome", "33.0.1750", "Mac OS X", "10.9.2", "Mac OS X 10.9.2", "Mac");
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testFullOutputWithDeviceType() {
+ extractDeviceType = true;
+ List requestedFields = List.of(NAME, VERSION, OS_NAME, OS_VERSION, OS_FULL, DEVICE_NAME, DEVICE_TYPE);
+ List input = List.of(CHROME_UA);
+ List> expected = List.of("Chrome", "33.0.1750", "Mac OS X", "10.9.2", "Mac OS X 10.9.2", "Mac", "Desktop");
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testDeviceTypeNotExtracted() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, DEVICE_TYPE);
+ List input = List.of(CHROME_UA);
+ List> expected = Arrays.asList("Chrome", null);
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testPartialFieldsRequested() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, VERSION);
+ List input = List.of(CHROME_UA);
+ List> expected = List.of("Chrome", "33.0.1750");
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testNoOsMatch() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, OS_NAME, OS_VERSION, OS_FULL);
+ List input = List.of(CURL_UA);
+ List> expected = Arrays.asList("curl", null, null, null);
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testNoDeviceMatch() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, DEVICE_NAME);
+ List input = List.of(CURL_UA);
+ List> expected = Arrays.asList("curl", null);
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testUnknownInput() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, VERSION, OS_NAME);
+ List input = List.of("completely unknown agent string");
+ List> expected = Arrays.asList(null, null, null);
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testUnknownInputWithDeviceType() {
+ extractDeviceType = true;
+ List requestedFields = List.of(NAME, VERSION, OS_NAME, OS_VERSION, OS_FULL, DEVICE_NAME, DEVICE_TYPE);
+ List input = List.of("completely unknown agent string");
+ List> expected = Arrays.asList(null, null, null, null, null, null, null);
+ evaluateAndCompare(input, requestedFields, expected);
+ }
+
+ public void testMultiValue() {
+ extractDeviceType = false;
+ List requestedFields = List.of(NAME, VERSION, OS_NAME, OS_VERSION, OS_FULL, DEVICE_NAME);
+ List input = List.of(CHROME_UA, FIREFOX_UA);
+ List