Skip to content

Instantly share code, notes, and snippets.

@thyngster
Last active April 2, 2024 16:03
Show Gist options
  • Save thyngster/61dfb241564a00f5bde30243d0a3aa1d to your computer and use it in GitHub Desktop.
Save thyngster/61dfb241564a00f5bde30243d0a3aa1d to your computer and use it in GitHub Desktop.
/* 2019-02-25
David Vallejo ( @ thyng )
New Google Analytics Linker Parameter Algo
version 0.1 Needs commenting
!!! Current version not properly generating the browser fingerprint . As usual the
linker value should be only valid for the next 2 minutes since it was generated.
Will review it
Note that GA library currently provides a public function to get the linker param
google_tag_data.glBridge.generate()
Use:
google_tag_data.glBridge.generate({
_ga: '121321321321.2315648466', // Google Analytics GA ID
_gac: undefined, // Google Remarketing ,not needed at all
_gid: '121321321321.2315648466' // Google ID
});
This glBridge ( Google Linker Bridge ? )tools provides same functionality as GA Linker / Decorate plugin, but
more likely to be used for All Google Suite products instead of just for Google Analytics
{
auto: ƒ,
decorate: ƒ,
generate: ƒ,
get: ƒ
}
*/
var generateLinkerParam = function(a) {
// Function to properly grab ID's from Cookies
var getCookiebyName = function(name) {
var pair = document.cookie.match(new RegExp(name + '=([^;]+)'));
return !!pair ? pair[1].match(/GA1\.[0-9]\.(.+)/)[1] : undefined;
};
// These are the 3 values used by the new linker
var cookies = {
_ga: getCookiebyName("_ga"),
// Google Analytics GA ID
_gac: undefined,
// Google Remarketing
_gid: getCookiebyName("_gid")// Google ID
};
// Calculate current browser_fingerprint based on UA, time, timezone and language
//
var browser_fingerprint = (function(a, b) {
var F = function(a) {
// Didnt check what this does, the algo just needs F to be defined. commenting out
Ne.set(a)
};
a = [window.navigator.userAgent, (new Date).getTimezoneOffset(), window.navigator.userLanguage || window.navigator.language, Math.floor((new Date).getTime() / 60 / 1E3) - (void 0 === b ? 0 : b), a].join("*");
if (!(b = F)) {
b = Array(256);
for (var c = 0; 256 > c; c++) {
for (var d = c, e = 0; 8 > e; e++)
d = d & 1 ? d >>> 1 ^ 3988292384 : d >>> 1;
b[c] = d
}
}
F = b;
b = 4294967295;
for (c = 0; c < a.length; c++)
b = b >>> 8 ^ F[(b ^ a.charCodeAt(c)) & 255];
return ((b ^ -1) >>> 0).toString(36);
}
)();
// Function to hash the cookie value
// The following functions takes a string and returns a hash value.
var hash_cookie_value = function(val) {
var A, C, D = function(a) {
A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
C = {
"0": 52,
"1": 53,
"2": 54,
"3": 55,
"4": 56,
"5": 57,
"6": 58,
"7": 59,
"8": 60,
"9": 61,
"A": 0,
"B": 1,
"C": 2,
"D": 3,
"E": 4,
"F": 5,
"G": 6,
"H": 7,
"I": 8,
"J": 9,
"K": 10,
"L": 11,
"M": 12,
"N": 13,
"O": 14,
"P": 15,
"Q": 16,
"R": 17,
"S": 18,
"T": 19,
"U": 20,
"V": 21,
"W": 22,
"X": 23,
"Y": 24,
"Z": 25,
"a": 26,
"b": 27,
"c": 28,
"d": 29,
"e": 30,
"f": 31,
"g": 32,
"h": 33,
"i": 34,
"j": 35,
"k": 36,
"l": 37,
"m": 38,
"n": 39,
"o": 40,
"p": 41,
"q": 42,
"r": 43,
"s": 44,
"t": 45,
"u": 46,
"v": 47,
"w": 48,
"x": 49,
"y": 50,
"z": 51,
"-": 62,
"_": 63,
".": 64
};
for (var b = [], c = 0; c < a.length; c += 3) {
var d = c + 1 < a.length
, e = c + 2 < a.length
, g = a.charCodeAt(c)
, f = d ? a.charCodeAt(c + 1) : 0
, h = e ? a.charCodeAt(c + 2) : 0
, p = g >> 2;
g = (g & 3) << 4 | f >> 4;
f = (f & 15) << 2 | h >> 6;
h &= 63;
e || (h = 64,
d || (f = 64));
b.push(A[p], A[g], A[f], A[h])
}
return b.join("")
};
return D(String(val));
};
// Now we have all the data Let's build the linker String! =)
// First value is a fixed "1" value, the current GA code does the same. May change in a future
return ["1", browser_fingerprint, "_ga", hash_cookie_value(cookies._ga), "_gid", hash_cookie_value(cookies._gid)].join('*');
};
var decrypt_cookies_ids = function(a, b) {
var P = function(a) {
if (encodeURIComponent instanceof Function) return encodeURIComponent(a);
F(28);
return a
};
var m = function(a, b) {
for (var c in b) b.hasOwnProperty(c) && (a[c] = b[c])
};
var H = function() {
var a = {};
var b = window.google_tag_data;
window.google_tag_data = void 0 === b ? a : b;
a = window.google_tag_data;
b = a.gl;
b && b.decorators || (b = {
decorators: []
}, a.gl = b);
return b
};
var c = P(!!b);
b = H();
b.data || (b.data = {
query: {},
fragment: {}
}, c(b.data));
c = {};
if (b = b.data) m(c, b.query), a && m(c, b.fragment);
return c
}
// Example: decrypt_cookies_ids("1*qutsvh*_ga*MTczMjg1NDM3Ni4xNTUwMTAyNDc3*_gid*MTQwMzczNTA1OC4xNTUxMDIzOTA2");
@mr0atarcher
Copy link

