___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