0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

feat: add ee licensed replay transformer (#18874)

first pass through using the EE licensed replay transformer in playback

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: David Newell <d.newell1@outlook.com>
This commit is contained in:
Paul D'Ambra 2023-11-29 10:41:18 +00:00 committed by GitHub
parent bda9166711
commit 16323959fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 2980 additions and 596 deletions

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
.eslintrc.js
jest.config.ts

View File

@ -54,6 +54,7 @@ module.exports = {
'compat',
'posthog',
'simple-import-sort',
'import',
],
rules: {
'no-console': ['error', { allow: ['warn', 'error'] }],
@ -261,6 +262,19 @@ module.exports = {
'no-constant-condition': 'off',
'no-prototype-builtins': 'off',
'no-irregular-whitespace': 'off',
'import/no-restricted-paths': [
'error',
{
zones: [
{
target: './frontend/**',
from: './ee/frontend/**',
message:
"EE licensed TypeScript should only be accessed via the posthogEE objects. Use `import posthogEE from '@posthog/ee/exports'`",
},
],
},
],
},
overrides: [
{

View File

@ -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

View File

@ -17,7 +17,7 @@
<env name="PYTHONUNBUFFERED" value="1" />
<env name="SESSION_RECORDING_KAFKA_COMPRESSION" value="gzip" />
<env name="SESSION_RECORDING_KAFKA_HOSTS" value="localhost" />
<env name="SESSION_RECORDING_KAFKA_MAX_REQUEST_SIZE_BYTES" value="524288" />
<env name="SESSION_RECORDING_KAFKA_MAX_REQUEST_SIZE_BYTES" value="20971520" />
<env name="SKIP_SERVICE_VERSION_REQUIREMENTS" value="1" />
<env name="REPLAY_EVENTS_NEW_CONSUMER_RATIO" value="1" />
</envs>
@ -48,4 +48,4 @@
<option name="customRunCommand" value="" />
<method v="2" />
</configuration>
</component>
</component>

View File

@ -1,13 +1,12 @@
import { PostHogEE } from '@posthog/ee/types'
const myTestCode = (): void => {
// eslint-disable-next-line no-console
console.log('it works!')
}
import { transformEventToWeb, transformToWeb } from './mobile-replay'
const postHogEE: PostHogEE = {
enabled: true,
myTestCode,
}
export default postHogEE
export default async (): Promise<PostHogEE> =>
Promise.resolve({
enabled: true,
mobileReplay: {
transformEventToWeb,
transformToWeb,
},
})

View File

@ -0,0 +1,429 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`replay/transform transform can convert images 1`] = `
[
{
"data": {
"height": 600,
"href": "",
"width": 300,
},
"timestamp": 1,
"type": 4,
},
{
"data": {
"initialOffset": {
"left": 0,
"top": 0,
},
"node": {
"childNodes": [
{
"id": 2,
"name": "html",
"publicId": "",
"systemId": "",
"type": 1,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [],
"id": 4,
"tagName": "head",
"type": 2,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [
{
"attributes": {
"style": "color: #ffffff;width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"id": 12345,
"textContent": "Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ",
"type": 3,
},
],
"id": 102,
"tagName": "div",
"type": 2,
},
{
"attributes": {
"height": 30,
"src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=",
"style": "width: 100px;height: 30px;position: absolute;left: 25px;top: 42px;",
"width": 100,
},
"childNodes": [],
"id": 12345,
"tagName": "img",
"type": 2,
},
],
"id": 101,
"tagName": "div",
"type": 2,
},
],
"id": 5,
"tagName": "body",
"type": 2,
},
],
"id": 3,
"tagName": "html",
"type": 2,
},
],
"id": 1,
"type": 0,
},
},
"timestamp": 1,
"type": 2,
},
]
`;
exports[`replay/transform transform can convert rect with text 1`] = `
[
{
"data": {
"height": 600,
"href": "",
"width": 300,
},
"timestamp": 1,
"type": 4,
},
{
"data": {
"initialOffset": {
"left": 0,
"top": 0,
},
"node": {
"childNodes": [
{
"id": 2,
"name": "html",
"publicId": "",
"systemId": "",
"type": 1,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [],
"id": 4,
"tagName": "head",
"type": 2,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [
{
"attributes": {
"style": "width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;",
"viewBox": "0 0 100 30",
},
"childNodes": [
{
"attributes": {
"fill": "transparent",
"height": 30,
"rx": "10px",
"stroke": "#ee3ee4",
"stroke-width": "4",
"width": 100,
"x": 0,
"y": 0,
},
"childNodes": [],
"id": 104,
"tagName": "rect",
"type": 2,
},
],
"id": 12345,
"tagName": "svg",
"type": 2,
},
{
"attributes": {
"style": "width: 100px;height: 30px;position: absolute;left: 13px;top: 17px;overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"id": 12345,
"textContent": "i am in the box",
"type": 3,
},
],
"id": 105,
"tagName": "div",
"type": 2,
},
],
"id": 103,
"tagName": "div",
"type": 2,
},
],
"id": 5,
"tagName": "body",
"type": 2,
},
],
"id": 3,
"tagName": "html",
"type": 2,
},
],
"id": 1,
"type": 0,
},
},
"timestamp": 1,
"type": 2,
},
]
`;
exports[`replay/transform transform can ignore unknown wireframe types 1`] = `
[
{
"data": {
"height": 600,
"href": "",
"width": 300,
},
"timestamp": 1,
"type": 4,
},
{
"data": {
"initialOffset": {
"left": 0,
"top": 0,
},
"node": {
"childNodes": [
{
"id": 2,
"name": "html",
"publicId": "",
"systemId": "",
"type": 1,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [],
"id": 4,
"tagName": "head",
"type": 2,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [],
"id": 100,
"tagName": "div",
"type": 2,
},
],
"id": 5,
"tagName": "body",
"type": 2,
},
],
"id": 3,
"tagName": "html",
"type": 2,
},
],
"id": 1,
"type": 0,
},
},
"timestamp": 1,
"type": 2,
},
]
`;
exports[`replay/transform transform can short-circuit non-mobile full snapshot 1`] = `
[
{
"data": {
"height": 600,
"href": "https://my-awesome.site",
"width": 300,
},
"timestamp": 1,
"type": 4,
},
{
"data": {
"node": {
"the": "payload",
},
},
"timestamp": 1,
"type": 2,
},
]
`;
exports[`replay/transform transform child wireframes are processed 1`] = `
[
{
"data": {
"height": 600,
"href": "",
"width": 300,
},
"timestamp": 1,
"type": 4,
},
{
"data": {
"initialOffset": {
"left": 0,
"top": 0,
},
"node": {
"childNodes": [
{
"id": 2,
"name": "html",
"publicId": "",
"systemId": "",
"type": 1,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [],
"id": 4,
"tagName": "head",
"type": 2,
},
{
"attributes": {},
"childNodes": [
{
"attributes": {},
"childNodes": [
{
"attributes": {
"style": "overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"attributes": {
"style": "overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"attributes": {
"style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"id": 12345,
"textContent": "first nested",
"type": 3,
},
],
"id": 107,
"tagName": "div",
"type": 2,
},
{
"attributes": {
"style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"id": 12345,
"textContent": "second nested",
"type": 3,
},
],
"id": 108,
"tagName": "div",
"type": 2,
},
],
"id": 98765,
"tagName": "div",
"type": 2,
},
{
"attributes": {
"style": "color: #ffffff;background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;",
},
"childNodes": [
{
"id": 12345,
"textContent": "third (different level) nested",
"type": 3,
},
],
"id": 109,
"tagName": "div",
"type": 2,
},
],
"id": 123456789,
"tagName": "div",
"type": 2,
},
],
"id": 106,
"tagName": "div",
"type": 2,
},
],
"id": 5,
"tagName": "body",
"type": 2,
},
],
"id": 3,
"tagName": "html",
"type": 2,
},
],
"id": 1,
"type": 0,
},
},
"timestamp": 1,
"type": 2,
},
]
`;

View File

@ -0,0 +1,73 @@
import { eventWithTime } from '@rrweb/types'
import Ajv, { ErrorObject } from 'ajv'
import { mobileEventWithTime } from './mobile.types'
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({
allowUnionTypes: true,
}) // options can be passed, e.g. {allErrors: true}
const transformers: Record<number, (x: any) => eventWithTime> = {
4: makeMetaEvent,
2: 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)
function couldBeEventWithTime(x: unknown): x is eventWithTime | mobileEventWithTime {
return typeof x === 'object' && x !== null && 'type' in x && 'timestamp' in x
}
export function transformEventToWeb(event: unknown): eventWithTime | null {
if (!couldBeEventWithTime(event)) {
console.warn(`No type in event: ${JSON.stringify(event)}`)
return null
}
const transformer = transformers[event.type]
if (transformer) {
const transformed = transformer(event)
validateAgainstWebSchema(transformed)
return transformed
} else {
console.warn(`No transformer for event type ${event.type}`)
return event as eventWithTime
}
}
export function transformToWeb(mobileData: (eventWithTime | mobileEventWithTime)[]): eventWithTime[] {
return mobileData.reduce((acc, event) => {
const transformed = transformEventToWeb(event)
if (transformed) {
acc.push(transformed)
}
return acc
}, [] as eventWithTime[])
}
export function validateAgainstWebSchema(data: unknown): boolean {
const validationResult = webSchemaValidator(data)
if (!validationResult) {
// we are passing all data through this validation now and don't know how safe the schema is
// TODO would we ever want to reject here?
console.error(webSchemaValidator.errors)
}
return validationResult
}

View File

@ -0,0 +1,172 @@
// 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' | 'div'
export type MobileStyles = {
/**
* @description maps to CSS color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000
*/
color?: string
/**
* @description maps to CSS background-color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000
*/
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
/**
* @description vertical alignment with respect to its parent
*/
verticalAlign?: 'top' | 'bottom' | 'center'
/**
* @description horizontal alignment with respect to its parent
*/
horizontalAlign?: 'left' | 'right' | 'center'
}
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, if they are not present this is equivalent to setting them to 0
*/
x: number
y: number
/*
* @description width and height are the dimensions of the element, the only accepted units is pixels. You can omit the unit.
*/
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 wireframeDiv = wireframeBase & {
/*
* @description this is the default type, if no type is specified then it is assumed to be a div
*/
type: 'div'
}
export type wireframe = wireframeText | wireframeImage | wireframeRectangle | wireframeDiv
// 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: {
href?: string
width: number
height: number
}
}
export type mobileEvent = fullSnapshotEvent | metaEvent | customEvent
export type mobileEventWithTime = mobileEvent & {
timestamp: number
delay?: number
}

View File

@ -0,0 +1,324 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"initialOffset": {
"additionalProperties": false,
"properties": {
"left": {
"type": "number"
},
"top": {
"type": "number"
}
},
"required": ["top", "left"],
"type": "object"
},
"wireframes": {
"description": "This mimics the RRWeb full snapshot event type, except instead of reporting a serialized DOM it reports a wireframe representation of the screen.",
"items": {
"$ref": "#/definitions/wireframe"
},
"type": "array"
}
},
"required": ["wireframes", "initialOffset"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.FullSnapshot"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"height": {
"type": "number"
},
"href": {
"type": "string"
},
"width": {
"type": "number"
}
},
"required": ["width", "height"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.Meta"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"payload": {},
"tag": {
"type": "string"
}
},
"required": ["tag", "payload"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.Custom"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
}
],
"definitions": {
"EventType.Custom": {
"const": 5,
"type": "number"
},
"EventType.FullSnapshot": {
"const": 2,
"type": "number"
},
"EventType.Meta": {
"const": 4,
"type": "number"
},
"MobileNodeType": {
"enum": ["text", "image", "rectangle", "div"],
"type": "string"
},
"MobileStyles": {
"additionalProperties": false,
"properties": {
"backgroundColor": {
"description": "maps to CSS background-color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000",
"type": "string"
},
"borderColor": {
"description": "if borderColor is present, then border style is assumed to be solid",
"type": "string"
},
"borderRadius": {
"description": "if borderRadius is present, then border style is assumed to be solid",
"type": ["string", "number"]
},
"borderWidth": {
"description": "if borderWidth is present, then border style is assumed to be solid",
"type": ["string", "number"]
},
"color": {
"description": "maps to CSS color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000",
"type": "string"
},
"horizontalAlign": {
"description": "horizontal alignment with respect to its parent",
"enum": ["left", "right", "center"],
"type": "string"
},
"verticalAlign": {
"description": "vertical alignment with respect to its parent",
"enum": ["top", "bottom", "center"],
"type": "string"
}
},
"type": "object"
},
"wireframe": {
"anyOf": [
{
"$ref": "#/definitions/wireframeText"
},
{
"$ref": "#/definitions/wireframeImage"
},
{
"$ref": "#/definitions/wireframeRectangle"
},
{
"$ref": "#/definitions/wireframeDiv"
}
]
},
"wireframeDiv": {
"additionalProperties": false,
"properties": {
"childWireframes": {
"items": {
"$ref": "#/definitions/wireframe"
},
"type": "array"
},
"height": {
"type": "number"
},
"id": {
"type": "number"
},
"style": {
"$ref": "#/definitions/MobileStyles"
},
"type": {
"$ref": "#/definitions/MobileNodeType"
},
"width": {
"type": "number"
},
"x": {
"description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0",
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["height", "id", "type", "width", "x", "y"],
"type": "object"
},
"wireframeImage": {
"additionalProperties": false,
"properties": {
"base64": {
"description": "this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG",
"type": "string"
},
"childWireframes": {
"items": {
"$ref": "#/definitions/wireframe"
},
"type": "array"
},
"height": {
"type": "number"
},
"id": {
"type": "number"
},
"style": {
"$ref": "#/definitions/MobileStyles"
},
"type": {
"$ref": "#/definitions/MobileNodeType"
},
"width": {
"type": "number"
},
"x": {
"description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0",
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["base64", "height", "id", "type", "width", "x", "y"],
"type": "object"
},
"wireframeRectangle": {
"additionalProperties": false,
"properties": {
"childWireframes": {
"items": {
"$ref": "#/definitions/wireframe"
},
"type": "array"
},
"height": {
"type": "number"
},
"id": {
"type": "number"
},
"style": {
"$ref": "#/definitions/MobileStyles"
},
"type": {
"$ref": "#/definitions/MobileNodeType"
},
"width": {
"type": "number"
},
"x": {
"description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0",
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["height", "id", "type", "width", "x", "y"],
"type": "object"
},
"wireframeText": {
"additionalProperties": false,
"properties": {
"childWireframes": {
"items": {
"$ref": "#/definitions/wireframe"
},
"type": "array"
},
"height": {
"type": "number"
},
"id": {
"type": "number"
},
"style": {
"$ref": "#/definitions/MobileStyles"
},
"text": {
"type": "string"
},
"type": {
"$ref": "#/definitions/MobileNodeType"
},
"width": {
"type": "number"
},
"x": {
"description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0",
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["height", "id", "text", "type", "width", "x", "y"],
"type": "object"
}
}
}

View File

@ -0,0 +1,951 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"additionalProperties": false,
"properties": {
"data": {},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.DomContentLoaded"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.Load"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"initialOffset": {
"additionalProperties": false,
"properties": {
"left": {
"type": "number"
},
"top": {
"type": "number"
}
},
"required": ["top", "left"],
"type": "object"
},
"node": {}
},
"required": ["node", "initialOffset"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.FullSnapshot"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"$ref": "#/definitions/incrementalData"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.IncrementalSnapshot"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"height": {
"type": "number"
},
"href": {
"type": "string"
},
"width": {
"type": "number"
}
},
"required": ["href", "width", "height"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.Meta"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"payload": {},
"tag": {
"type": "string"
}
},
"required": ["tag", "payload"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.Custom"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"data": {
"additionalProperties": false,
"properties": {
"payload": {},
"plugin": {
"type": "string"
}
},
"required": ["plugin", "payload"],
"type": "object"
},
"delay": {
"type": "number"
},
"timestamp": {
"type": "number"
},
"type": {
"$ref": "#/definitions/EventType.Plugin"
}
},
"required": ["data", "timestamp", "type"],
"type": "object"
}
],
"definitions": {
"CanvasContext": {
"enum": [0, 1, 2],
"type": "number"
},
"EventType.Custom": {
"const": 5,
"type": "number"
},
"EventType.DomContentLoaded": {
"const": 0,
"type": "number"
},
"EventType.FullSnapshot": {
"const": 2,
"type": "number"
},
"EventType.IncrementalSnapshot": {
"const": 3,
"type": "number"
},
"EventType.Load": {
"const": 1,
"type": "number"
},
"EventType.Meta": {
"const": 4,
"type": "number"
},
"EventType.Plugin": {
"const": 6,
"type": "number"
},
"FontDisplay": {
"enum": ["auto", "block", "fallback", "optional", "swap"],
"type": "string"
},
"FontFaceDescriptors": {
"additionalProperties": false,
"properties": {
"ascentOverride": {
"type": "string"
},
"descentOverride": {
"type": "string"
},
"display": {
"$ref": "#/definitions/FontDisplay"
},
"featureSettings": {
"type": "string"
},
"lineGapOverride": {
"type": "string"
},
"stretch": {
"type": "string"
},
"style": {
"type": "string"
},
"unicodeRange": {
"type": "string"
},
"variant": {
"type": "string"
},
"weight": {
"type": "string"
}
},
"type": "object"
},
"IncrementalSource.AdoptedStyleSheet": {
"const": 15,
"type": "number"
},
"IncrementalSource.CanvasMutation": {
"const": 9,
"type": "number"
},
"IncrementalSource.Drag": {
"const": 12,
"type": "number"
},
"IncrementalSource.Font": {
"const": 10,
"type": "number"
},
"IncrementalSource.Input": {
"const": 5,
"type": "number"
},
"IncrementalSource.MediaInteraction": {
"const": 7,
"type": "number"
},
"IncrementalSource.MouseInteraction": {
"const": 2,
"type": "number"
},
"IncrementalSource.MouseMove": {
"const": 1,
"type": "number"
},
"IncrementalSource.Mutation": {
"const": 0,
"type": "number"
},
"IncrementalSource.Scroll": {
"const": 3,
"type": "number"
},
"IncrementalSource.Selection": {
"const": 14,
"type": "number"
},
"IncrementalSource.StyleDeclaration": {
"const": 13,
"type": "number"
},
"IncrementalSource.StyleSheetRule": {
"const": 8,
"type": "number"
},
"IncrementalSource.TouchMove": {
"const": 6,
"type": "number"
},
"IncrementalSource.ViewportResize": {
"const": 4,
"type": "number"
},
"MediaInteractions": {
"enum": [0, 1, 2, 3, 4],
"type": "number"
},
"MouseInteractions": {
"enum": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"type": "number"
},
"PointerTypes": {
"enum": [0, 1, 2],
"type": "number"
},
"SelectionRange": {
"additionalProperties": false,
"properties": {
"end": {
"type": "number"
},
"endOffset": {
"type": "number"
},
"start": {
"type": "number"
},
"startOffset": {
"type": "number"
}
},
"required": ["start", "startOffset", "end", "endOffset"],
"type": "object"
},
"addedNodeMutation": {
"additionalProperties": false,
"properties": {
"nextId": {
"type": ["number", "null"]
},
"node": {},
"parentId": {
"type": "number"
},
"previousId": {
"type": ["number", "null"]
}
},
"required": ["parentId", "nextId", "node"],
"type": "object"
},
"adoptedStyleSheetData": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"source": {
"$ref": "#/definitions/IncrementalSource.AdoptedStyleSheet"
},
"styleIds": {
"items": {
"type": "number"
},
"type": "array"
},
"styles": {
"items": {
"additionalProperties": false,
"properties": {
"rules": {
"items": {
"$ref": "#/definitions/styleSheetAddRule"
},
"type": "array"
},
"styleId": {
"type": "number"
}
},
"required": ["styleId", "rules"],
"type": "object"
},
"type": "array"
}
},
"required": ["id", "source", "styleIds"],
"type": "object"
},
"attributeMutation": {
"additionalProperties": false,
"properties": {
"attributes": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"$ref": "#/definitions/styleOMValue"
},
{
"type": "null"
}
]
},
"type": "object"
},
"id": {
"type": "number"
}
},
"required": ["id", "attributes"],
"type": "object"
},
"canvasMutationCommand": {
"additionalProperties": false,
"properties": {
"args": {
"items": {},
"type": "array"
},
"property": {
"type": "string"
},
"setter": {
"const": true,
"type": "boolean"
}
},
"required": ["property", "args"],
"type": "object"
},
"canvasMutationData": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"commands": {
"items": {
"$ref": "#/definitions/canvasMutationCommand"
},
"type": "array"
},
"id": {
"type": "number"
},
"source": {
"$ref": "#/definitions/IncrementalSource.CanvasMutation"
},
"type": {
"$ref": "#/definitions/CanvasContext"
}
},
"required": ["commands", "id", "source", "type"],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"args": {
"items": {},
"type": "array"
},
"id": {
"type": "number"
},
"property": {
"type": "string"
},
"setter": {
"const": true,
"type": "boolean"
},
"source": {
"$ref": "#/definitions/IncrementalSource.CanvasMutation"
},
"type": {
"$ref": "#/definitions/CanvasContext"
}
},
"required": ["args", "id", "property", "source", "type"],
"type": "object"
}
]
},
"fontData": {
"additionalProperties": false,
"properties": {
"buffer": {
"type": "boolean"
},
"descriptors": {
"$ref": "#/definitions/FontFaceDescriptors"
},
"family": {
"type": "string"
},
"fontSource": {
"type": "string"
},
"source": {
"$ref": "#/definitions/IncrementalSource.Font"
}
},
"required": ["buffer", "family", "fontSource", "source"],
"type": "object"
},
"incrementalData": {
"anyOf": [
{
"$ref": "#/definitions/mutationData"
},
{
"$ref": "#/definitions/mousemoveData"
},
{
"$ref": "#/definitions/mouseInteractionData"
},
{
"$ref": "#/definitions/scrollData"
},
{
"$ref": "#/definitions/viewportResizeData"
},
{
"$ref": "#/definitions/inputData"
},
{
"$ref": "#/definitions/mediaInteractionData"
},
{
"$ref": "#/definitions/styleSheetRuleData"
},
{
"$ref": "#/definitions/canvasMutationData"
},
{
"$ref": "#/definitions/fontData"
},
{
"$ref": "#/definitions/selectionData"
},
{
"$ref": "#/definitions/styleDeclarationData"
},
{
"$ref": "#/definitions/adoptedStyleSheetData"
}
]
},
"inputData": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"isChecked": {
"type": "boolean"
},
"source": {
"$ref": "#/definitions/IncrementalSource.Input"
},
"text": {
"type": "string"
},
"userTriggered": {
"type": "boolean"
}
},
"required": ["id", "isChecked", "source", "text"],
"type": "object"
},
"mediaInteractionData": {
"additionalProperties": false,
"properties": {
"currentTime": {
"type": "number"
},
"id": {
"type": "number"
},
"muted": {
"type": "boolean"
},
"playbackRate": {
"type": "number"
},
"source": {
"$ref": "#/definitions/IncrementalSource.MediaInteraction"
},
"type": {
"$ref": "#/definitions/MediaInteractions"
},
"volume": {
"type": "number"
}
},
"required": ["id", "source", "type"],
"type": "object"
},
"mouseInteractionData": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"pointerType": {
"$ref": "#/definitions/PointerTypes"
},
"source": {
"$ref": "#/definitions/IncrementalSource.MouseInteraction"
},
"type": {
"$ref": "#/definitions/MouseInteractions"
},
"x": {
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["id", "source", "type", "x", "y"],
"type": "object"
},
"mousePosition": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"timeOffset": {
"type": "number"
},
"x": {
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["x", "y", "id", "timeOffset"],
"type": "object"
},
"mousemoveData": {
"additionalProperties": false,
"properties": {
"positions": {
"items": {
"$ref": "#/definitions/mousePosition"
},
"type": "array"
},
"source": {
"anyOf": [
{
"$ref": "#/definitions/IncrementalSource.MouseMove"
},
{
"$ref": "#/definitions/IncrementalSource.TouchMove"
},
{
"$ref": "#/definitions/IncrementalSource.Drag"
}
]
}
},
"required": ["source", "positions"],
"type": "object"
},
"mutationData": {
"additionalProperties": false,
"properties": {
"adds": {
"items": {
"$ref": "#/definitions/addedNodeMutation"
},
"type": "array"
},
"attributes": {
"items": {
"$ref": "#/definitions/attributeMutation"
},
"type": "array"
},
"isAttachIframe": {
"const": true,
"type": "boolean"
},
"removes": {
"items": {
"$ref": "#/definitions/removedNodeMutation"
},
"type": "array"
},
"source": {
"$ref": "#/definitions/IncrementalSource.Mutation"
},
"texts": {
"items": {
"$ref": "#/definitions/textMutation"
},
"type": "array"
}
},
"required": ["adds", "attributes", "removes", "source", "texts"],
"type": "object"
},
"removedNodeMutation": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"isShadow": {
"type": "boolean"
},
"parentId": {
"type": "number"
}
},
"required": ["parentId", "id"],
"type": "object"
},
"scrollData": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"source": {
"$ref": "#/definitions/IncrementalSource.Scroll"
},
"x": {
"type": "number"
},
"y": {
"type": "number"
}
},
"required": ["id", "source", "x", "y"],
"type": "object"
},
"selectionData": {
"additionalProperties": false,
"properties": {
"ranges": {
"items": {
"$ref": "#/definitions/SelectionRange"
},
"type": "array"
},
"source": {
"$ref": "#/definitions/IncrementalSource.Selection"
}
},
"required": ["ranges", "source"],
"type": "object"
},
"styleDeclarationData": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"index": {
"items": {
"type": "number"
},
"type": "array"
},
"remove": {
"additionalProperties": false,
"properties": {
"property": {
"type": "string"
}
},
"required": ["property"],
"type": "object"
},
"set": {
"additionalProperties": false,
"properties": {
"priority": {
"type": "string"
},
"property": {
"type": "string"
},
"value": {
"type": ["string", "null"]
}
},
"required": ["property", "value"],
"type": "object"
},
"source": {
"$ref": "#/definitions/IncrementalSource.StyleDeclaration"
},
"styleId": {
"type": "number"
}
},
"required": ["index", "source"],
"type": "object"
},
"styleOMValue": {
"additionalProperties": {
"anyOf": [
{
"$ref": "#/definitions/styleValueWithPriority"
},
{
"type": "string"
},
{
"const": false,
"type": "boolean"
}
]
},
"type": "object"
},
"styleSheetAddRule": {
"additionalProperties": false,
"properties": {
"index": {
"anyOf": [
{
"type": "number"
},
{
"items": {
"type": "number"
},
"type": "array"
}
]
},
"rule": {
"type": "string"
}
},
"required": ["rule"],
"type": "object"
},
"styleSheetDeleteRule": {
"additionalProperties": false,
"properties": {
"index": {
"anyOf": [
{
"type": "number"
},
{
"items": {
"type": "number"
},
"type": "array"
}
]
}
},
"required": ["index"],
"type": "object"
},
"styleSheetRuleData": {
"additionalProperties": false,
"properties": {
"adds": {
"items": {
"$ref": "#/definitions/styleSheetAddRule"
},
"type": "array"
},
"id": {
"type": "number"
},
"removes": {
"items": {
"$ref": "#/definitions/styleSheetDeleteRule"
},
"type": "array"
},
"replace": {
"type": "string"
},
"replaceSync": {
"type": "string"
},
"source": {
"$ref": "#/definitions/IncrementalSource.StyleSheetRule"
},
"styleId": {
"type": "number"
}
},
"required": ["source"],
"type": "object"
},
"styleValueWithPriority": {
"items": {
"type": "string"
},
"maxItems": 2,
"minItems": 2,
"type": "array"
},
"textMutation": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"value": {
"type": ["string", "null"]
}
},
"required": ["id", "value"],
"type": "object"
},
"viewportResizeData": {
"additionalProperties": false,
"properties": {
"height": {
"type": "number"
},
"source": {
"$ref": "#/definitions/IncrementalSource.ViewportResize"
},
"width": {
"type": "number"
}
},
"required": ["height", "source", "width"],
"type": "object"
}
}
}

View File

@ -0,0 +1,301 @@
import posthogEE from '@posthog/ee/exports'
import { EventType } from '@rrweb/types'
import { ifEeDescribe } from 'lib/ee.test'
import { PostHogEE } from '../../../frontend/@posthog/ee/types'
import { validateAgainstWebSchema, validateFromMobile } from './index'
const heartEyesEmojiURL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII='
describe('replay/transform', () => {
describe('validation', () => {
test('example of validating incoming _invalid_ data', () => {
const invalidData = {
foo: 'abc',
bar: 'abc',
}
expect(validateFromMobile(invalidData).isValid).toBe(false)
})
test('example of validating mobile meta event', () => {
const validData = {
data: { width: 1, height: 1 },
timestamp: 1,
type: EventType.Meta,
}
expect(validateFromMobile(validData)).toStrictEqual({
isValid: true,
errors: null,
})
})
describe('validate web schema', () => {
test('does not block when invalid', () => {
expect(validateAgainstWebSchema({})).toBeFalsy()
})
test('should be valid when...', () => {
expect(validateAgainstWebSchema({ data: {}, timestamp: 12345, type: 0 })).toBeTruthy()
})
})
})
ifEeDescribe('transform', () => {
let posthogEEModule: PostHogEE
beforeEach(async () => {
posthogEEModule = await posthogEE()
})
test('can ignore unknown types', () => {
expect(
posthogEEModule.mobileReplay?.transformToWeb([
{
data: { width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{
data: { href: 'included when present', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{ type: 9999 },
])
).toStrictEqual([
{ type: 4, data: { href: '', width: 300, height: 600 }, timestamp: 1 },
{ type: 4, data: { href: 'included when present', width: 300, height: 600 }, timestamp: 1 },
])
})
test('can ignore unknown wireframe types', () => {
const unexpectedWireframeType = posthogEEModule.mobileReplay?.transformToWeb([
{
data: { screen: 'App Home Page', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{
type: 2,
data: {
wireframes: [
{
id: 12345,
x: 11,
y: 12,
width: 100,
height: 30,
type: 'something in the SDK but not yet the transformer',
},
],
},
timestamp: 1,
},
])
expect(unexpectedWireframeType).toMatchSnapshot()
})
test('can short-circuit non-mobile full snapshot', () => {
const allWeb = posthogEEModule.mobileReplay?.transformToWeb([
{
data: { href: 'https://my-awesome.site', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{
type: 2,
data: {
node: { the: 'payload' },
},
timestamp: 1,
},
])
expect(allWeb).toMatchSnapshot()
})
test('can convert images', () => {
const exampleWithImage = posthogEEModule.mobileReplay?.transformToWeb([
{
data: {
screen: 'App Home Page',
width: 300,
height: 600,
},
timestamp: 1,
type: 4,
},
{
type: 2,
data: {
wireframes: [
{
id: 12345,
x: 11,
y: 12,
width: 100,
height: 30,
// clip: {
// bottom: 83,
// right: 44,
// },
type: 'text',
text: 'Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ',
style: {
// family: '疴ꖻ䖭㋑⁃⻋ꑧٹ㧕Ⓖ',
// size: 4220431756569966319,
color: '#ffffff',
},
},
{
id: 12345,
x: 25,
y: 42,
width: 100,
height: 30,
// clip: {
// bottom: 83,
// right: 44,
// },
type: 'image',
base64: heartEyesEmojiURL,
},
],
},
timestamp: 1,
},
])
expect(exampleWithImage).toMatchSnapshot()
})
test('can convert rect with text', () => {
const exampleWithRectAndText = posthogEEModule.mobileReplay?.transformToWeb([
{
data: {
width: 300,
height: 600,
},
timestamp: 1,
type: 4,
},
{
type: 2,
data: {
wireframes: [
{
id: 12345,
x: 11,
y: 12,
width: 100,
height: 30,
type: 'rectangle',
style: {
color: '#ee3ee4',
borderColor: '#ee3ee4',
borderWidth: '4',
borderRadius: '10px',
},
},
{
id: 12345,
x: 13,
y: 17,
width: 100,
height: 30,
verticalAlign: 'top',
horizontalAlign: 'right',
type: 'text',
text: 'i am in the box',
},
],
},
timestamp: 1,
},
])
expect(exampleWithRectAndText).toMatchSnapshot()
})
test('child wireframes are processed', () => {
const textEvent = posthogEEModule.mobileReplay?.transformToWeb([
{
data: { screen: 'App Home Page', width: 300, height: 600 },
timestamp: 1,
type: 4,
},
{
type: 2,
data: {
wireframes: [
{
id: 123456789,
childWireframes: [
{
id: 98765,
childWireframes: [
{
id: 12345,
x: 11,
y: 12,
width: 100,
height: 30,
type: 'text',
text: 'first nested',
style: {
color: '#ffffff',
backgroundColor: '#000000',
borderWidth: '4px',
borderColor: '#000ddd',
borderRadius: '10px',
},
},
{
id: 12345,
x: 11,
y: 12,
width: 100,
height: 30,
type: 'text',
text: 'second nested',
style: {
color: '#ffffff',
backgroundColor: '#000000',
borderWidth: '4px',
borderColor: '#000ddd',
borderRadius: '10px',
},
},
],
},
{
id: 12345,
x: 11,
y: 12,
width: 100,
height: 30,
// clip: {
// bottom: 83,
// right: 44,
// },
type: 'text',
text: 'third (different level) nested',
style: {
// family: '疴ꖻ䖭㋑⁃⻋ꑧٹ㧕Ⓖ',
// size: 4220431756569966319,
color: '#ffffff',
backgroundColor: '#000000',
borderWidth: '4px',
borderColor: '#000ddd',
borderRadius: '10', // you can omit the pixels
},
},
],
},
],
},
timestamp: 1,
},
])
expect(textEvent).toMatchSnapshot()
})
})
})

View File

@ -0,0 +1,269 @@
import { EventType, fullSnapshotEvent, metaEvent } from '@rrweb/types'
import {
fullSnapshotEvent as MobileFullSnapshotEvent,
metaEvent as MobileMetaEvent,
NodeType,
serializedNodeWithId,
wireframe,
wireframeDiv,
wireframeImage,
wireframeRectangle,
wireframeText,
} from './mobile.types'
import { makePositionStyles, makeStylesString, makeSvgBorder } from './wireframeStyle'
/**
* generates a sequence of ids
* from 100 to 9,999,999
* the transformer reserves ids in the range 0 to 9,999,999
* we reserve a range of ids because we need nodes to have stable ids across snapshots
* in order for incremental snapshots to work
* some mobile elements have to be wrapped in other elements in order to be styled correctly
* which means the web version of a mobile replay will use ids that don't exist in the mobile replay,
* and we need to ensure they don't clash
* -----
* id is typed as a number in rrweb
* and there's a few places in their code where rrweb uses a check for `id === -1` to bail out of processing
* so, it's safest to assume that id is expected to be a positive integer
*/
function* ids(): Generator<number> {
let i = 100
while (i < 9999999) {
yield i++
}
}
const idSequence = ids()
export const makeMetaEvent = (
mobileMetaEvent: MobileMetaEvent & {
timestamp: number
}
): metaEvent & {
timestamp: number
delay?: number
} => ({
type: EventType.Meta,
data: {
href: mobileMetaEvent.data.href || '', // the replay doesn't use the href, so we safely ignore any absence
// mostly we need width and height in order to size the viewport
width: mobileMetaEvent.data.width,
height: mobileMetaEvent.data.height,
},
timestamp: mobileMetaEvent.timestamp,
})
function _isPositiveInteger(id: unknown): boolean {
return typeof id === 'number' && id > 0 && id % 1 === 0
}
function makeDivElement(wireframe: wireframeDiv, children: serializedNodeWithId[]): serializedNodeWithId | null {
const _id = _isPositiveInteger(wireframe.id) ? wireframe.id : idSequence.next().value
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;',
},
id: _id,
childNodes: children,
}
}
function makeTextElement(wireframe: wireframeText, children: serializedNodeWithId[]): serializedNodeWithId | null {
if (wireframe.type !== 'text') {
console.error('Passed incorrect wireframe type to makeTextElement')
return null
}
// because we might have to style the text, we always wrap it in a div
// and apply styles to that
return {
type: NodeType.Element,
tagName: 'div',
attributes: {
style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;',
},
id: idSequence.next().value,
childNodes: [
{
type: NodeType.Text,
textContent: wireframe.text,
id: wireframe.id,
},
...children,
],
}
}
function makeImageElement(wireframe: wireframeImage, children: serializedNodeWithId[]): serializedNodeWithId | null {
let src = wireframe.base64
if (!src.startsWith('data:image/')) {
src = 'data:image/png;base64,' + src
}
return {
type: NodeType.Element,
tagName: 'img',
attributes: {
src: src,
width: wireframe.width,
height: wireframe.height,
style: makeStylesString(wireframe),
},
id: wireframe.id,
childNodes: children,
}
}
function makeRectangleElement(
wireframe: wireframeRectangle,
children: serializedNodeWithId[]
): serializedNodeWithId | null {
return {
type: NodeType.Element,
tagName: 'svg',
attributes: {
style: makePositionStyles(wireframe),
viewBox: `0 0 ${wireframe.width} ${wireframe.height}`,
},
id: wireframe.id,
childNodes: [
{
type: NodeType.Element,
tagName: 'rect',
attributes: {
x: 0,
y: 0,
width: wireframe.width,
height: wireframe.height,
fill: wireframe.style?.backgroundColor || 'transparent',
...makeSvgBorder(wireframe.style),
},
id: idSequence.next().value,
childNodes: children,
},
],
}
}
function chooseConverter<T extends wireframe>(
wireframe: T
): (wireframe: T, children: serializedNodeWithId[]) => serializedNodeWithId | null {
// in theory type is always present
// but since this is coming over the wire we can't really be sure
// and so we default to div
const converterType = wireframe.type || 'div'
switch (converterType) {
case 'text':
return makeTextElement as unknown as (
wireframe: T,
children: serializedNodeWithId[]
) => serializedNodeWithId | null
case 'image':
return makeImageElement as unknown as (
wireframe: T,
children: serializedNodeWithId[]
) => serializedNodeWithId | null
case 'rectangle':
return makeRectangleElement as unknown as (
wireframe: T,
children: serializedNodeWithId[]
) => serializedNodeWithId | null
case 'div':
return makeDivElement as unknown as (
wireframe: T,
children: serializedNodeWithId[]
) => serializedNodeWithId | null
}
}
function convertWireframesFor(wireframes: wireframe[] | undefined): serializedNodeWithId[] {
if (!wireframes) {
return []
}
return wireframes.reduce((acc, wireframe) => {
const children = convertWireframesFor(wireframe.childWireframes)
const converter = chooseConverter(wireframe)
if (!converter) {
console.error(`No converter for wireframe type ${wireframe.type}`)
return acc
}
const convertedEl = converter(wireframe, children)
if (convertedEl !== null) {
acc.push(convertedEl)
}
return acc
}, [] as serializedNodeWithId[])
}
export const makeFullEvent = (
mobileEvent: MobileFullSnapshotEvent & {
timestamp: number
delay?: number
}
): fullSnapshotEvent & {
timestamp: number
delay?: number
} => {
if (!('wireframes' in mobileEvent.data)) {
return mobileEvent as unknown as fullSnapshotEvent & {
timestamp: number
delay?: number
}
}
return {
type: EventType.FullSnapshot,
timestamp: mobileEvent.timestamp,
data: {
node: {
type: NodeType.Document,
childNodes: [
{
type: NodeType.DocumentType,
name: 'html',
publicId: '',
systemId: '',
id: 2,
},
{
type: NodeType.Element,
tagName: 'html',
attributes: {},
id: 3,
childNodes: [
{
type: NodeType.Element,
tagName: 'head',
attributes: {},
id: 4,
childNodes: [],
},
{
type: NodeType.Element,
tagName: 'body',
attributes: {},
id: 5,
childNodes: [
{
type: NodeType.Element,
tagName: 'div',
attributes: {},
id: idSequence.next().value,
childNodes: convertWireframesFor(mobileEvent.data.wireframes),
},
],
},
],
},
],
id: 1,
},
initialOffset: {
top: 0,
left: 0,
},
},
}
}

View File

@ -0,0 +1,95 @@
import { MobileStyles, wireframe } from './mobile.types'
function ensureUnit(value: string | number): string {
return typeof value === 'number' ? `${value}px` : value.replace(/px$/g, '') + 'px'
}
function makeBorderStyles(wireframe: wireframe): string {
let styles = ''
if (wireframe.style?.borderWidth) {
const borderWidth = ensureUnit(wireframe.style.borderWidth)
styles += `border-width: ${borderWidth};`
}
if (wireframe.style?.borderRadius) {
const borderRadius = ensureUnit(wireframe.style.borderRadius)
styles += `border-radius: ${borderRadius};`
}
if (wireframe.style?.borderColor) {
styles += `border-color: ${wireframe.style.borderColor};`
}
if (styles.length > 0) {
styles += `border-style: solid;`
}
return styles
}
export function makeSvgBorder(style: MobileStyles | undefined): Record<string, string> {
const svgBorderStyles: Record<string, string> = {}
if (style?.borderWidth) {
svgBorderStyles['stroke-width'] = style.borderWidth.toString()
}
if (style?.borderColor) {
svgBorderStyles.stroke = style.borderColor
}
if (style?.borderRadius) {
svgBorderStyles.rx = ensureUnit(style.borderRadius)
}
return svgBorderStyles
}
export function makePositionStyles(wireframe: wireframe): string {
let styles = ''
if (wireframe.width) {
styles += `width: ${ensureUnit(wireframe.width)};`
}
if (wireframe.height) {
styles += `height: ${ensureUnit(wireframe.height)};`
}
if (wireframe.x || wireframe.y) {
styles += `position: absolute;`
if (wireframe.x) {
styles += `left: ${ensureUnit(wireframe.x)};`
}
if (wireframe.y) {
styles += `top: ${ensureUnit(wireframe.y)};`
}
}
return styles
}
function makeLayoutStyles(wireframe: wireframe): string {
let styles = ''
if (wireframe.style?.verticalAlign) {
styles += `align-items: ${
{ top: 'flex-start', center: 'center', bottom: 'flex-end' }[wireframe.style.verticalAlign]
};`
}
if (wireframe.style?.horizontalAlign) {
styles += `justify-content: ${
{ left: 'flex-start', center: 'center', right: 'flex-end' }[wireframe.style.horizontalAlign]
};`
}
if (styles.length) {
styles += `display: flex;`
}
return styles
}
export function makeStylesString(wireframe: wireframe): string {
let styles = ''
if (wireframe.style?.color) {
styles += `color: ${wireframe.style.color};`
}
if (wireframe.style?.backgroundColor) {
styles += `background-color: ${wireframe.style.backgroundColor};`
}
styles += makeBorderStyles(wireframe)
styles += makePositionStyles(wireframe)
styles += makeLayoutStyles(wireframe)
return styles
}

View File

@ -1,7 +1,14 @@
import { PostHogEE } from './types'
const posthogEE: PostHogEE = {
enabled: false,
export default async (): Promise<PostHogEE> => {
// eslint-disable-next-line import/no-restricted-paths
return import('../../../ee/frontend/exports')
.then((ee) => {
return ee.default()
})
.catch(() => {
return {
enabled: false,
}
})
}
export default posthogEE

View File

@ -1,5 +1,12 @@
// NOTE: All exported items from the EE module _must_ be optionally defined to ensure we work well with FOSS
import { eventWithTime } from '@rrweb/types'
export type PostHogEE = {
enabled: boolean
myTestCode?: () => void
mobileReplay?: {
// defined as unknown while the mobileEventWithTime type is in the ee folder
transformEventToWeb(x: unknown): eventWithTime | null
transformToWeb(x: unknown[]): eventWithTime[]
}
}

View File

@ -3,15 +3,24 @@ import fs from 'fs'
const eeFolderExists = fs.existsSync('ee/frontend/exports.ts')
export const ifEeIt = eeFolderExists ? it : it.skip
export const ifFossIt = !eeFolderExists ? it : it.skip
export const ifEeDescribe = eeFolderExists ? describe : describe.skip
export const ifFossDescribe = !eeFolderExists ? describe : describe.skip
import posthogEE from '@posthog/ee/exports'
import { PostHogEE } from '../../@posthog/ee/types'
describe('ee importing', () => {
let posthogEEModule: PostHogEE
beforeEach(async () => {
posthogEEModule = await posthogEE()
})
ifEeIt('should import actual ee code', () => {
expect(posthogEE.enabled).toBe(true)
expect(posthogEEModule.enabled).toBe(true)
})
ifFossIt('should import actual ee code', () => {
expect(posthogEE.enabled).toBe(false)
expect(posthogEEModule.enabled).toBe(false)
})
})

View File

@ -1,6 +1,8 @@
export async function formatSource(filename: string, source: string): Promise<string> {
// Lazy-load prettier, as it's pretty big and its only use is formatting app source code
// @ts-expect-error
const prettier = (await import('prettier/standalone')).default
// @ts-expect-error
const parserTypeScript = (await import('prettier/parser-typescript')).default
if (filename.endsWith('.json')) {

View File

@ -1,3 +1,6 @@
import { eventWithTime } from '@rrweb/types'
import { prepareRecordingSnapshots } from 'scenes/session-recordings/player/sessionRecordingDataLogic'
import { RecordingSnapshot } from '~/types'
const lineOne =
@ -7,6 +10,23 @@ const lineTwo =
export const snapshotsAsJSONLines = (): string => `${lineOne}\n${lineTwo}\n`
export const convertSnapshotsByWindowId = (snapshotsByWindowId: {
[key: string]: eventWithTime[]
}): RecordingSnapshot[] =>
Object.entries(snapshotsByWindowId).flatMap(([windowId, snapshots]) => {
return snapshots.map((snapshot) => ({
...snapshot,
windowId,
}))
})
export const convertSnapshotsResponse = (
snapshotsByWindowId: { [key: string]: eventWithTime[] },
existingSnapshots?: RecordingSnapshot[]
): RecordingSnapshot[] => {
return prepareRecordingSnapshots(convertSnapshotsByWindowId(snapshotsByWindowId), existingSnapshots)
}
export const sortedRecordingSnapshots = (): { snapshot_data_by_window_id: Record<string, RecordingSnapshot[]> } => {
const sortedRecordingSnapshotsJson = { snapshot_data_by_window_id: {} }

View File

@ -21,7 +21,12 @@ import { PlayerInspectorListItem } from './components/PlayerInspectorListItem'
import { playerInspectorLogic } from './playerInspectorLogic'
function isLocalhost(url: string | null | undefined): boolean {
return !!url && ['localhost', '127.0.0.1'].includes(new URL(url).hostname)
try {
return !!url && ['localhost', '127.0.0.1'].includes(new URL(url).hostname)
} catch (e) {
// for e.g. mobile doesn't have a URL, so we can swallow this and move on
return false
}
}
function EmptyNetworkTab({

View File

@ -1,8 +1,8 @@
import { expectLogic } from 'kea-test-utils'
import { api, MOCK_TEAM_ID } from 'lib/api.mock'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { convertSnapshotsByWindowId } from 'scenes/session-recordings/__mocks__/recording_snapshots'
import {
convertSnapshotsByWindowId,
prepareRecordingSnapshots,
sessionRecordingDataLogic,
} from 'scenes/session-recordings/player/sessionRecordingDataLogic'

View File

@ -1,3 +1,4 @@
import posthogEE from '@posthog/ee/exports'
import { EventType, eventWithTime } from '@rrweb/types'
import { captureException } from '@sentry/react'
import { actions, connect, defaults, kea, key, listeners, path, props, reducers, selectors } from 'kea'
@ -29,22 +30,35 @@ import {
SessionRecordingUsageType,
} from '~/types'
import { PostHogEE } from '../../../../@posthog/ee/types'
import type { sessionRecordingDataLogicType } from './sessionRecordingDataLogicType'
import { createSegments, mapSnapshotsToWindowId } from './utils/segmenter'
const IS_TEST_MODE = process.env.NODE_ENV === 'test'
const BUFFER_MS = 60000 // +- before and after start and end of a recording to query for.
const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[], sessionId: string): RecordingSnapshot[] =>
items.flatMap((l) => {
let postHogEEModule: PostHogEE
const parseEncodedSnapshots = async (
items: (EncodedRecordingSnapshot | string)[],
sessionId: string
): Promise<RecordingSnapshot[]> => {
if (!postHogEEModule) {
postHogEEModule = await posthogEE()
}
return items.flatMap((l) => {
try {
const snapshotLine = typeof l === 'string' ? (JSON.parse(l) as EncodedRecordingSnapshot) : l
const snapshotData = snapshotLine['data']
return snapshotData.map((d: any) => ({
windowId: snapshotLine['window_id'],
...d,
}))
// TODO can we type this better and still have mobileEventWithTime in ee folder?
return snapshotData.map((d: unknown) => {
const snap = postHogEEModule?.mobileReplay?.transformEventToWeb(d) || (d as eventWithTime)
return {
windowId: snapshotLine['window_id'],
...(snap || (d as eventWithTime)),
}
})
} catch (e) {
posthog.capture('session recording had unparseable line', {
sessionId,
@ -54,6 +68,7 @@ const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[], ses
return []
}
})
}
const getHrefFromSnapshot = (snapshot: RecordingSnapshot): string | undefined => {
return (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href
@ -65,7 +80,7 @@ export const prepareRecordingSnapshots = (
): RecordingSnapshot[] => {
const seenHashes: Record<string, (RecordingSnapshot | string)[]> = {}
const prepared = (newSnapshots || [])
return (newSnapshots || [])
.concat(existingSnapshots ? existingSnapshots ?? [] : [])
.filter((snapshot) => {
// For a multitude of reasons, there can be duplicate snapshots in the same recording.
@ -89,27 +104,6 @@ export const prepareRecordingSnapshots = (
return true
})
.sort((a, b) => a.timestamp - b.timestamp)
return prepared
}
export const convertSnapshotsByWindowId = (snapshotsByWindowId: {
[key: string]: eventWithTime[]
}): RecordingSnapshot[] => {
return Object.entries(snapshotsByWindowId).flatMap(([windowId, snapshots]) => {
return snapshots.map((snapshot) => ({
...snapshot,
windowId,
}))
})
}
// Until we change the API to return a simple list of snapshots, we need to convert this ourselves
export const convertSnapshotsResponse = (
snapshotsByWindowId: { [key: string]: eventWithTime[] },
existingSnapshots?: RecordingSnapshot[]
): RecordingSnapshot[] => {
return prepareRecordingSnapshots(convertSnapshotsByWindowId(snapshotsByWindowId), existingSnapshots)
}
const generateRecordingReportDurations = (
@ -346,8 +340,9 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
props.sessionRecordingId,
source.blob_key
)
data.snapshots = prepareRecordingSnapshots(
parseEncodedSnapshots(encodedResponse, props.sessionRecordingId),
await parseEncodedSnapshots(encodedResponse, props.sessionRecordingId),
values.sessionPlayerSnapshotData?.snapshots ?? []
)
} else {
@ -359,7 +354,7 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
const response = await api.recordings.listSnapshots(props.sessionRecordingId, params)
if (response.snapshots) {
data.snapshots = prepareRecordingSnapshots(
parseEncodedSnapshots(response.snapshots, props.sessionRecordingId),
await parseEncodedSnapshots(response.snapshots, props.sessionRecordingId),
values.sessionPlayerSnapshotData?.snapshots ?? []
)
}

View File

@ -1,10 +1,12 @@
import { dayjs } from 'lib/dayjs'
import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json'
import { sortedRecordingSnapshots } from 'scenes/session-recordings/__mocks__/recording_snapshots'
import {
convertSnapshotsResponse,
sortedRecordingSnapshots,
} from 'scenes/session-recordings/__mocks__/recording_snapshots'
import { RecordingSnapshot } from '~/types'
import { convertSnapshotsResponse } from '../sessionRecordingDataLogic'
import { createSegments } from './segmenter'
describe('segmenter', () => {

View File

@ -309,7 +309,7 @@ export async function buildOrWatch(config) {
if (isDev) {
chokidar
.watch(path.resolve(absWorkingDir, 'src'), {
.watch([path.resolve(absWorkingDir, 'src'), path.resolve(absWorkingDir, '../ee/frontend')], {
ignored: /.*(Type|\.test\.stories)\.[tj]sx$/,
ignoreInitial: true,
})

View File

@ -1,4 +1,5 @@
import type { Config } from 'jest'
import fs from 'fs'
process.env.TZ = process.env.TZ || 'UTC'
@ -8,6 +9,14 @@ process.env.TZ = process.env.TZ || 'UTC'
*/
const esmModules = ['query-selector-shadow-dom', 'react-syntax-highlighter', '@react-hook', '@medv']
const eeFolderExists = fs.existsSync('ee/frontend/exports.ts')
function rootDirectories() {
const rootDirectories = ['<rootDir>/frontend/src']
if (eeFolderExists) {
rootDirectories.push('<rootDir>/ee/frontend')
}
return rootDirectories
}
const config: Config = {
// All imported modules in your tests should be mocked automatically
@ -85,16 +94,16 @@ const config: Config = {
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'^.+\\.(css|less|scss|svg|png|lottie)$': '<rootDir>/test/mocks/styleMock.js',
'^~/(.*)$': '<rootDir>/$1',
'^@posthog/lemon-ui(|/.*)$': '<rootDir>/../@posthog/lemon-ui/src/$1',
'^@posthog/apps-common(|/.*)$': '<rootDir>/../@posthog/apps-common/src/$1',
'^@posthog/ee/exports': ['<rootDir>/../../ee/frontend/exports', '<rootDir>/../@posthog/ee/exports'],
'^lib/(.*)$': '<rootDir>/lib/$1',
'^scenes/(.*)$': '<rootDir>/scenes/$1',
'^.+\\.(css|less|scss|svg|png|lottie)$': '<rootDir>/frontend/src/test/mocks/styleMock.js',
'^~/(.*)$': '<rootDir>/frontend/src/$1',
'^@posthog/lemon-ui(|/.*)$': '<rootDir>/frontend/@posthog/lemon-ui/src/$1',
'^@posthog/apps-common(|/.*)$': '<rootDir>/frontend/@posthog/apps-common/src/$1',
'^@posthog/ee/exports': ['<rootDir>/ee/frontend/exports', '<rootDir>/frontend/@posthog/ee/exports'],
'^lib/(.*)$': '<rootDir>/frontend/src/lib/$1',
'^scenes/(.*)$': '<rootDir>/frontend/src/scenes/$1',
'^antd/es/(.*)$': 'antd/lib/$1',
'^react-virtualized/dist/es/(.*)$': 'react-virtualized/dist/commonjs/$1',
d3: '<rootDir>/../../node_modules/d3/dist/d3.min.js',
d3: '<rootDir>/node_modules/d3/dist/d3.min.js',
'^d3-(.*)$': `d3-$1/dist/d3-$1`,
},
@ -129,22 +138,19 @@ const config: Config = {
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
rootDir: 'frontend/src',
modulePaths: ['<rootDir>/'],
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
roots: rootDirectories(),
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: ['../../jest.setup.ts'],
setupFiles: ['<rootDir>/jest.setup.ts'],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['../../jest.setupAfterEnv.ts', 'givens/setup', './mocks/jest.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setupAfterEnv.ts', 'givens/setup', '<rootDir>/frontend/src/mocks/jest.ts'],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,

View File

@ -63,7 +63,10 @@
"build-storybook": "storybook build",
"dev:migrate:postgres": "export DEBUG=1 && source env/bin/activate && python manage.py migrate",
"dev:migrate:clickhouse": "export DEBUG=1 && source env/bin/activate && python manage.py migrate_clickhouse",
"prepare": "husky install"
"prepare": "husky install",
"mobile-replay:web:schema:build:json": "ts-json-schema-generator -f tsconfig.json --path 'node_modules/@rrweb/types/dist/index.d.ts' --type 'eventWithTime' --expose all --no-top-ref --out ee/frontend/mobile-replay/schema/web/rr-web-schema.json && prettier --write ee/frontend/mobile-replay/schema/web/rr-web-schema.json",
"mobile-replay:mobile:schema:build:json": "ts-json-schema-generator -f tsconfig.json --path 'ee/frontend/mobile-replay/mobile.types.ts' --type 'mobileEventWithTime' --expose all --no-top-ref --out ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json && prettier --write ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json",
"mobile-replay:schema:build:json": "pnpm mobile-replay:web:schema:build:json && pnpm mobile-replay:mobile:schema:build:json"
},
"dependencies": {
"@ant-design/icons": "^4.7.0",
@ -95,6 +98,7 @@
"@types/md5": "^2.3.0",
"@types/react-transition-group": "^4.4.5",
"@types/react-virtualized": "^9.21.23",
"ajv": "^8.12.0",
"antd": "^4.17.1",
"antd-dayjs-webpack-plugin": "^1.0.6",
"babel-preset-nano-react-app": "^0.1.0",
@ -246,6 +250,7 @@
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jest": "^27.4.3",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-posthog": "link:./eslint-rules",
@ -258,7 +263,7 @@
"history": "^5.0.1",
"html-webpack-harddisk-plugin": "^2.0.0",
"html-webpack-plugin": "^5.5.3",
"jest": "^29.3.1",
"jest": "^29.7.0",
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^29.3.1",
"jest-image-snapshot": "^6.1.0",
@ -286,7 +291,7 @@
"stylelint-order": "^6.0.3",
"sucrase": "^3.29.0",
"timekeeper": "^2.2.0",
"ts-json-schema-generator": "^1.2.0",
"ts-json-schema-generator": "^1.4.0",
"ts-node": "^10.9.1",
"typescript": "~4.9.5",
"webpack": "^5.88.2",

File diff suppressed because it is too large Load Diff

View File

@ -31,8 +31,10 @@
"noUnusedParameters": true, // Report errors on unused parameters
"experimentalDecorators": true, // Enables experimental support for ES decorators
"noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statement
// TODO this will be deprecated in TS 5. and we have _many_ of these
"suppressImplicitAnyIndexErrors": true, // Index objects by number
"lib": ["dom", "es2019"]
"lib": ["dom", "es2019"],
"ignoreDeprecations": "5.0"
},
"include": ["frontend/**/*", ".storybook/**/*", "ee/frontend/**/*"],
"exclude": ["frontend/dist/**/*"],