mr0atarcher commented Nov 3, 2023

@thyngster not sure if you actively working on this but your article was a huge help to get me in the right direction. I needed to implement cross-domain tracking on a form where we hijack the submission to perform additional steps before redirecting the user, and hence the traditional methods wouldn't work. The "manual" method google describes wasn't working either.

The JS was a little cryptic, but I ran with it and converted it into the below typescript. I noticed there was a couple of things that seemed like they weren't actually doing anything, and removing that functionality didn't hinder this from working.

 * **Google Linker Bridge Implementation**
 *
 * - Replicates the Google Linker Bridge functionality found in `analytics.js`.
 * - Address the gap where `gtag.js` does not expose this bridge function (as of the current version).
 * - Inspired and guided by the article:
 *   [Cross Domain Tracking on Google Analytics 4 (GA4)](https://www.thyngster.com/cross-domain-tracking-on-google-analytics-4-ga4/)
 *
 * @version 1.0.0
 * @last_updated 2023-11-03
 */

/**
 * Encodes a value using a custom base64-like encoding scheme.
 *
 * This function takes any input value, converts it to a string, and then
 * encodes it using a custom encoding scheme that's similar to base64 encoding.
 * The scheme uses a predefined character set to represent different bits of
 * each character in the input string.
 *
 * @param val - The input value to be encoded.
 * @returns The encoded string.
 */
function hash_cookie_value(val: any): string {
  // Character set used for the encoding.
  const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_."

  /**
   * Internal function to perform the custom base64-like encoding.
   *
   * @param a - The input string to be encoded.
   * @returns The encoded string.
   */
  const D = (a: string): string => {
    const b: string[] = []

    for (let c = 0; c < a.length; c += 3) {
      const hasSecondChar = c + 1 < a.length
      const hasThirdChar = c + 2 < a.length

      const firstChar = a.charCodeAt(c)
      const secondChar = hasSecondChar ? a.charCodeAt(c + 1) : 0
      const thirdChar = hasThirdChar ? a.charCodeAt(c + 2) : 0

      const p = firstChar >> 2
      const q = (firstChar & 3) << 4 | secondChar >> 4
      const r = (secondChar & 15) << 2 | thirdChar >> 6
      const s = thirdChar & 63

      // Handle padding for the custom encoding.
      const t = hasThirdChar ? s : 64
      const u = hasSecondChar ? r : 64

      b.push(A[p], A[q], A[u], A[t])
    }

    return b.join("")
  }

  return D(String(val))
}

/**
 * The purpose of this function is to generate a '_gl' parameter value based on existing
 * Google Analytics cookies to aid in cross-domain tracking.
 *
 * @param a Any input string to be used for generating a browser fingerprint.
 * @returns A string representation of the '_gl' parameter value.
 */
export function generateLinkerParam(a: any): string {
  // Function to retrieve the value of the '_ga' cookie.
  const getGACookies = (name: string): string | undefined => {
    const pair = document.cookie.match(new RegExp(name + '=([^;]+)'))
    // Extracts the actual value after the 'GA1' prefix.
    return pair ? pair[1].match(/GA1\.[0-9]\.(.+)/)?.[1] : undefined
  }

  // Function to retrieve all cookies with names starting with '_ga_'.
  const getGSCookies = (prefix: string) => {
    const regex = new RegExp(prefix + "([^=]+)=([^;]+)", "g")
    let match
    const results = []
    while ((match = regex.exec(document.cookie)) !== null) {
      const name = match[1]
      // Extracts the actual value after the 'GS1' prefix.
      const value = match[2].match(/GS1\.[0-9]\.(.+)/)?.[1] || undefined
      if (value) {
        results.push({ name: prefix + name, value })
      }
    }
    return results
  }

  // Collects the cookies for processing.
  const cookies: CookieMap = {
    _ga: getGACookies("_ga"),
    _ga_: getGSCookies("_ga_")
  }

  // Function to generate a fingerprint for the browser.
  const generateBrowserFingerprint = (a: string, b?: any): string => {
    if (!b) {
      b = Array(256)
      for (let c = 0; c < 256; c++) {
        let d = c
        for (let e = 0; e < 8; e++) {
          d = d & 1 ? d >>> 1 ^ 3988292384 : d >>> 1
        }
        b[c] = d
      }
    }

    const FValues = b!
    let browserFingerprintValue = 4294967295

    // Compute CRC-32 checksum for the input string.
    for (let c = 0; c < a.length; c++) {
      browserFingerprintValue = browserFingerprintValue >>> 8 ^ FValues[(browserFingerprintValue ^ a.charCodeAt(c)) & 255]
    }

    // Convert the computed value to base-36 for shortening.
    return ((browserFingerprintValue ^ -1) >>> 0).toString(36)
  }

  // Convert array of cookie objects to segments for the final parameter.
  const generateSegmentForCookie = (cookieObjects: { name: string, value: string }[]): string[] => {
    return cookieObjects.flatMap(cookie => [cookie.name, hash_cookie_value(cookie.value)])
  }

  // Generate segments for the '_ga' and '_ga_' cookies.
  const gaSegment = ["_ga", hash_cookie_value(cookies._ga)]
  const ga_Segments = generateSegmentForCookie(cookies._ga_)

  // Join everything together to form the final '_gl' parameter.
  return ["1", generateBrowserFingerprint(a), ...gaSegment, ...ga_Segments!].join('*')
}

/**
 * Retrieves or initializes the `google_tag_data.gl` object on the window object.
 *
 * This function ensures that the global `google_tag_data` structure is correctly initialized
 * and populated, particularly focusing on the `gl` substructure used for decorators and
 * any additional tagging data. If not present, it initializes the necessary objects and properties.
 *
 * @returns The google_tag_data.gl object, either retrieved or newly initialized.
 */
function getOrCreateGoogleTagData(): DecoratorsType {
  // Default structure if google_tag_data is not present on the window object.
  const defaultData = {}
  const existingData = window.google_tag_data || defaultData
  window.google_tag_data = existingData

  const googleTagData = window.google_tag_data

  // Ensure that the gl structure exists within google_tag_data.
  if (!googleTagData.gl) {
    googleTagData.gl = { decorators: [] }
  }

  // Ensure that the data structure exists within gl.
  if (!googleTagData.gl.data) {
    googleTagData.gl.data = {
      query: {},
      fragment: {}
    }
  }

  return googleTagData.gl
}

/**
 * Decodes a value that was encoded using a custom base64-like encoding specific to the cookie values.
 *
 * This function essentially reverses the process used in `hash_cookie_value`.
 * Given an encoded string, it retrieves the original string value.
 *
 * @param encodedVal - The string value that has been encoded using the custom base64-like encoding.
 * @returns The original decoded string.
 */
function decode_cookie_value(encodedVal: string): string {
  // Character set used for the custom base64-like encoding.
  const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_."

  /**
   * Core decoding function using the custom base64-like encoding.
   *
   * @param a - The encoded string segment.
   * @returns The decoded string segment.
   */
  const D = (a: string): string => {
    let decoded = ''
    for (let i = 0; i < a.length; i += 4) {
      const p = A.indexOf(a.charAt(i))
      const q = A.indexOf(a.charAt(i + 1))
      const r = A.indexOf(a.charAt(i + 2))
      const s = A.indexOf(a.charAt(i + 3))

      // Extract and convert the encoded characters back to their original byte values.
      const g = (p << 2) | (q >> 4)
      decoded += String.fromCharCode(g)

      // Ensure padding characters are not treated as additional characters in the decoded string.
      if (r !== 64) {
        const f = ((q & 15) << 4) | (r >> 2)
        decoded += String.fromCharCode(f)
      }

      if (s !== 64) {
        const h = ((r & 3) << 6) | s
        decoded += String.fromCharCode(h)
      }
    }
    return decoded
  }

  return D(encodedVal)
}

/**
 * @summary Decrypts a `_gl` parameter and merges its values into the `google_tag_data.data` object.
 *
 * This function processes the encrypted `_gl` parameter typically generated by the Google Analytics linker
 * feature. After decryption, it will merge the result into the `google_tag_data.data` object, either
 * into the query or fragment based on the `useFragment` argument.
 *
 * @param glParam - The `_gl` parameter to be decrypted.
 * @param useFragment - Optional flag indicating if data should be merged into the fragment.
 *
 * @returns The updated googleTagData.data object.
 *
 * @throws {Error} When Google Tag Manager (GTM) is not available.
 */
export function decryptCookiesIds(glParam: string, useFragment?: boolean): { [key: string]: any } {
  const googleTagData = getOrCreateGoogleTagData()

  // Ensure GTM is available.
  if (!googleTagData) {
    throw Error("GTM is not available!")
  }

  // Split the `_gl` parameter into segments for decryption.
  const segments = glParam.split('*')
  const decryptedData: { [key: string]: any } = {}

  // Skip the initial segments and process the key-value pairs.
  for (let i = 2; i < segments.length; i += 2) {
    const key = segments[i]
    const value = decode_cookie_value(segments[i + 1]) // Decrypt the encoded cookie value.
    decryptedData[key] = value
  }

  /**
   * Helper function to merge two objects.
   *
   * @param target - The object to be modified.
   * @param source - The object with properties to be merged.
   */
  const mergeObjects = (target: { [key: string]: any }, source: { [key: string]: any }): void => {
    for (const key in source) {
      if (source.hasOwnProperty(key)) {
        target[key] = source[key]
      }
    }
  }

  // Merge the decrypted data into the appropriate googleTagData.data section.
  if (useFragment) {
    mergeObjects(googleTagData.data!.fragment, decryptedData)
  } else {
    mergeObjects(googleTagData.data!.query, decryptedData)
  }

  // Return the updated googleTagData.data object.
  const result = {
    query: { ...googleTagData.data!.query },
    fragment: { ...googleTagData.data!.fragment }
  }

  return result
}

@mr0atarcher
Copy link

mr0atarcher commented Nov 3, 2023

^ relevant typings for the above

type CookieMap = {
  _ga?: string
  _ga_?: any
}

type DecoratorsData = {
  query: { [key: string]: any },
  fragment: { [key: string]: any }
}

type DecoratorsType = {
  decorators: any[]
  data?: DecoratorsData
}

type GoogleTagDataType = {
  gl?: DecoratorsType;
  [key: string]: any
}

interface GoogleTagData {
  gl?: {
    decorators?: any[]
    data?: {
      query?: Record<string, any>
      fragment?: Record<string, any>
    }
  }
}

interface Window {
  google_tag_data?: GoogleTagDataType
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment