Skip to content

Commit

Permalink
feat: add ee licensed replay transformer
Browse files Browse the repository at this point in the history
  • Loading branch information
pauldambra committed Nov 23, 2023
1 parent e12454a commit d8afa8d
Show file tree
Hide file tree
Showing 15 changed files with 1,994 additions and 16 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/ci-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
# NOTE: we are at risk of missing a dependency here.
- 'bin/**'
- 'frontend/**'
- 'ee/frontend/**'
- 'ee/frontend/**'
# Make sure we run if someone is explicitly change the workflow
- .github/workflows/ci-frontend.yml
# various JS config files
Expand Down Expand Up @@ -107,6 +107,10 @@ jobs:
if: needs.changes.outputs.frontend == 'true'
run: pnpm schema:build:json && git diff --exit-code

- name: Check if mobile replay "schema.json" is up to date
if: needs.changes.outputs.frontend == 'true'
run: pnpm mobile-replay:schema:build:json && git diff --exit-code

- name: Check toolbar bundle size
if: needs.changes.outputs.frontend == 'true'
uses: preactjs/compressed-size-action@v2
Expand Down
5 changes: 5 additions & 0 deletions ee/frontend/exports.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PostHogEE } from '@posthog/ee/types'

import { transformToWeb } from './mobile-replay'

const myTestCode = (): void => {
// eslint-disable-next-line no-console
console.log('it works!')
Expand All @@ -8,6 +10,9 @@ const myTestCode = (): void => {
const postHogEE: PostHogEE = {
enabled: true,
myTestCode,
mobileReplay: {
transformToWeb,
},
}

export default postHogEE
63 changes: 63 additions & 0 deletions ee/frontend/mobile-replay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { eventWithTime } from '@rrweb/types'
import Ajv, { ErrorObject } from 'ajv'

import mobileSchema from './schema/mobile/rr-mobile-schema.json'
import webSchema from './schema/web/rr-web-schema.json'
import { makeFullEvent, makeMetaEvent } from './transformers'

const ajv = new Ajv() // options can be passed, e.g. {allErrors: true}

const transformers: Record<number, (x: any) => eventWithTime> = {
4: makeMetaEvent,
10: makeFullEvent,
}

const mobileSchemaValidator = ajv.compile(mobileSchema)

export function validateFromMobile(data: unknown): {
isValid: boolean
errors: ErrorObject[] | null | undefined
} {
const isValid = mobileSchemaValidator(data)
return {
isValid,
errors: isValid ? null : mobileSchemaValidator.errors,
}
}

const webSchemaValidator = ajv.compile(webSchema)

export class TransformationError implements Error {
name = 'TransformationError'
message = 'Failed to transform to web schema'
errors: ErrorObject<string, Record<string, unknown>, unknown>[] | null | undefined

constructor(_errors: ErrorObject<string, Record<string, unknown>, unknown>[] | null | undefined) {
this.errors = _errors
}
}

export function transformToWeb(mobileData: any[]): string {
const response = mobileData.reduce((acc, event) => {
const transformer = transformers[event.type]
if (!transformer) {
console.warn(`No transformer for event type ${event.type}`)
} else {
const transformed = transformer(event)
validateAgainstWebSchema(transformed)
acc.push(transformed)
}
return acc
}, [])

return JSON.stringify(response)
}

export function validateAgainstWebSchema(data: unknown): boolean {
const validationResult = webSchemaValidator(data)
if (!validationResult) {
console.error(webSchemaValidator.errors)
throw new TransformationError(webSchemaValidator.errors)
}
return validationResult
}
150 changes: 150 additions & 0 deletions ee/frontend/mobile-replay/mobile.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// copied from rrweb-snapshot, not included in rrweb types
import { customEvent, EventType } from '@rrweb/types'

export enum NodeType {
Document = 0,
DocumentType = 1,
Element = 2,
Text = 3,
CDATA = 4,
Comment = 5,
}

export type documentNode = {
type: NodeType.Document
childNodes: serializedNodeWithId[]
compatMode?: string
}

export type documentTypeNode = {
type: NodeType.DocumentType
name: string
publicId: string
systemId: string
}

export type attributes = {
[key: string]: string | number | true | null
}

export type elementNode = {
type: NodeType.Element
tagName: string
attributes: attributes
childNodes: serializedNodeWithId[]
isSVG?: true
needBlock?: boolean
// This is a custom element or not.
isCustom?: true
}

export type textNode = {
type: NodeType.Text
textContent: string
isStyle?: true
}

export type cdataNode = {
type: NodeType.CDATA
textContent: ''
}

export type commentNode = {
type: NodeType.Comment
textContent: string
}

export type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & {
rootId?: number
isShadowHost?: boolean
isShadow?: boolean
}

export type serializedNodeWithId = serializedNode & { id: number }

// end copied section

export type MobileNodeType = 'text' | 'image' | 'rectangle'

export type MobileStyles = {
color?: string
backgroundColor?: string
/**
* @description if borderWidth is present, then border style is assumed to be solid
*/
borderWidth?: string | number
/**
* @description if borderRadius is present, then border style is assumed to be solid
*/
borderRadius?: string | number
/**
* @description if borderColor is present, then border style is assumed to be solid
*/
borderColor?: string
}

type wireframeBase = {
id: number
/**
* @description x and y are the top left corner of the element, if they are present then the element is absolutely positioned
*/
x: number
y: number
width: number
height: number
childWireframes?: wireframe[]
type: MobileNodeType
style?: MobileStyles
}

export type wireframeText = wireframeBase & {
type: 'text'
text: string
}

export type wireframeImage = wireframeBase & {
type: 'image'
/**
* @description this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG
*/
base64: string
}

export type wireframeRectangle = wireframeBase & {
type: 'rectangle'
}

export type wireframe = wireframeText | wireframeImage | wireframeRectangle

// the rrweb full snapshot event type, but it contains wireframes not html
export type fullSnapshotEvent = {
type: EventType.FullSnapshot
data: {
/**
* @description This mimics the RRWeb full snapshot event type, except instead of reporting a serialized DOM it reports a wireframe representation of the screen.
*/
wireframes: wireframe[]
initialOffset: {
top: number
left: number
}
}
}

export type metaEvent = {
type: EventType.Meta
data: {
/**
* @description This mimics the RRWeb meta event type, except does not report href.
*/
width: number
height: number
}
}

export type mobileEvent = fullSnapshotEvent | metaEvent | customEvent

export type mobileEventWithTime = mobileEvent & {
timestamp: number
delay?: number
}
Loading

0 comments on commit d8afa8d

Please sign in to comment.