diff --git a/src/__tests__/autocapture-utils.test.ts b/src/__tests__/autocapture-utils.test.ts index 162c7dbf2..456c24570 100644 --- a/src/__tests__/autocapture-utils.test.ts +++ b/src/__tests__/autocapture-utils.test.ts @@ -11,6 +11,7 @@ import { isAngularStyleAttr, getNestedSpanText, getDirectAndNestedSpanText, + getElementsChainString, } from '../autocapture-utils' import { document } from '../utils/globals' import { makeMouseEvent } from './autocapture.test' @@ -381,4 +382,19 @@ describe(`Autocapture utility functions`, () => { expect(getNestedSpanText(parent)).toBe('test test2') }) }) + + describe('getElementsChainString', () => { + it('should return an empty string with no elements', () => { + const elementChain = getElementsChainString([]) + + expect(elementChain).toEqual('') + }) + it('should process elements correctly', () => { + const elementChain = getElementsChainString([ + { tag_name: 'div', nth_child: 1, nth_of_type: 2, $el_text: 'text' }, + ]) + + expect(elementChain).toEqual('div:text="text"nth-child="1"nth-of-type="2"') + }) + }) }) diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index 07b21e818..7a83db600 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -1075,6 +1075,29 @@ describe('Autocapture system', () => { expect(props1['$elements'][0]).not.toHaveProperty('$el_text') }) + + it('returns elementsChain instead of elements when set', () => { + const elTarget = document.createElement('a') + elTarget.setAttribute('href', 'http://test.com') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + + const e = { + target: elTarget, + type: 'click', + } + + const newLib = { + ...lib, + elementsChainAsString: true, + } + + autocapture._captureEvent(e, newLib) + const props1 = getCapturedProps(newLib.capture) + + expect(props1['$elements_chain']).toBeDefined() + expect(props1['$elements']).toBeUndefined() + }) }) describe('_addDomEventHandlers', () => { diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 73ef340ee..99f977506 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -276,6 +276,13 @@ describe('posthog core', () => { expect(given.lib.analyticsDefaultEndpoint).toEqual('/i/v0/e/') }) + + it('enables elementsChainAsString if given', () => { + given('decideResponse', () => ({ elementsChainAsString: true })) + given.subject() + + expect(given.lib.elementsChainAsString).toBe(true) + }) }) describe('_calculate_event_properties()', () => { diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index 5f2d652cd..5e3072e5b 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -3,10 +3,11 @@ * @param {Element} el - element to get the className of * @returns {string} the element's class */ -import { AutocaptureConfig } from 'types' -import { _each, _includes, _trim } from './utils' -import { _isNull, _isString, _isUndefined } from './utils/type-utils' +import { AutocaptureConfig, Properties } from 'types' +import { _each, _entries, _includes, _trim } from './utils' + +import { _isArray, _isNull, _isString, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { window } from './utils/globals' @@ -346,3 +347,98 @@ export function getNestedSpanText(target: Element): string { } return text } + +/* +Back in the day storing events in Postgres we use Elements for autocapture events. +Now we're using elements_chain. We used to do this parsing/processing during ingestion. +This code is just copied over from ingestion, but we should optimize it +to create elements_chain string directly. +*/ +export function getElementsChainString(elements: Properties[]): string { + return elementsToString(extractElements(elements)) +} + +// This interface is called 'Element' in plugin-scaffold https://github.com/PostHog/plugin-scaffold/blob/b07d3b879796ecc7e22deb71bf627694ba05386b/src/types.ts#L200 +// However 'Element' is a DOM Element when run in the browser, so we have to rename it +interface PHElement { + text?: string + tag_name?: string + href?: string + attr_id?: string + attr_class?: string[] + nth_child?: number + nth_of_type?: number + attributes?: Record + event_id?: number + order?: number + group_id?: number +} + +function escapeQuotes(input: string): string { + return input.replace(/"|\\"/g, '\\"') +} + +function elementsToString(elements: PHElement[]): string { + const ret = elements.map((element) => { + let el_string = '' + if (element.tag_name) { + el_string += element.tag_name + } + if (element.attr_class) { + element.attr_class.sort() + for (const single_class of element.attr_class) { + el_string += `.${single_class.replace(/"/g, '')}` + } + } + const attributes: Record = { + ...(element.text ? { text: element.text } : {}), + 'nth-child': element.nth_child ?? 0, + 'nth-of-type': element.nth_of_type ?? 0, + ...(element.href ? { href: element.href } : {}), + ...(element.attr_id ? { attr_id: element.attr_id } : {}), + ...element.attributes, + } + const sortedAttributes: Record = {} + _entries(attributes) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach( + ([key, value]) => (sortedAttributes[escapeQuotes(key.toString())] = escapeQuotes(value.toString())) + ) + el_string += ':' + el_string += _entries(attributes) + .map(([key, value]) => `${key}="${value}"`) + .join('') + return el_string + }) + return ret.join(';') +} + +function extractElements(elements: Properties[]): PHElement[] { + return elements.map((el) => { + const response = { + text: el['$el_text']?.slice(0, 400), + tag_name: el['tag_name'], + href: el['attr__href']?.slice(0, 2048), + attr_class: extractAttrClass(el), + attr_id: el['attr__id'], + nth_child: el['nth_child'], + nth_of_type: el['nth_of_type'], + attributes: {} as { [id: string]: any }, + } + _entries(el) + .filter(([key]) => key.indexOf('attr__') === 0) + .forEach(([key, value]) => (response.attributes[key] = value)) + return response + }) +} + +function extractAttrClass(el: Properties): PHElement['attr_class'] { + const attr_class = el['attr__class'] + if (!attr_class) { + return undefined + } else if (_isArray(attr_class)) { + return attr_class + } else { + return attr_class.split(' ') + } +} diff --git a/src/autocapture.ts b/src/autocapture.ts index 86faa9be1..448a46654 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -13,6 +13,7 @@ import { isAngularStyleAttr, isDocumentFragment, getDirectAndNestedSpanText, + getElementsChainString, } from './autocapture-utils' import RageClick from './extensions/rageclick' import { AutocaptureConfig, AutoCaptureCustomProperty, DecideResponse, Properties } from './types' @@ -255,9 +256,13 @@ const autocapture = { const props = _extend( this._getDefaultProperties(e.type), - { - $elements: elementsJson, - }, + instance.elementsChainAsString + ? { + $elements_chain: getElementsChainString(elementsJson), + } + : { + $elements: elementsJson, + }, elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, this._getCustomProperties(targetElementList), autocaptureAugmentProperties diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 0b4551c85..e48d9cc13 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -287,6 +287,7 @@ export class PostHog { __autocapture: boolean | AutocaptureConfig | undefined decideEndpointWasHit: boolean analyticsDefaultEndpoint: string + elementsChainAsString: boolean SentryIntegration: typeof SentryIntegration segmentIntegration: () => any @@ -310,6 +311,7 @@ export class PostHog { this.__autocapture = undefined this._jsc = function () {} as JSC this.analyticsDefaultEndpoint = '/e/' + this.elementsChainAsString = false this.featureFlags = new PostHogFeatureFlags(this) this.toolbar = new Toolbar(this) @@ -536,6 +538,10 @@ export class PostHog { if (response.analytics?.endpoint) { this.analyticsDefaultEndpoint = response.analytics.endpoint } + + if (response.elementsChainAsString) { + this.elementsChainAsString = response.elementsChainAsString + } } _loaded(): void { diff --git a/src/types.ts b/src/types.ts index a413967a7..85a0651cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,6 +239,7 @@ export interface DecideResponse { analytics?: { endpoint?: string } + elementsChainAsString?: boolean // this is currently in development and may have breaking changes without a major version bump autocaptureExceptions?: | boolean