___TERMS_OF_SERVICE___
By creating or modifying this file you agree to Google Tag Manager's Community
Template Gallery Developer Terms of Service available at
https://developers.google.com/tag-manager/gallery-tos (or such other URL as
Google may provide), as modified from time to time.
___INFO___
{
"type": "MACRO",
"id": "cvt_temp_public_id",
"version": 1,
"securityGroups": [],
"displayName": "useragentparser.xyz User-Agent Parser",
"categories": [
"UTILITY",
"ANALYTICS"
],
"brand": {
"id": "brand_useragentparser_eu",
"displayName": "useragentparser.xyz",
"thumbnail": "data:image/png;base64,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"
},
"description": "Parses the event's user-agent into browser, OS, device, CPU and bot/AI-crawler fields via the useragentparser.xyz API (user agents are not stored or logged — privacy policy: https://useragentparser.xyz/privacy). Returns the full object or one field, e.g. device category for BigQuery. Results are cached per instance to save credits. Requires an API key from useragentparser.xyz (1,000 free credits).",
"containerContexts": [
"SERVER"
]
}
___TEMPLATE_PARAMETERS___
[
{
"type": "TEXT",
"name": "apiKey",
"displayName": "API key",
"simpleValueType": true,
"help": "Your key from useragentparser.xyz/account (starts with uap_). Store it in a constant variable and reference it here.",
"valueValidators": [
{
"type": "NON_EMPTY"
},
{
"type": "REGEX",
"args": [
"^(uap_[A-Za-z0-9_-]{43}|\\{\\{.+\\}\\})$"
]
}
]
},
{
"type": "SELECT",
"name": "output",
"displayName": "Return",
"macrosInSelect": false,
"selectItems": [
{
"value": "full",
"displayValue": "Full result object"
},
{
"value": "deviceCategory",
"displayValue": "Device category (desktop / mobile / tablet / bot / …)"
},
{
"value": "browser.name",
"displayValue": "Browser name"
},
{
"value": "browser.version",
"displayValue": "Browser version"
},
{
"value": "browser.major",
"displayValue": "Browser major version"
},
{
"value": "browser.type",
"displayValue": "Browser type (crawler, cli, inapp, …)"
},
{
"value": "engine.name",
"displayValue": "Engine name"
},
{
"value": "engine.version",
"displayValue": "Engine version"
},
{
"value": "os.name",
"displayValue": "OS name"
},
{
"value": "os.version",
"displayValue": "OS version"
},
{
"value": "device.type",
"displayValue": "Device type (raw; empty for desktop)"
},
{
"value": "device.vendor",
"displayValue": "Device vendor"
},
{
"value": "device.model",
"displayValue": "Device model"
},
{
"value": "cpu.architecture",
"displayValue": "CPU architecture"
},
{
"value": "bot.isBot",
"displayValue": "Is bot (true/false)"
},
{
"value": "bot.isAIBot",
"displayValue": "Is AI crawler (true/false)"
}
],
"simpleValueType": true,
"defaultValue": "full",
"help": "Each variable that misses the cache makes one API call (1 credit). If you need several fields for one event, prefer one 'Full result object' variable."
},
{
"type": "RADIO",
"name": "uaSource",
"displayName": "User-agent source",
"defaultValue": "eventData",
"simpleValueType": true,
"radioItems": [
{
"value": "eventData",
"displayValue": "Event data key",
"subParams": [
{
"type": "TEXT",
"name": "eventDataKey",
"displayName": "Key",
"defaultValue": "user_agent",
"simpleValueType": true,
"help": "The GA4 client puts the browser's user-agent in user_agent."
}
]
},
{
"value": "header",
"displayValue": "Incoming request User-Agent header"
},
{
"value": "custom",
"displayValue": "Custom value",
"subParams": [
{
"type": "TEXT",
"name": "customUserAgent",
"displayName": "User-agent",
"simpleValueType": true
}
]
}
]
},
{
"type": "GROUP",
"name": "advanced",
"displayName": "Advanced",
"groupStyle": "ZIPPY_CLOSED",
"subParams": [
{
"type": "CHECKBOX",
"name": "cache",
"checkboxText": "Cache results per user-agent (saves credits)",
"simpleValueType": true,
"defaultValue": true
},
{
"type": "TEXT",
"name": "cacheTtlHours",
"displayName": "Cache lifetime (hours)",
"simpleValueType": true,
"defaultValue": "24",
"valueValidators": [
{
"type": "POSITIVE_NUMBER"
}
],
"enablingConditions": [
{
"paramName": "cache",
"paramValue": true,
"type": "EQUALS"
}
]
},
{
"type": "TEXT",
"name": "timeoutMs",
"displayName": "Request timeout (ms)",
"simpleValueType": true,
"defaultValue": "2000",
"valueValidators": [
{
"type": "POSITIVE_NUMBER"
}
],
"help": "1–4500 ms. sGTM gives up on a variable after 5 s, so larger values are capped."
},
{
"type": "CHECKBOX",
"name": "debug",
"checkboxText": "Log errors to the console (preview/debug)",
"simpleValueType": true,
"defaultValue": false
}
]
}
]
___SANDBOXED_JS_FOR_SERVER___
// UserAgent Parser — server-side GTM variable (sandboxed JS).
//
// Parses the request's user-agent via https://useragentparser.xyz and returns
// either the full result object or one field (e.g. os.name, deviceCategory).
// Typical use: enrich events streamed from sGTM to BigQuery / a warehouse.
//
// Cost control: results are cached per sGTM instance (templateDataStorage),
// keyed by a SHA-256 of the user-agent, so repeated visitors with the same
// browser cost one credit per instance per TTL — not one per event.
// Privacy: only the user-agent string is sent (POST body); the service does
// not store or log it.
const sendHttpRequest = require('sendHttpRequest');
const getEventData = require('getEventData');
const getRequestHeader = require('getRequestHeader');
const templateDataStorage = require('templateDataStorage');
const sha256Sync = require('sha256Sync');
const getTimestampMillis = require('getTimestampMillis');
const getType = require('getType');
const makeString = require('makeString');
const makeInteger = require('makeInteger');
const logToConsole = require('logToConsole');
const JSON = require('JSON');
const API_URL = 'https://useragentparser.xyz/v1/parse';
const MAX_UA_LENGTH = 2048; // the API rejects longer strings with 400 — don't spend a call on them
const CACHE_PREFIX = 'uap:v1:';
/** The user-agent to parse, per the chosen source; undefined when unavailable. */
function resolveUserAgent() {
let ua;
if (data.uaSource === 'header') ua = getRequestHeader('user-agent');
else if (data.uaSource === 'custom') ua = data.customUserAgent;
else ua = getEventData(data.eventDataKey || 'user_agent');
if (getType(ua) !== 'string') return undefined;
if (ua.trim() === '' || ua.length > MAX_UA_LENGTH) return undefined;
return ua;
}
/**
* Coarse category that reporting tables usually want: bot | mobile | tablet |
* smarttv | console | wearable | xr | embedded | desktop. The API returns
* device.type null for desktop browsers, so null (and not a bot) → desktop.
*/
function deviceCategory(result) {
if (result.bot && result.bot.isBot) return 'bot';
return (result.device && result.device.type) || 'desktop';
}
/** Return the configured output from a parse result. */
function pick(result) {
const output = data.output || 'full';
if (output === 'full') return result;
if (output === 'deviceCategory') return deviceCategory(result);
const parts = output.split('.');
let value = result;
for (let i = 0; i < parts.length; i++) {
if (value === undefined || value === null) return undefined;
value = value[parts[i]];
}
// The API uses null for "unknown"; GTM fields treat undefined as "unset".
return value === null ? undefined : value;
}
function clampTimeout(ms) {
if (!ms || ms < 1) return 2000;
return ms > 4500 ? 4500 : ms;
}
function debug(message) {
if (data.debug) logToConsole('[UserAgent Parser] ' + message);
}
const userAgent = resolveUserAgent();
if (!userAgent) {
debug('no usable user-agent');
return undefined;
}
const now = getTimestampMillis();
const ttlMs = (makeInteger(data.cacheTtlHours) || 24) * 60 * 60 * 1000;
const cacheKey = CACHE_PREFIX + sha256Sync(userAgent, { outputEncoding: 'hex' });
const cached = data.cache === false ? undefined : templateDataStorage.getItemCopy(cacheKey);
if (cached && cached.expires > now) return pick(cached.result);
return sendHttpRequest(
API_URL,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Api-Key': makeString(data.apiKey) },
// sGTM rejects a variable's promise after 5 s anyway; stay safely below.
timeout: clampTimeout(makeInteger(data.timeoutMs)),
},
JSON.stringify({ userAgent: userAgent })
).then(
(response) => {
if (response.statusCode !== 200) {
// 401 bad key, 402 out of credits, 5xx: leave the field empty, never fail the event.
debug('API returned HTTP ' + response.statusCode + ': ' + response.body);
return undefined;
}
const result = JSON.parse(response.body);
if (!result) return undefined;
if (data.cache !== false) templateDataStorage.setItemCopy(cacheKey, { result: result, expires: now + ttlMs });
return pick(result);
},
() => {
debug('request failed or timed out');
return undefined;
}
);
___SERVER_PERMISSIONS___
[
{
"instance": {
"key": {
"publicId": "read_event_data",
"versionId": "1"
},
"param": [
{
"key": "eventDataAccess",
"value": {
"type": 1,
"string": "any"
}
}
]
},
"clientAnnotations": {
"isEditedByUser": true
},
"isRequired": true
},
{
"instance": {
"key": {
"publicId": "read_request",
"versionId": "1"
},
"param": [
{
"key": "headerWhitelist",
"value": {
"type": 2,
"listItem": [
{
"type": 3,
"mapKey": [
{
"type": 1,
"string": "headerName"
}
],
"mapValue": [
{
"type": 1,
"string": "user-agent"
}
]
}
]
}
},
{
"key": "headersAllowed",
"value": {
"type": 8,
"boolean": true
}
},
{
"key": "requestAccess",
"value": {
"type": 1,
"string": "specific"
}
},
{
"key": "headerAccess",
"value": {
"type": 1,
"string": "specific"
}
},
{
"key": "queryParameterAccess",
"value": {
"type": 1,
"string": "any"
}
}
]
},
"clientAnnotations": {
"isEditedByUser": true
},
"isRequired": true
},
{
"instance": {
"key": {
"publicId": "send_http",
"versionId": "1"
},
"param": [
{
"key": "allowedUrls",
"value": {
"type": 1,
"string": "specific"
}
},
{
"key": "urls",
"value": {
"type": 2,
"listItem": [
{
"type": 1,
"string": "https://useragentparser.xyz/v1/*"
}
]
}
}
]
},
"clientAnnotations": {
"isEditedByUser": true
},
"isRequired": true
},
{
"instance": {
"key": {
"publicId": "access_template_storage",
"versionId": "1"
},
"param": []
},
"clientAnnotations": {
"isEditedByUser": true
},
"isRequired": true
},
{
"instance": {
"key": {
"publicId": "logging",
"versionId": "1"
},
"param": [
{
"key": "environments",
"value": {
"type": 1,
"string": "debug"
}
}
]
},
"clientAnnotations": {
"isEditedByUser": true
},
"isRequired": true
}
]
___TESTS___
scenarios:
- name: Returns the requested field from the API response
code: |-
mockSuccess();
runCode(mockData).then((value) => {
assertThat(value).isEqualTo('iOS');
assertApi('sendHttpRequest').wasCalled();
});
- name: Second call for the same user-agent is served from the cache
code: |-
// assertApi has no call counter in GTM tests, so count requests ourselves.
let requests = 0;
mock('sendHttpRequest', () => {
requests++;
return Promise.create((resolve) => resolve({ statusCode: 200, headers: {}, body: JSON.stringify(RESULT) }));
});
runCode(mockData).then(() => runCode(mockData)).then((value) => {
assertThat(value).isEqualTo('iOS');
assertThat(requests).isEqualTo(1);
});
- name: deviceCategory maps a desktop (null device type) to desktop
code: |-
mockSuccess({ device: { type: null, vendor: null, model: null }, bot: { isBot: false, isAIBot: false } });
mockData.output = 'deviceCategory';
runCode(mockData).then((value) => assertThat(value).isEqualTo('desktop'));
- name: API error leaves the variable undefined
code: |-
mock('sendHttpRequest', () => Promise.create((resolve) => resolve({ statusCode: 402, body: '{}' })));
runCode(mockData).then((value) => assertThat(value).isUndefined());
- name: Missing user-agent makes no request
code: |-
mock('getEventData', () => undefined);
assertThat(runCode(mockData)).isUndefined();
assertApi('sendHttpRequest').wasNotCalled();
setup: |-
const Promise = require('Promise');
const JSON = require('JSON');
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
const RESULT = {
browser: { name: 'Mobile Safari', version: '17.5', major: '17', type: null },
engine: { name: 'WebKit', version: '605.1.15' },
os: { name: 'iOS', version: '17.5' },
device: { type: 'mobile', vendor: 'Apple', model: 'iPhone' },
cpu: { architecture: null },
bot: { isBot: false, isAIBot: false },
};
const mockData = { apiKey: 'uap_0123456789012345678901234567890123456789abc', output: 'os.name', uaSource: 'eventData', eventDataKey: 'user_agent', cache: true, cacheTtlHours: '24', timeoutMs: '2000' };
mock('getEventData', (key) => (key === 'user_agent' ? UA : undefined));
// Fresh in-memory storage per scenario, so cache assertions are deterministic.
// Object-valued sandbox APIs must be mocked with mockObject (mock() only replaces functions).
const storage = {};
mockObject('templateDataStorage', {
getItemCopy: (key) => storage[key],
setItemCopy: (key, value) => { storage[key] = value; },
removeItem: (key) => { storage[key] = undefined; },
});
function mockSuccess(result) {
mock('sendHttpRequest', () => Promise.create((resolve) => resolve({ statusCode: 200, headers: {}, body: JSON.stringify(result || RESULT) })));
}
___NOTES___
Built from https://useragentparser.xyz — docs: https://useragentparser.xyz/docs#sgtm