Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create elements chain string as we store it #823

Merged
merged 16 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/__tests__/autocapture-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isAngularStyleAttr,
getNestedSpanText,
getDirectAndNestedSpanText,
getElementsChainString,
} from '../autocapture-utils'
import { document } from '../utils/globals'
import { makeMouseEvent } from './autocapture.test'
Expand Down Expand Up @@ -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"')
})
})
})
23 changes: 23 additions & 0 deletions src/__tests__/autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/posthog-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
102 changes: 99 additions & 3 deletions src/autocapture-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, any>
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<string, any> = {
...(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<string, any> = {}
_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(' ')
}
}
11 changes: 8 additions & 3 deletions src/autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isAngularStyleAttr,
isDocumentFragment,
getDirectAndNestedSpanText,
getElementsChainString,
} from './autocapture-utils'
import RageClick from './extensions/rageclick'
import { AutocaptureConfig, AutoCaptureCustomProperty, DecideResponse, Properties } from './types'
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export class PostHog {
__autocapture: boolean | AutocaptureConfig | undefined
decideEndpointWasHit: boolean
analyticsDefaultEndpoint: string
elementsChainAsString: boolean

SentryIntegration: typeof SentryIntegration
segmentIntegration: () => any
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading