diff --git a/.babelrc b/.babelrc index e2dc58a93..7fe9a63a1 100644 --- a/.babelrc +++ b/.babelrc @@ -1,103 +1,13 @@ { - "presets": [ - [ - "@babel/preset-env", - { - "debug": true, - "corejs": "3.38", - "useBuiltIns": "usage", - "include": [ - "es.array.from" - ], - "exclude": [ - "es.array.at", - "es.array.concat", - "es.array.find", - "es.array.find-index", - "es.array.fill", - "es.array.filter", - "es.array.flat-map", - "es.array.includes", - "es.array.iterator", - "es.array.join", - "es.array.map", - "es.array.slice", - "es.array.splice", - "es.array.sort", - "es.array.unscopables.flat-map", - "es.array-buffer.constructor", - "es.error.cause", - "es.function.name", - "es.global-this", - "es.json.stringify", - "es.math.trunc", - "es.math.sign", - "es.map", - "es.number.constructor", - "es.number.is-integer", - "es.number.is-nan", - "es.number.to-fixed", - "es.object.assign", - "es.object.entries", - "es.object.get-own-property-descriptor", - "es.object.get-own-property-names", - "es.object.keys", - "es.object.to-string", - "es.object.values", - "es.promise", - "es.promise.finally", - "es.reflect.get", - "es.reflect.to-string-tag", - "es.regexp.*", - "es.set", - "es.string.ends-with", - "es.string.includes", - "es.string.iterator", - "es.string.link", - "es.string.match", - "es.string.match-all", - "es.string.repeat", - "es.string.replace", - "es.string.search", - "es.string.starts-with", - "es.string.split", - "es.string.sub", - "es.string.trim", - "es.symbol", - "es.symbol.description", - "es.typed-array.*", - "es.weak-map", - "es.weak-set", - "esnext.global-this", - "esnext.string.match-all", - "esnext.typed-array.*", - "web.atob", - "web.dom-collections.for-each", - "web.dom-collections.iterator", - "web.dom-exception.constructor", - "web.dom-exception.stack", - "web.dom-exception.to-string-tag", - "web.url", - "web.url-search-params", - "web.url.to-json" - ], - "targets": ">0.5%, last 2 versions, Firefox ESR, not dead, IE 11" - } - ], - [ - "@babel/typescript", - { - "jsxPragma": "h" - } + "presets": ["@babel/env", ["@babel/typescript", { "jsxPragma": "h" }]], + "plugins": [ + "@babel/plugin-transform-nullish-coalescing-operator", + [ + "@babel/transform-react-jsx", + { + "runtime": "automatic", + "importSource": "preact" + } + ] ] - ], - "plugins": [ - [ - "@babel/transform-react-jsx", - { - "runtime": "automatic", - "importSource": "preact" - } - ] - ] } diff --git a/.github/workflows/ssr-es-check.yml b/.github/workflows/es-check.yml similarity index 57% rename from .github/workflows/ssr-es-check.yml rename to .github/workflows/es-check.yml index 18ad939e5..16e7ebed5 100644 --- a/.github/workflows/ssr-es-check.yml +++ b/.github/workflows/es-check.yml @@ -1,4 +1,4 @@ -name: Server-side rendering and ES5 +name: ES5 support check on: - pull_request @@ -19,8 +19,5 @@ jobs: - run: pnpm install && pnpm build - - name: Run es-check to check if our bundle is ES5 compatible - run: npx es-check@6.1.1 es5 dist/{array,main}.js - - - name: Require module via node - run: cd dist; node -e "require('./main')" + - name: Run es-check to check if our ie11 bundle is ES5 compatible + run: npx es-check@7.2.1 es5 dist/array.full.es5.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc9478de..0b5299ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.172.0 - 2024-10-17 + +- chore: build an es5 bundle and move main to es6 (#1480) + +## 1.171.0 - 2024-10-17 + +- feat: start session recording on url trigger (#1451) +- chore: babel targets in rollup config (#1479) + ## 1.170.1 - 2024-10-16 - feat: add stack to stacktraceless "exceptions" (#1472) diff --git a/package.json b/package.json index df196a3f9..d5ba60340 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.170.1", + "version": "1.172.0", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", @@ -46,6 +46,7 @@ "devDependencies": { "@babel/core": "7.18.9", "@babel/plugin-syntax-decorators": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.8", "@babel/plugin-transform-react-jsx": "^7.23.4", "@babel/preset-env": "7.18.9", "@babel/preset-typescript": "^7.18.6", @@ -54,9 +55,9 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.4", - "@rollup/plugin-typescript": "^11.1.6", + "@rollup/plugin-typescript": "^12.1.1", "@rrweb/types": "2.0.0-alpha.13", "@sentry/types": "8.7.0", "@testing-library/dom": "^9.3.0", @@ -100,8 +101,7 @@ "preact-render-to-string": "^6.3.1", "prettier": "^2.7.1", "rollup": "^4.24.0", - "rollup-plugin-dts": "^6.1.0", - "rollup-plugin-ts": "^3.4.5", + "rollup-plugin-dts": "^6.1.1", "rollup-plugin-visualizer": "^5.12.0", "rrweb": "2.0.0-alpha.13", "rrweb-snapshot": "2.0.0-alpha.13", @@ -122,7 +122,7 @@ ] }, "browserslist": [ - ">0.5%, last 2 versions, Firefox ESR, not dead, IE 11" + ">0.5%, last 2 versions, Firefox ESR, not dead" ], "pnpm": { "patchedDependencies": { diff --git a/rollup.config.js b/rollup.config.js index ab1264bef..648cdd8bb 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -5,104 +5,37 @@ import typescript from '@rollup/plugin-typescript' import { dts } from 'rollup-plugin-dts' import terser from '@rollup/plugin-terser' import { visualizer } from 'rollup-plugin-visualizer' +import commonjs from '@rollup/plugin-commonjs' import fs from 'fs' import path from 'path' -import commonjs from '@rollup/plugin-commonjs' -const plugins = [ +const plugins = (es5) => [ json(), resolve({ browser: true }), + typescript({ sourceMap: true, outDir: './dist' }), commonjs(), - typescript({ sourceMap: true }), babel({ extensions: ['.js', '.jsx', '.ts', '.tsx'], babelHelpers: 'bundled', + plugins: ['@babel/plugin-transform-nullish-coalescing-operator'], presets: [ [ '@babel/preset-env', { - debug: true, - corejs: '3.38', - useBuiltIns: 'usage', - include: ['es.array.from'], - exclude: [ - 'es.array.at', - 'es.array.concat', - 'es.array.find', - 'es.array.find-index', - 'es.array.fill', - 'es.array.filter', - 'es.array.flat-map', - 'es.array.includes', - 'es.array.iterator', - 'es.array.join', - 'es.array.map', - 'es.array.slice', - 'es.array.splice', - 'es.array.sort', - 'es.array.unscopables.flat-map', - 'es.array-buffer.constructor', - 'es.error.cause', - 'es.function.name', - 'es.global-this', - 'es.json.stringify', - 'es.math.trunc', - 'es.math.sign', - 'es.map', - 'es.number.constructor', - 'es.number.is-integer', - 'es.number.is-nan', - 'es.number.to-fixed', - 'es.object.assign', - 'es.object.entries', - 'es.object.get-own-property-descriptor', - 'es.object.get-own-property-names', - 'es.object.keys', - 'es.object.to-string', - 'es.object.values', - 'es.promise', - 'es.promise.finally', - 'es.reflect.get', - 'es.reflect.to-string-tag', - 'es.regexp.*', - 'es.set', - 'es.string.ends-with', - 'es.string.includes', - 'es.string.iterator', - 'es.string.link', - 'es.string.match', - 'es.string.match-all', - 'es.string.repeat', - 'es.string.replace', - 'es.string.search', - 'es.string.starts-with', - 'es.string.split', - 'es.string.sub', - 'es.string.trim', - 'es.symbol', - 'es.symbol.description', - 'es.typed-array.*', - 'es.weak-map', - 'es.weak-set', - 'esnext.global-this', - 'esnext.string.match-all', - 'esnext.typed-array.*', - 'web.atob', - 'web.dom-collections.for-each', - 'web.dom-collections.iterator', - 'web.dom-exception.constructor', - 'web.dom-exception.stack', - 'web.dom-exception.to-string-tag', - 'web.url', - 'web.url-search-params', - 'web.url.to-json', - ], - targets: '>0.5%, last 2 versions, Firefox ESR, not dead, IE 11', + targets: es5 + ? '>0.5%, last 2 versions, Firefox ESR, not dead, IE 11' + : '>0.5%, last 2 versions, Firefox ESR, not dead', }, ], ], }), - terser({ toplevel: true }), + terser({ + toplevel: true, + compress: { + // 5 is the default if unspecified + ecma: es5 ? 5 : 6, + }, + }), ] const entrypoints = fs.readdirSync('./src/entrypoints') @@ -122,6 +55,8 @@ const entrypointTargets = entrypoints.map((file) => { const fileName = fileParts.join('.') + const pluginsForThisFile = plugins(fileName.includes('es5')) + // we're allowed to console log in this file :) // eslint-disable-next-line no-console console.log(`Building ${fileName} in ${format} format`) @@ -145,7 +80,7 @@ const entrypointTargets = entrypoints.map((file) => { ...(format === 'cjs' ? { exports: 'auto' } : {}), }, ], - plugins: [...plugins, visualizer({ filename: `bundle-stats-${fileName}.html` })], + plugins: [...pluginsForThisFile, visualizer({ filename: `bundle-stats-${fileName}.html` })], } }) diff --git a/src/constants.ts b/src/constants.ts index fc3154879..ac85e6936 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -24,6 +24,8 @@ export const SESSION_RECORDING_SAMPLE_RATE = '$replay_sample_rate' export const SESSION_RECORDING_MINIMUM_DURATION = '$replay_minimum_duration' export const SESSION_ID = '$sesid' export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled' +export const SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION = '$session_recording_url_trigger_activated_session' +export const SESSION_RECORDING_URL_TRIGGER_STATUS = '$session_recording_url_trigger_status' export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags' export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features' export const STORED_PERSON_PROPERTIES_KEY = '$stored_person_properties' diff --git a/src/entrypoints/array.full.es5.ts b/src/entrypoints/array.full.es5.ts new file mode 100644 index 000000000..b1a76efc8 --- /dev/null +++ b/src/entrypoints/array.full.es5.ts @@ -0,0 +1,11 @@ +// a straight copy of the array.full.ts entrypoint, +// but will have different config when passed through rollup +// to allow es5/IE11 support + +// it doesn't include recorder which doesn't support IE11, +// and it doesn't include web-vitals which doesn't support IE11 + +import './surveys' +import './exception-autocapture' +import './tracing-headers' +import './array.no-external' diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 0f0f65bf1..95420aa5f 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -6,6 +6,8 @@ import { SESSION_RECORDING_MINIMUM_DURATION, SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, SESSION_RECORDING_SAMPLE_RATE, + SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, + SESSION_RECORDING_URL_TRIGGER_STATUS, } from '../../constants' import { estimateSize, @@ -16,7 +18,14 @@ import { truncateLargeConsoleLogs, } from './sessionrecording-utils' import { PostHog } from '../../posthog-core' -import { DecideResponse, FlagVariant, NetworkRecordOptions, NetworkRequest, Properties } from '../../types' +import { + DecideResponse, + FlagVariant, + NetworkRecordOptions, + NetworkRequest, + Properties, + SessionRecordingUrlTrigger, +} from '../../types' import { customEvent, EventType, @@ -44,7 +53,8 @@ type SessionStartReason = const BASE_ENDPOINT = '/s/' -const FIVE_MINUTES = 1000 * 60 * 5 +const ONE_MINUTE = 1000 * 60 +const FIVE_MINUTES = ONE_MINUTE * 5 const TWO_SECONDS = 2000 export const RECORDING_IDLE_THRESHOLD_MS = FIVE_MINUTES const ONE_KB = 1024 @@ -68,6 +78,9 @@ const ACTIVE_SOURCES = [ IncrementalSource.Drag, ] +const TRIGGER_STATUSES = ['trigger_activated', 'trigger_pending', 'trigger_disabled'] as const +type TriggerStatus = typeof TRIGGER_STATUSES[number] + /** * Session recording starts in buffering mode while waiting for decide response * Once the response is received it might be disabled, active or sampled @@ -233,6 +246,8 @@ export class SessionRecording { // then we can manually track href changes private _lastHref?: string + private _urlTriggers: SessionRecordingUrlTrigger[] = [] + // Util to help developers working on this feature manually override _forceAllowLocalhostNetworkCapture = false @@ -258,7 +273,11 @@ export class SessionRecording { } private get fullSnapshotIntervalMillis(): number { - return this.instance.config.session_recording?.full_snapshot_interval_millis || FIVE_MINUTES + if (this.urlTriggerStatus === 'trigger_pending') { + return ONE_MINUTE + } + + return this.instance.config.session_recording?.full_snapshot_interval_millis ?? FIVE_MINUTES } private get isSampled(): boolean | null { @@ -348,6 +367,10 @@ export class SessionRecording { return 'buffering' } + if (this.urlTriggerStatus === 'trigger_pending') { + return 'buffering' + } + if (isBoolean(this.isSampled)) { return this.isSampled ? 'sampled' : 'disabled' } else { @@ -355,6 +378,34 @@ export class SessionRecording { } } + private get urlTriggerStatus(): TriggerStatus { + if (this.receivedDecide && this._urlTriggers.length === 0) { + return 'trigger_disabled' + } + + const currentStatus = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_STATUS) + const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + + if (currentTriggerSession !== this.sessionId) { + this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) + return 'trigger_pending' + } + + if (TRIGGER_STATUSES.includes(currentStatus)) { + return currentStatus as TriggerStatus + } + + return 'trigger_pending' + } + + private set urlTriggerStatus(status: TriggerStatus) { + this.instance?.persistence?.register({ + [SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION]: this.sessionId, + [SESSION_RECORDING_URL_TRIGGER_STATUS]: status, + }) + } + constructor(private readonly instance: PostHog) { this._captureStarted = false this._endpoint = BASE_ENDPOINT @@ -438,6 +489,9 @@ export class SessionRecording { this._onSessionIdListener = this.sessionManager.onSessionId((sessionId, windowId, changeReason) => { if (changeReason) { this._tryAddCustomEvent('$session_id_change', { sessionId, windowId, changeReason }) + + this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) } }) } @@ -556,6 +610,10 @@ export class SessionRecording { }) } + if (response.sessionRecording?.urlTriggers) { + this._urlTriggers = response.sessionRecording.urlTriggers + } + this.receivedDecide = true this.startIfEnabledOrStop() } @@ -921,11 +979,19 @@ export class SessionRecording { this._pageViewFallBack() } + // Check if the URL matches any trigger patterns + this._checkUrlTrigger() + // we're processing a full snapshot, so we should reset the timer if (rawEvent.type === EventType.FullSnapshot) { this._scheduleFullSnapshot() } + // Clear the buffer if waiting for a trigger, and only keep data from after the current full snapshot + if (rawEvent.type === EventType.FullSnapshot && this.urlTriggerStatus === 'trigger_pending') { + this.clearBuffer() + } + const throttledEvent = this.mutationRateLimiter ? this.mutationRateLimiter.throttleMutations(rawEvent) : rawEvent @@ -1102,6 +1168,36 @@ export class SessionRecording { }) } + private _checkUrlTrigger() { + if (typeof window === 'undefined' || !window.location.href) { + return + } + + const url = window.location.href + + if ( + this._urlTriggers.some((trigger) => { + switch (trigger.matching) { + case 'regex': + return new RegExp(trigger.url).test(url) + default: + return false + } + }) + ) { + this._activateUrlTrigger() + } + } + + private _activateUrlTrigger() { + if (this.urlTriggerStatus === 'trigger_pending') { + this.urlTriggerStatus = 'trigger_activated' + this._tryAddCustomEvent('url trigger activated', {}) + this._flushBuffer() + logger.info(LOGGER_PREFIX + ' recording triggered by URL pattern match') + } + } + /** * this ignores the linked flag config and causes capture to start * (if recording would have started had the flag been received i.e. it does not override other config). diff --git a/src/types.ts b/src/types.ts index bd28662cf..268265f18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -396,6 +396,7 @@ export interface DecideResponse { canvasQuality?: string | null linkedFlag?: string | FlagVariant | null networkPayloadCapture?: Pick + urlTriggers?: SessionRecordingUrlTrigger[] } surveys?: boolean toolbarParams: ToolbarParams @@ -591,3 +592,27 @@ export type ErrorMetadata = { // but provided as an array of literal types, so we can constrain the level below export const severityLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'] as const export declare type SeverityLevel = typeof severityLevels[number] + +export interface ErrorProperties { + $exception_type: string + $exception_message: string + $exception_level: SeverityLevel + $exception_source?: string + $exception_lineno?: number + $exception_colno?: number + $exception_DOMException_code?: string + $exception_is_synthetic?: boolean + $exception_stack_trace_raw?: string + $exception_handled?: boolean + $exception_personURL?: string +} + +export interface ErrorConversions { + errorToProperties: (args: ErrorEventArgs) => ErrorProperties + unhandledRejectionToProperties: (args: [ev: PromiseRejectionEvent]) => ErrorProperties +} + +export interface SessionRecordingUrlTrigger { + url: string + matching: 'regex' +} diff --git a/testcafe/helpers.js b/testcafe/helpers.js index e321dc21c..5f3312298 100644 --- a/testcafe/helpers.js +++ b/testcafe/helpers.js @@ -33,7 +33,10 @@ export const staticFilesMock = RequestMock() .onRequestTo(/array.full.js/) .respond((req, res) => { // eslint-disable-next-line no-undef - const arrayjs = fs.readFileSync(path.resolve(__dirname, '../dist/array.full.js')) + const ENV_BROWSER = process.env.BROWSER + const fileToRead = ENV_BROWSER === 'browserstack:ie' ? '../dist/array.full.es5.js' : '../dist/array.full.js' + // eslint-disable-next-line no-undef + const arrayjs = fs.readFileSync(path.resolve(__dirname, fileToRead)) res.setBody(arrayjs) }) .onRequestTo(/playground/)