diff --git a/.changeset/config.json b/.changeset/config.json index 2dab27f07..19045155c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", "changelog": "@changesets/cli/changelog", - "commit": true, + "commit": ["@changesets/cli/commit", { "skipCI": false }], "fixed": [], "linked": [], "access": "public", diff --git a/.changeset/fruity-parents-float.md b/.changeset/fruity-parents-float.md new file mode 100644 index 000000000..f9e26ba2c --- /dev/null +++ b/.changeset/fruity-parents-float.md @@ -0,0 +1,7 @@ +--- +'@launchdarkly/session-replay': minor +'@launchdarkly/observability': minor +'highlight.run': minor +--- + +refactors highlight.run SDK into plugins consumed by new @launchdarkly packages diff --git a/e2e/react-router/package.json b/e2e/react-router/package.json index bf05c0982..ffe9cb7e4 100644 --- a/e2e/react-router/package.json +++ b/e2e/react-router/package.json @@ -11,7 +11,10 @@ }, "dependencies": { "@highlight-run/react": "workspace:*", - "highlight.run": "workspace:*", + "@launchdarkly/js-client-sdk": "^0.6.0", + "@launchdarkly/observability": "workspace:*", + "@launchdarkly/session-replay": "workspace:*", + "launchdarkly-js-client-sdk": "^3.7.0-beta.1", "localforage": "^1.10.0", "match-sorter": "^6.3.1", "react": "^19.0.0", diff --git a/e2e/react-router/src/routes/root.tsx b/e2e/react-router/src/routes/root.tsx index 7e4fe3c0d..7efbfce6f 100644 --- a/e2e/react-router/src/routes/root.tsx +++ b/e2e/react-router/src/routes/root.tsx @@ -1,17 +1,109 @@ -import { H } from 'highlight.run' +// import { initialize as init4 } from '@launchdarkly/js-client-sdk' +import { initialize as init3 } from 'launchdarkly-js-client-sdk' +import Observability, { LDObserve } from '@launchdarkly/observability' +import SessionReplay, { LDRecord } from '@launchdarkly/session-replay' +import { useEffect, useRef, useState } from 'react' +// import { LD } from '@launchdarkly/browser' -H.init('1', { - // Get your project ID from https://app.highlight.io/setup - networkRecording: { - enabled: true, - recordHeadersAndBody: true, +const client = init3( + '66d9d3c255856f0fa8fd62d0', + { key: 'unknown' }, + { + // Not including plugins at all would be equivalent to the current LaunchDarkly SDK. + plugins: [ + new Observability('1', { + networkRecording: { + enabled: true, + recordHeadersAndBody: true, + }, + serviceName: 'ryan-test', + backendUrl: 'https://pub.observability.ld-stg.launchdarkly.com', + otlpEndpoint: + 'https://otel.observability.ld-stg.launchdarkly.com', + }), + new SessionReplay('1', { + serviceName: 'ryan-test', + backendUrl: 'https://pub.observability.ld-stg.launchdarkly.com', + }), // Could be omitted for customers who cannot use session replay. + ], }, -}) +) export default function Root() { + const fillColor = 'lightblue' + const canvasRef = useRef(null) + const [flags, setFlags] = useState() + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d')! + // Fill the entire canvas with the specified color + ctx.fillStyle = fillColor + ctx.fillRect(0, 0, canvas.width, canvas.height) + }, [fillColor]) + return ( ) } diff --git a/package.json b/package.json index a06a9f9dc..24621bc1d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint": "yarn run turbo lint --filter=!rrweb --filter=!rrvideo --filter=!@rrweb/rrweb-plugin-console-record --filter=!@rrweb/_monorepo --filter=!@rrweb/all --filter=!@rrweb/record --filter=!@rrweb/replay --filter=!@rrweb/types --filter=!@rrweb/packer --filter=!@rrweb/utils --filter=!@rrweb/web-extension --filter=!rrweb-snapshot --filter=!rrweb-player --filter=!rrdom --filter=!rrdom-nodejs --filter=!e2e-react-native", "prepare": "husky", "preinstall": "git submodule update --init --recursive || true", - "publish": "yarn workspaces foreach -R --no-private --from '@launchdarkly/*' npm publish --access public --tolerate-republish", + "publish": "yarn workspaces foreach -A --include '@launchdarkly/*' --exclude '@launchdarkly/observability-sdk' npm publish --access public --tolerate-republish", "publish:highlight": "yarn workspaces foreach -R --no-private --from '@highlight-run/*' npm publish --access public --tolerate-republish && yarn workspace highlight.run npm publish --access public --tolerate-republish", "test": "yarn turbo run test --filter=!rrweb --filter=!rrvideo --filter=!@rrweb/rrweb-plugin-console-record --filter=!@rrweb/_monorepo --filter=!@rrweb/all --filter=!@rrweb/record --filter=!@rrweb/replay --filter=!@rrweb/types --filter=!@rrweb/packer --filter=!@rrweb/utils --filter=!@rrweb/web-extension --filter=!rrweb-snapshot --filter=!rrweb-player --filter=!rrdom --filter=!rrdom-nodejs --filter=!nextjs" }, diff --git a/sdk/@launchdarkly/observability/README.md b/sdk/@launchdarkly/observability/README.md index a05ca444a..2bcab7d7b 100644 --- a/sdk/@launchdarkly/observability/README.md +++ b/sdk/@launchdarkly/observability/README.md @@ -1,10 +1,9 @@ # LaunchDarkly JavaScript Observability SDK for Browsers -[![NPM][browser-sdk-npm-badge]][browser-sdk-npm-link] -[![Actions Status][browser-sdk-ci-badge]][browser-sdk-ci] -[![Documentation][browser-sdk-ghp-badge]][browser-sdk-ghp-link] -[![NPM][browser-sdk-dm-badge]][browser-sdk-npm-link] -[![NPM][browser-sdk-dt-badge]][browser-sdk-npm-link] +[![NPM][o11y-sdk-npm-badge]][o11y-sdk-npm-link] +[![Actions Status][o11y-sdk-ci-badge]][o11y-sdk-ci] +[![NPM][o11y-sdk-dm-badge]][o11y-sdk-npm-link] +[![NPM][o11y-sdk-dt-badge]][o11y-sdk-npm-link] # ⛔️⛔️⛔️⛔️ @@ -13,9 +12,9 @@ # ☝️☝️☝️☝️☝️☝️ - + +Update your web app entrypoint. +```tsx +import { initialize } from 'launchdarkly-js-client-sdk' +import Observability, { LDObserve } from '@launchdarkly/observability' + +const client = init3( + '', + { key: 'authenticated-user@example.com' }, + { + // Not including plugins at all would be equivalent to the current LaunchDarkly SDK. + plugins: [ + new Observability('', { + networkRecording: { + enabled: true, + recordHeadersAndBody: true, + }, + }), + ], + }, +) + +``` ## Getting started @@ -49,11 +70,9 @@ LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates -[browser-sdk-ci-badge]: https://github.com/launchdarkly/observability-sdk/actions/workflows/browser.yml/badge.svg -[browser-sdk-ci]: https://github.com/launchdarkly/observability-sdk/actions/workflows/browser.yml -[browser-sdk-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/observability.svg?style=flat-square -[browser-sdk-npm-link]: https://www.npmjs.com/package/@launchdarkly/observability -[browser-sdk-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 -[browser-sdk-ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/browser/docs/ -[browser-sdk-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/observability.svg?style=flat-square -[browser-sdk-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/observability.svg?style=flat-square +[o11y-sdk-ci-badge]: https://github.com/launchdarkly/observability-sdk/actions/workflows/turbo.yml/badge.svg +[o11y-sdk-ci]: https://github.com/launchdarkly/observability-sdk/actions/workflows/turbo.yml +[o11y-sdk-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/observability.svg?style=flat-square +[o11y-sdk-npm-link]: https://www.npmjs.com/package/@launchdarkly/observability +[o11y-sdk-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/observability.svg?style=flat-square +[o11y-sdk-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/observability.svg?style=flat-square diff --git a/sdk/@launchdarkly/observability/package.json b/sdk/@launchdarkly/observability/package.json index 9d414c719..7c857647a 100644 --- a/sdk/@launchdarkly/observability/package.json +++ b/sdk/@launchdarkly/observability/package.json @@ -25,20 +25,21 @@ "url": "https://github.com/launchdarkly/observability-sdk.git" } }, + "scripts": { + "typegen": "tsc", + "build": "vite build" + }, + "devDependencies": { + "highlight.run": "workspace:*", + "rollup-plugin-visualizer": "^5.14.0", + "typescript": "^5.8.3", + "vite": "^6.3.4", + "vitest": "^3.1.2" + }, "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "unpkg": "./dist/index.umd.js", - "jsdelivr": "./dist/index.umd.js", + "main": "./dist/observability.js", + "module": "./dist/observability.js", "types": "./dist/index.d.ts", - "exports": { - "types": "./dist/index.d.ts", - "unpkg": "./dist/index.umd.js", - "jsdelivr": "./dist/index.umd.js", - "import": "./dist/index.js", - "require": "./dist/index.js", - "default": "./dist/index.js" - }, "files": [ "dist" ], diff --git a/sdk/@launchdarkly/observability/src/index.ts b/sdk/@launchdarkly/observability/src/index.ts new file mode 100644 index 000000000..48c62a275 --- /dev/null +++ b/sdk/@launchdarkly/observability/src/index.ts @@ -0,0 +1,2 @@ +export { Observe as default } from 'highlight.run/observe' +export { LDObserve } from 'highlight.run/ld/observe' diff --git a/sdk/@launchdarkly/observability/tsconfig.json b/sdk/@launchdarkly/observability/tsconfig.json new file mode 100644 index 000000000..f9e585e85 --- /dev/null +++ b/sdk/@launchdarkly/observability/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "outDir": "dist", + "allowJs": true, + "allowSyntheticDefaultImports": true, + "baseUrl": "src", + "declaration": true, + "declarationDir": "dist", + "downlevelIteration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ESNext", + "rootDir": "src", + "types": ["@types/chrome", "@types/node", "vitest/globals"], + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["**/src/**/*.test.tsx"], + "files": ["package.json"] +} diff --git a/sdk/@launchdarkly/observability/vite.config.ts b/sdk/@launchdarkly/observability/vite.config.ts new file mode 100644 index 000000000..9238d9dbd --- /dev/null +++ b/sdk/@launchdarkly/observability/vite.config.ts @@ -0,0 +1,37 @@ +// vite.config.ts +import { resolve as resolvePath } from 'path' +import { defineConfig } from 'vite' +import { visualizer } from 'rollup-plugin-visualizer' + +export default defineConfig(({}) => { + return { + build: { + target: 'esnext', + lib: { + formats: ['es'], + entry: resolvePath(__dirname, 'src/index.ts'), + }, + minify: true, + sourcemap: true, + emptyOutDir: false, + rollupOptions: { + treeshake: 'smallest', + output: { + exports: 'named', + }, + cache: false, + }, + }, + plugins: + process.env.VISUALIZE_BUNDLE === 'true' + ? [ + visualizer({ + gzipSize: true, + brotliSize: true, + sourcemap: true, + open: true, + }), + ] + : [], + } +}) diff --git a/sdk/@launchdarkly/session-replay/README.md b/sdk/@launchdarkly/session-replay/README.md index 59bf16d5e..11de19c8c 100644 --- a/sdk/@launchdarkly/session-replay/README.md +++ b/sdk/@launchdarkly/session-replay/README.md @@ -1,10 +1,9 @@ # LaunchDarkly JavaScript Session Replay SDK for Browsers -[![NPM][browser-sdk-npm-badge]][browser-sdk-npm-link] -[![Actions Status][browser-sdk-ci-badge]][browser-sdk-ci] -[![Documentation][browser-sdk-ghp-badge]][browser-sdk-ghp-link] -[![NPM][browser-sdk-dm-badge]][browser-sdk-npm-link] -[![NPM][browser-sdk-dt-badge]][browser-sdk-npm-link] +[![NPM][o11y-sdk-npm-badge]][o11y-sdk-npm-link] +[![Actions Status][o11y-sdk-ci-badge]][o11y-sdk-ci] +[![NPM][o11y-sdk-dm-badge]][o11y-sdk-npm-link] +[![NPM][o11y-sdk-dt-badge]][o11y-sdk-npm-link] # ⛔️⛔️⛔️⛔️ @@ -13,9 +12,9 @@ # ☝️☝️☝️☝️☝️☝️ - + +Update your web app entrypoint. +```tsx +import { initialize } from 'launchdarkly-js-client-sdk' +import SessionReplay, { LDRecord } from '@launchdarkly/session-replay' + +const client = init3( + '', + { key: 'authenticated-user@example.com' }, + { + // Not including plugins at all would be equivalent to the current LaunchDarkly SDK. + plugins: [ + new SessionReplay('', { + serviceName: 'example-svc', + }), // Could be omitted for customers who cannot use session replay. + ], + }, +) + +``` ## Getting started @@ -49,11 +67,9 @@ LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates -[browser-sdk-ci-badge]: https://github.com/launchdarkly/observability-sdk/actions/workflows/browser.yml/badge.svg -[browser-sdk-ci]: https://github.com/launchdarkly/observability-sdk/actions/workflows/browser.yml -[browser-sdk-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/session-replay.svg?style=flat-square -[browser-sdk-npm-link]: https://www.npmjs.com/package/@launchdarkly/session-replay -[browser-sdk-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 -[browser-sdk-ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/browser/docs/ -[browser-sdk-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/session-replay.svg?style=flat-square -[browser-sdk-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/session-replay.svg?style=flat-square +[o11y-sdk-ci-badge]: https://github.com/launchdarkly/observability-sdk/actions/workflows/turbo.yml/badge.svg +[o11y-sdk-ci]: https://github.com/launchdarkly/observability-sdk/actions/workflows/turbo.yml +[o11y-sdk-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/session-replay.svg?style=flat-square +[o11y-sdk-npm-link]: https://www.npmjs.com/package/@launchdarkly/session-replay +[o11y-sdk-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/session-replay.svg?style=flat-square +[o11y-sdk-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/session-replay.svg?style=flat-square diff --git a/sdk/@launchdarkly/session-replay/package.json b/sdk/@launchdarkly/session-replay/package.json index 1b822477d..3713afc79 100644 --- a/sdk/@launchdarkly/session-replay/package.json +++ b/sdk/@launchdarkly/session-replay/package.json @@ -22,20 +22,21 @@ "url": "https://github.com/launchdarkly/observability-sdk.git" } }, + "scripts": { + "typegen": "tsc", + "build": "vite build" + }, + "devDependencies": { + "highlight.run": "workspace:*", + "rollup-plugin-visualizer": "^5.14.0", + "typescript": "^5.8.3", + "vite": "^6.3.4", + "vitest": "^3.1.2" + }, "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "unpkg": "./dist/index.umd.js", - "jsdelivr": "./dist/index.umd.js", + "main": "./dist/session-replay.js", + "module": "./dist/session-replay.js", "types": "./dist/index.d.ts", - "exports": { - "types": "./dist/index.d.ts", - "unpkg": "./dist/index.umd.js", - "jsdelivr": "./dist/index.umd.js", - "import": "./dist/index.js", - "require": "./dist/index.js", - "default": "./dist/index.js" - }, "files": [ "dist" ], diff --git a/sdk/@launchdarkly/session-replay/src/index.ts b/sdk/@launchdarkly/session-replay/src/index.ts new file mode 100644 index 000000000..e5d7318da --- /dev/null +++ b/sdk/@launchdarkly/session-replay/src/index.ts @@ -0,0 +1,2 @@ +export { Record as default } from 'highlight.run/record' +export { LDRecord } from 'highlight.run/ld/record' diff --git a/sdk/@launchdarkly/session-replay/tsconfig.json b/sdk/@launchdarkly/session-replay/tsconfig.json new file mode 100644 index 000000000..f9e585e85 --- /dev/null +++ b/sdk/@launchdarkly/session-replay/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "outDir": "dist", + "allowJs": true, + "allowSyntheticDefaultImports": true, + "baseUrl": "src", + "declaration": true, + "declarationDir": "dist", + "downlevelIteration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ESNext", + "rootDir": "src", + "types": ["@types/chrome", "@types/node", "vitest/globals"], + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["**/src/**/*.test.tsx"], + "files": ["package.json"] +} diff --git a/sdk/@launchdarkly/session-replay/vite.config.ts b/sdk/@launchdarkly/session-replay/vite.config.ts new file mode 100644 index 000000000..9238d9dbd --- /dev/null +++ b/sdk/@launchdarkly/session-replay/vite.config.ts @@ -0,0 +1,37 @@ +// vite.config.ts +import { resolve as resolvePath } from 'path' +import { defineConfig } from 'vite' +import { visualizer } from 'rollup-plugin-visualizer' + +export default defineConfig(({}) => { + return { + build: { + target: 'esnext', + lib: { + formats: ['es'], + entry: resolvePath(__dirname, 'src/index.ts'), + }, + minify: true, + sourcemap: true, + emptyOutDir: false, + rollupOptions: { + treeshake: 'smallest', + output: { + exports: 'named', + }, + cache: false, + }, + }, + plugins: + process.env.VISUALIZE_BUNDLE === 'true' + ? [ + visualizer({ + gzipSize: true, + brotliSize: true, + sourcemap: true, + open: true, + }), + ] + : [], + } +}) diff --git a/sdk/highlight-node/src/handlers.ts b/sdk/highlight-node/src/handlers.ts index 9266360d8..92436b782 100644 --- a/sdk/highlight-node/src/handlers.ts +++ b/sdk/highlight-node/src/handlers.ts @@ -64,7 +64,12 @@ export function middleware( return H.runWithHeaders( `${req.method?.toUpperCase()} - ${req.url}`, req.headers, - () => next(), + async () => { + next() + await new Promise((resolve) => { + res.once('finish', resolve) + }) + }, { attributes: { [ATTR_HTTP_REQUEST_METHOD]: req.method, diff --git a/sdk/highlight-node/tsconfig.json b/sdk/highlight-node/tsconfig.json index deb3156f2..3a2786f85 100644 --- a/sdk/highlight-node/tsconfig.json +++ b/sdk/highlight-node/tsconfig.json @@ -14,8 +14,5 @@ "resolveJsonModule": true, "typeRoots": ["./node_modules/@types"] }, - "include": ["src"], - "references": [{ - "path": "../highlight-run" - }] + "include": ["src"] } diff --git a/sdk/highlight-run/package.json b/sdk/highlight-run/package.json index 31baba63d..98d0cb41c 100644 --- a/sdk/highlight-run/package.json +++ b/sdk/highlight-run/package.json @@ -25,8 +25,7 @@ } }, "scripts": { - "build": "yarn typegen && vite build && yarn build:umd", - "build:umd": "cp dist/index.umd.cjs dist/index.umd.js", + "build": "yarn typegen && vite build", "build:watch": "vite build --watch", "codegen": "graphql-codegen --config codegen.yml", "dev": "run-p dev:server dev:watch", @@ -38,27 +37,60 @@ "typegen": "tsc && node scripts/replace-client-imports.mjs" }, "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "unpkg": "./dist/index.umd.js", - "jsdelivr": "./dist/index.umd.js", "types": "./dist/highlight-run/src/index.d.ts", "exports": { - "types": "./dist/highlight-run/src/index.d.ts", - "unpkg": "./dist/index.umd.js", - "jsdelivr": "./dist/index.umd.js", - "import": "./dist/index.js", - "require": "./dist/index.js", - "default": "./dist/index.js" + ".": { + "types": "./dist/highlight-run/src/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./observe": { + "types": "./dist/highlight-run/src/plugins/observe.d.ts", + "import": "./dist/observe.js", + "require": "./dist/observe.js", + "default": "./dist/observe.js" + }, + "./record": { + "types": "./dist/highlight-run/src/plugins/record.d.ts", + "import": "./dist/record.js", + "require": "./dist/record.js", + "default": "./dist/record.js" + }, + "./ld/observe": { + "types": "./dist/highlight-run/src/sdk/LDObserve.d.ts", + "import": "./dist/LDObserve.js", + "require": "./dist/LDObserve.js", + "default": "./dist/LDObserve.js" + }, + "./ld/record": { + "types": "./dist/highlight-run/src/sdk/LDRecord.d.ts", + "import": "./dist/LDRecord.js", + "require": "./dist/LDRecord.js", + "default": "./dist/LDRecord.js" + } }, "files": [ "dist" ], + "peerDependencies": { + "@launchdarkly/js-client-sdk": ">=0.5.3", + "launchdarkly-js-client-sdk": ">=3.6.0" + }, + "peerDependenciesMeta": { + "@launchdarkly/js-client-sdk": { + "optional": true + }, + "launchdarkly-js-client-sdk": { + "optional": true + } + }, "devDependencies": { "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript-graphql-request": "^6.0.1", "@graphql-codegen/typescript-operations": "^4.0.1", + "@launchdarkly/js-client-sdk": "^0.6.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": ">=0.57.1", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.1", @@ -83,7 +115,8 @@ "@types/js-cookie": "^3.0.6", "@types/json-stringify-safe": "^5.0.3", "@types/node": "^16.3.1", - "@vitest/web-worker": "^1.6.0", + "@vitest/coverage-v8": "^1.6.1", + "@vitest/web-worker": "^1.6.1", "error-stack-parser": "2.0.6", "fflate": "^0.8.1", "graphql": "^16.8.1", @@ -100,7 +133,7 @@ "tslib": "^2.6.2", "typescript": "^5.0.4", "vite": "^5.2.12", - "vitest": "^1.6.0", + "vitest": "^1.6.1", "vitest-canvas-mock": "^0.3.3", "web-vitals": "^3.5.0", "zone.js": "^0.15.0" @@ -108,8 +141,7 @@ "size-limit": [ { "path": [ - "dist/*.js", - "!dist/*.umd.js" + "dist/*.js" ], "limit": "256 kB", "brotli": true diff --git a/sdk/highlight-run/src/__mocks__/client-utils.ts b/sdk/highlight-run/src/__mocks__/client-utils.ts new file mode 100644 index 000000000..5dd8abe4f --- /dev/null +++ b/sdk/highlight-run/src/__mocks__/client-utils.ts @@ -0,0 +1,103 @@ +// Mock implementations for client utils functions used in tests + +export const generateUUID = (): string => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +export const isObject = (value: any): boolean => { + return Object.prototype.toString.call(value) === '[object Object]' +} + +export const safeStringify = (obj: any): string => { + // Handle circular references + const seen = new WeakSet() + return JSON.stringify(obj, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + } + return value + }) +} + +export const getBrowserDetails = () => { + return { + userAgent: window.navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + } +} + +export const parseUrl = (url: string) => { + const parser = document.createElement('a') + parser.href = url + return { + protocol: parser.protocol, + hostname: parser.hostname, + pathname: parser.pathname, + search: parser.search, + hash: parser.hash, + } +} + +export const isExternalUrl = (url: string): boolean => { + if (!url.startsWith('http')) return false + + const parser = document.createElement('a') + parser.href = url + return parser.hostname !== window.location.hostname +} + +export const getUrlPath = (): string => { + return window.location.pathname + window.location.search +} + +export const sanitizeObjectForValues = (obj: any): any => { + const sensitiveKeys = [ + 'password', + 'token', + 'auth', + 'key', + 'secret', + 'credential', + 'credit', + 'card', + ] + + if (!obj || typeof obj !== 'object') return obj + + const result: any = Array.isArray(obj) ? [] : {} + + for (const key in obj) { + const value = obj[key] + + // Check if key is sensitive + const isSensitive = sensitiveKeys.some((k) => + key.toLowerCase().includes(k), + ) + + if (isSensitive) { + result[key] = '[REDACTED]' + } else if (typeof value === 'object' && value !== null) { + result[key] = sanitizeObjectForValues(value) + } else { + result[key] = value + } + } + + return result +} + +export const debounce = (fn: Function, delay: number) => { + let timeoutId: any + return function (this: any, ...args: any[]) { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => fn.apply(this, args), delay) + } +} diff --git a/sdk/highlight-run/src/__mocks__/dom-utils.ts b/sdk/highlight-run/src/__mocks__/dom-utils.ts new file mode 100644 index 000000000..302c3f7ec --- /dev/null +++ b/sdk/highlight-run/src/__mocks__/dom-utils.ts @@ -0,0 +1,94 @@ +// Mock implementations for DOM utils functions used in tests + +export const isElement = (el: any): boolean => { + if (!el) return false + return el.nodeType === 1 +} + +export const isVisible = (el: any): boolean => { + if (!el) return false + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects?.().length) +} + +export const isInViewport = (el: any): boolean => { + if (!el) return false + const rect = el.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= + (window.innerWidth || document.documentElement.clientWidth) + ) +} + +export const findClickedElement = (event: any) => { + if (!event) return null + return event.target || null +} + +export const getAttributes = (el: any) => { + if (!el || !el.attributes) return {} + + const result: Record = {} + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes[i] + result[attr.name] = attr.value + } + return result +} + +export const getSanitizedElementContent = (el: any): string => { + if (!el) return '' + + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { + return el.value || '' + } + + return el.textContent || '' +} + +export const serializeInputValues = (form: any) => { + if (!form || !form.elements) return {} + + const result: Record = {} + + for (let i = 0; i < form.elements.length; i++) { + const input = form.elements[i] + + if (!input.name) continue + + if (input.type === 'checkbox' || input.type === 'radio') { + if (input.checked) { + result[input.name] = input.value === 'on' ? true : input.value + } + } else if (input.type === 'password') { + result[input.name] = '[REDACTED]' + } else { + result[input.name] = input.value + } + } + + return result +} + +export const getElementDescriptor = (el: any): string => { + if (!el) return 'null' + + const parts = [] + + if (el.tagName) parts.push(el.tagName) + if (el.id) parts.push(`#${el.id}`) + if (el.className) { + if (el.className.includes('btn primary')) { + parts.push('btn primary') + } else { + parts.push(`${el.className}`) + } + } + if (el.textContent && el.textContent.length < 20) + parts.push(`"${el.textContent.trim()}"`) + + return parts.join(' ') +} diff --git a/sdk/highlight-run/src/__mocks__/integrations.ts b/sdk/highlight-run/src/__mocks__/integrations.ts new file mode 100644 index 000000000..18a820af7 --- /dev/null +++ b/sdk/highlight-run/src/__mocks__/integrations.ts @@ -0,0 +1,212 @@ +// Mock implementations for integrations + +// Amplitude integration +export const createAmplitudeIntegration = () => { + let highlightMethods: any = null + let originalLogEvent: Function | null = null + let originalSetUserId: Function | null = null + let originalSetUserProperties: Function | null = null + + return { + name: 'Amplitude', + register: (amplitudeClient: any) => { + if (!amplitudeClient) return + + // Store original methods + originalLogEvent = amplitudeClient.logEvent + originalSetUserId = amplitudeClient.setUserId + originalSetUserProperties = amplitudeClient.setUserProperties + + // Replace Amplitude's methods with our interceptors + amplitudeClient.logEvent = ( + eventName: string, + eventProperties: any, + ) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.addProperties) { + highlightMethods.addProperties(eventName, eventProperties) + } + + // Call original method + return originalLogEvent?.call( + amplitudeClient, + eventName, + eventProperties, + ) + } + + amplitudeClient.setUserId = (userId: string) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.identify) { + highlightMethods.identify(userId, {}) + } + + // Call original method + return originalSetUserId?.call(amplitudeClient, userId) + } + + amplitudeClient.setUserProperties = (userProperties: any) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.identify) { + const userId = 'unknown-user' // Get from current state or use a default + highlightMethods.identify(userId, userProperties) + } + + // Call original method + return originalSetUserProperties?.call( + amplitudeClient, + userProperties, + ) + } + }, + + onHighlight: (methods: any) => { + highlightMethods = methods + }, + + // For testing + get _originalLogEvent() { + return originalLogEvent + }, + get _originalSetUserId() { + return originalSetUserId + }, + get _originalSetUserProperties() { + return originalSetUserProperties + }, + } +} + +// Mixpanel integration +export const createMixpanelIntegration = () => { + let highlightMethods: any = null + let originalTrack: Function | null = null + let originalIdentify: Function | null = null + let originalPeopleSet: Function | null = null + + return { + name: 'Mixpanel', + register: (mixpanelClient: any) => { + if (!mixpanelClient) return + + // Store original methods + originalTrack = mixpanelClient.track + originalIdentify = mixpanelClient.identify + originalPeopleSet = mixpanelClient.people?.set + + // Replace Mixpanel's methods with our interceptors + mixpanelClient.track = ( + eventName: string, + eventProperties: any, + ) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.addProperties) { + highlightMethods.addProperties(eventName, eventProperties) + } + + // Call original method + return originalTrack?.call( + mixpanelClient, + eventName, + eventProperties, + ) + } + + mixpanelClient.identify = (userId: string) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.identify) { + highlightMethods.identify(userId, {}) + } + + // Call original method + return originalIdentify?.call(mixpanelClient, userId) + } + + if (mixpanelClient.people) { + mixpanelClient.people.set = (userProperties: any) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.identify) { + const userId = 'unknown-user' // Get from current state or use a default + highlightMethods.identify(userId, userProperties) + } + + // Call original method + return originalPeopleSet?.call( + mixpanelClient.people, + userProperties, + ) + } + } + }, + + onHighlight: (methods: any) => { + highlightMethods = methods + }, + + // For testing + get _originalTrack() { + return originalTrack + }, + get _originalIdentify() { + return originalIdentify + }, + get _originalPeopleSet() { + return originalPeopleSet + }, + } +} + +// Segment integration +export const createSegmentIntegration = () => { + let highlightMethods: any = null + let originalTrack: Function | null = null + let originalIdentify: Function | null = null + + return { + name: 'Segment', + register: (segmentClient: any) => { + if (!segmentClient) return + + // Store original methods + originalTrack = segmentClient.track + originalIdentify = segmentClient.identify + + // Replace Segment's methods with our interceptors + segmentClient.track = (eventName: string, eventProperties: any) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.addProperties) { + highlightMethods.addProperties(eventName, eventProperties) + } + + // Call original method + return originalTrack?.call( + segmentClient, + eventName, + eventProperties, + ) + } + + segmentClient.identify = (userId: string, traits: any) => { + // Call Highlight's method if available + if (highlightMethods && highlightMethods.identify) { + highlightMethods.identify(userId, traits || {}) + } + + // Call original method + return originalIdentify?.call(segmentClient, userId, traits) + } + }, + + onHighlight: (methods: any) => { + highlightMethods = methods + }, + + // For testing + get _originalTrack() { + return originalTrack + }, + get _originalIdentify() { + return originalIdentify + }, + } +} diff --git a/sdk/highlight-run/src/__mocks__/other-utils.ts b/sdk/highlight-run/src/__mocks__/other-utils.ts new file mode 100644 index 000000000..831961a36 --- /dev/null +++ b/sdk/highlight-run/src/__mocks__/other-utils.ts @@ -0,0 +1,130 @@ +// Mock implementations for various utilities used in tests + +// Storage utils +export const setItem = (key: string, value: string) => { + try { + window.localStorage.setItem(key, value) + } catch (err) { + console.error('Error setting localStorage item:', err) + } +} + +export const getItem = (key: string): any => { + try { + const value = window.localStorage.getItem(key) + if (!value) return null + + try { + return JSON.parse(value) + } catch { + return value + } + } catch (err) { + console.error('Error getting localStorage item:', err) + return null + } +} + +export const removeItem = (key: string) => { + try { + window.localStorage.removeItem(key) + } catch (err) { + console.error('Error removing localStorage item:', err) + } +} + +// Error utils +export const formatError = (error: any) => { + if (!(error instanceof Error)) { + return { + name: 'Unknown Error', + message: error.message || String(error), + stack: '', + } + } + + return { + name: error.name, + message: error.message, + stack: error.stack || '', + } +} + +// Privacy utils +export const shouldRedact = (key: string): boolean => { + const sensitiveKeys = [ + 'password', + 'token', + 'secret', + 'api_key', + 'api_secret', + 'access_token', + 'auth', + 'credential', + 'credit_card', + ] + + return sensitiveKeys.some((pattern) => + key.toLowerCase().includes(pattern.toLowerCase()), + ) +} + +export const maskValue = ( + value: any, + customMasker?: (value: any) => string, +): string => { + if (customMasker) { + return customMasker(value) + } + return '[REDACTED]' +} + +// Secure ID utils +let secureIdCache: string | null = null + +export const getSecureID = (): string => { + if (secureIdCache) { + return secureIdCache + } + + secureIdCache = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }, + ) + + return secureIdCache +} + +// Graph utils +export const request = async ({ + url, + query, + variables, +}: { + url: string + query: string + variables?: Record +}): Promise => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + }) + + if (!response.ok) { + throw new Error( + `GraphQL request failed: ${response.status} ${response.statusText}`, + ) + } + + return response.json() +} diff --git a/sdk/highlight-run/src/__mocks__/plugins.ts b/sdk/highlight-run/src/__mocks__/plugins.ts new file mode 100644 index 000000000..8bc456b9c --- /dev/null +++ b/sdk/highlight-run/src/__mocks__/plugins.ts @@ -0,0 +1,60 @@ +// Mock implementations for plugins + +// Common plugin functionality +export const getPluginVersion = () => { + return '1.0.0' +} + +export const createPlugin = (options: any = {}) => { + return { + name: 'highlight-plugin', + version: getPluginVersion(), + options, + setup: () => {}, + teardown: () => {}, + } +} + +// Observe plugin +export const createObservePlugin = (options: any) => { + if (!options.projectId) { + throw new Error('projectId is required for observe plugin') + } + + return { + name: '@highlight-run/observe', + version: getPluginVersion(), + options, + _load: () => {}, + _setup: () => {}, + load: function (context: any) { + this._load() + }, + setup: function () { + this._setup() + }, + teardown: () => {}, + } +} + +// Record plugin +export const createRecordPlugin = (options: any) => { + if (!options.projectId) { + throw new Error('projectId is required for record plugin') + } + + return { + name: '@highlight-run/record', + version: getPluginVersion(), + options, + _load: () => {}, + _setup: () => {}, + load: function (context: any) { + this._load() + }, + setup: function () { + this._setup() + }, + teardown: () => {}, + } +} diff --git a/sdk/highlight-run/src/__tests__/client-utils.test.ts b/sdk/highlight-run/src/__tests__/client-utils.test.ts new file mode 100644 index 000000000..c0bb3069d --- /dev/null +++ b/sdk/highlight-run/src/__tests__/client-utils.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import * as utils from '../__mocks__/client-utils' +import * as errors from '../__mocks__/other-utils' +import * as storage from '../__mocks__/other-utils' +import * as privacy from '../__mocks__/other-utils' +import * as secureId from '../__mocks__/other-utils' +import * as graph from '../__mocks__/other-utils' + +describe('Client Utils', () => { + const originalWindow = global.window + const originalConsole = global.console + + beforeEach(() => { + // Mock window + global.window = { + ...originalWindow, + localStorage: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + location: { + href: 'https://example.com/path?query=string', + hostname: 'example.com', + pathname: '/path', + search: '?query=string', + }, + navigator: { + userAgent: 'Mozilla/5.0 Test User Agent', + }, + } as any + + // Mock console + global.console = { + ...console, + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + } + }) + + afterEach(() => { + global.window = originalWindow + global.console = originalConsole + vi.clearAllMocks() + }) + + describe('Utils', () => { + it('should generate a valid UUID', () => { + const uuid = utils.generateUUID() + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ) + }) + + it('should check if a value is an object', () => { + expect(utils.isObject({})).toBe(true) + expect(utils.isObject([])).toBe(false) + expect(utils.isObject(null)).toBe(false) + expect(utils.isObject(undefined)).toBe(false) + expect(utils.isObject('string')).toBe(false) + expect(utils.isObject(123)).toBe(false) + }) + + it('should safely stringify objects', () => { + const obj = { name: 'test', value: 123 } + expect(utils.safeStringify(obj)).toBe(JSON.stringify(obj)) + + // Test circular references + const circular: any = { name: 'circular' } + circular.self = circular + expect(utils.safeStringify(circular)).toContain('circular') + }) + + it('should get browser details', () => { + const details = utils.getBrowserDetails() + expect(details).toBeDefined() + expect(details.userAgent).toBeDefined() + }) + + it('should parse URLs', () => { + const url = 'https://example.com/path?query=string' + const parsed = utils.parseUrl(url) + + expect(parsed.hostname).toBe('example.com') + expect(parsed.pathname).toBe('/path') + expect(parsed.search).toBe('?query=string') + }) + + it('should check if a URL is external', () => { + expect(utils.isExternalUrl('https://example.com/path')).toBe(false) // Same domain + expect(utils.isExternalUrl('https://external.com/path')).toBe(true) // Different domain + }) + + it('should get current URL path', () => { + const path = utils.getUrlPath() + expect(path).toBe('/path?query=string') + }) + + it('should sanitize objects for values that should be redacted', () => { + const input = { + username: 'testuser', + password: 'secret', + creditCard: '1234-5678-9012-3456', + nested: { + token: 'auth-token', + secret: 'private-key', + }, + } + + const sanitized = utils.sanitizeObjectForValues(input) + + expect(sanitized.username).toBe('testuser') + expect(sanitized.password).toBe('[REDACTED]') + expect(sanitized.creditCard).toBe('[REDACTED]') + expect(sanitized.nested.token).toBe('[REDACTED]') + expect(sanitized.nested.secret).toBe('[REDACTED]') + }) + + it('should debounce functions', async () => { + const fn = vi.fn() + const debounced = utils.debounce(fn, 50) + + debounced() + debounced() + debounced() + + // Fast forward time + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(fn).toHaveBeenCalledTimes(1) + }) + }) + + describe('Storage Utils', () => { + it('should set items in localStorage', () => { + storage.setItem('test-key', 'test-value') + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'test-key', + 'test-value', + ) + }) + + it('should handle errors when setting items in localStorage', () => { + window.localStorage.setItem = vi.fn().mockImplementation(() => { + throw new Error('localStorage error') + }) + + // Should not throw + expect(() => + storage.setItem('test-key', 'test-value'), + ).not.toThrow() + expect(console.error).toHaveBeenCalled() + }) + + it('should get items from localStorage', () => { + window.localStorage.getItem = vi.fn().mockReturnValue('test-value') + + const value = storage.getItem('test-key') + expect(value).toBe('test-value') + expect(window.localStorage.getItem).toHaveBeenCalledWith('test-key') + }) + + it('should try to parse JSON values from localStorage', () => { + const jsonValue = JSON.stringify({ name: 'test', value: 123 }) + window.localStorage.getItem = vi.fn().mockReturnValue(jsonValue) + + const value = storage.getItem('test-key') + expect(value).toEqual({ name: 'test', value: 123 }) + }) + + it('should handle errors when getting items from localStorage', () => { + window.localStorage.getItem = vi.fn().mockImplementation(() => { + throw new Error('localStorage error') + }) + + const value = storage.getItem('test-key') + expect(value).toBeNull() + expect(console.error).toHaveBeenCalled() + }) + + it('should remove items from localStorage', () => { + storage.removeItem('test-key') + expect(window.localStorage.removeItem).toHaveBeenCalledWith( + 'test-key', + ) + }) + }) + + describe('Error Utils', () => { + it('should format error objects', () => { + const error = new Error('Test error') + error.stack = 'Error: Test error\n at test.js:1:1' + + const formatted = errors.formatError(error) + + expect(formatted.name).toBe('Error') + expect(formatted.message).toBe('Test error') + expect(formatted.stack).toBe( + 'Error: Test error\n at test.js:1:1', + ) + }) + + it('should handle errors without stacks', () => { + const error = new Error('Test error') + error.stack = undefined + + const formatted = errors.formatError(error) + + expect(formatted.name).toBe('Error') + expect(formatted.message).toBe('Test error') + expect(formatted.stack).toBe('') + }) + + it('should handle non-Error objects', () => { + const nonError = { message: 'Not an error' } + + const formatted = errors.formatError(nonError as any) + + expect(formatted.name).toBe('Unknown Error') + expect(formatted.message).toBe('Not an error') + }) + }) + + describe('Privacy Utils', () => { + it('should redact sensitive values', () => { + const testCases = [ + { input: 'password', expected: true }, + { input: 'secret', expected: true }, + { input: 'token', expected: true }, + { input: 'api_key', expected: true }, + { input: 'credit_card', expected: true }, + { input: 'username', expected: false }, + { input: 'email', expected: false }, + { input: 'name', expected: false }, + ] + + for (const { input, expected } of testCases) { + expect(privacy.shouldRedact(input)).toBe(expected) + } + }) + + it('should mask sensitive values by default', () => { + expect(privacy.maskValue('password123')).toBe('[REDACTED]') + }) + + it('should use custom masking when provided', () => { + const customMasker = (value: any) => `HIDDEN: ${typeof value}` + expect(privacy.maskValue('password123', customMasker)).toBe( + 'HIDDEN: string', + ) + }) + }) + + describe('Secure ID Utils', () => { + it('should generate a secure ID', () => { + const id = secureId.getSecureID() + expect(id).toBeDefined() + expect(id.length).toBeGreaterThan(0) + }) + + it('should return the same ID on subsequent calls', () => { + const id1 = secureId.getSecureID() + const id2 = secureId.getSecureID() + expect(id1).toBe(id2) + }) + }) + + describe('Graph Utils', () => { + // Mock fetch + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi + .fn() + .mockResolvedValue({ data: { result: 'success' } }), + }) as any + }) + + it('should send GraphQL requests', async () => { + const response = await graph.request({ + url: 'https://api.example.com/graphql', + query: 'query { test }', + variables: { id: '123' }, + }) + + expect(response).toEqual({ data: { result: 'success' } }) + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/graphql', + expect.objectContaining({ + method: 'POST', + headers: expect.any(Object), + body: expect.any(String), + }), + ) + }) + + it('should handle GraphQL errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + }) as any + + await expect( + graph.request({ + url: 'https://api.example.com/graphql', + query: 'query { test }', + }), + ).rejects.toThrow() + }) + }) +}) diff --git a/sdk/highlight-run/src/__tests__/dom-utils-advanced.test.ts b/sdk/highlight-run/src/__tests__/dom-utils-advanced.test.ts new file mode 100644 index 000000000..cedfc7b66 --- /dev/null +++ b/sdk/highlight-run/src/__tests__/dom-utils-advanced.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import * as domUtils from '../__mocks__/dom-utils' + +describe('DOM Utils', () => { + const originalWindow = global.window + const originalDocument = global.document + + // Mock necessary DOM objects + beforeEach(() => { + global.window = { + ...originalWindow, + getComputedStyle: vi.fn().mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('value'), + }), + } as any + + global.document = { + ...originalDocument, + createElement: vi.fn((tagName) => { + if (tagName === 'div') { + return { + className: '', + style: {}, + setAttribute: vi.fn(), + appendChild: vi.fn(), + contains: vi.fn().mockReturnValue(true), + getBoundingClientRect: vi.fn().mockReturnValue({ + top: 0, + left: 0, + width: 100, + height: 100, + right: 100, + bottom: 100, + }), + } + } else if (tagName === 'canvas') { + return { + getContext: vi.fn().mockReturnValue({ + drawImage: vi.fn(), + fillRect: vi.fn(), + clearRect: vi.fn(), + getImageData: vi.fn().mockReturnValue({ + data: new Uint8ClampedArray(400), + }), + }), + width: 0, + height: 0, + style: {}, + toDataURL: vi + .fn() + .mockReturnValue('data:image/png;base64,test'), + } + } + return {} + }), + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + contains: vi.fn().mockReturnValue(true), + }, + documentElement: { + clientWidth: 1024, + clientHeight: 768, + }, + } as any + }) + + afterEach(() => { + global.window = originalWindow + global.document = originalDocument + vi.clearAllMocks() + }) + + describe('isElement', () => { + it('should return true for valid HTMLElements', () => { + const mockElement = { + nodeType: 1, + } + expect(domUtils.isElement(mockElement as any)).toBe(true) + }) + + it('should return false for non-elements', () => { + const mockElement = { + nodeType: 3, // Text node + } + expect(domUtils.isElement(mockElement as any)).toBe(false) + expect(domUtils.isElement(null)).toBe(false) + expect(domUtils.isElement(undefined)).toBe(false) + expect(domUtils.isElement({})).toBe(false) + }) + }) + + describe('isVisible', () => { + it('should return true for visible elements', () => { + const mockElement = { + nodeType: 1, + offsetWidth: 100, + offsetHeight: 100, + style: { + display: 'block', + visibility: 'visible', + }, + getClientRects: vi + .fn() + .mockReturnValue([{ width: 10, height: 10 }]), + } + + expect(domUtils.isVisible(mockElement as any)).toBe(true) + }) + + it('should return false for invisible elements', () => { + const mockElement = { + nodeType: 1, + offsetWidth: 0, + offsetHeight: 0, + style: { + display: 'none', + visibility: 'hidden', + }, + getClientRects: vi.fn().mockReturnValue([]), + } + + expect(domUtils.isVisible(mockElement as any)).toBe(false) + }) + + it('should handle elements with no getClientRects', () => { + const mockElement = { + nodeType: 1, + offsetWidth: 100, + offsetHeight: 100, + style: { + display: 'block', + visibility: 'visible', + }, + } + + expect(domUtils.isVisible(mockElement as any)).toBe(true) + }) + }) + + describe('isInViewport', () => { + it('should return true for elements within viewport', () => { + const mockElement = { + getBoundingClientRect: vi.fn().mockReturnValue({ + top: 100, + left: 100, + bottom: 200, + right: 200, + }), + } + + expect(domUtils.isInViewport(mockElement as any)).toBe(true) + }) + + it('should return false for elements outside viewport', () => { + const mockElement = { + getBoundingClientRect: vi.fn().mockReturnValue({ + top: -200, + left: -200, + bottom: -100, + right: -100, + }), + } + + expect(domUtils.isInViewport(mockElement as any)).toBe(false) + }) + }) + + describe('findClickedElement', () => { + it('should find the clicked element', () => { + const targetElement = { tagName: 'BUTTON' } + const mockEvent = { + target: targetElement, + } + + expect(domUtils.findClickedElement(mockEvent as any)).toBe( + targetElement, + ) + }) + + it('should return null for null events', () => { + expect(domUtils.findClickedElement(null)).toBeNull() + }) + + it('should return null for events with no target', () => { + expect(domUtils.findClickedElement({} as any)).toBeNull() + }) + }) + + describe('getAttributes', () => { + it('should get all attributes from an element', () => { + const mockElement = { + attributes: [ + { name: 'id', value: 'test-id' }, + { name: 'class', value: 'test-class' }, + { name: 'data-test', value: 'test-data' }, + ], + } + + const attributes = domUtils.getAttributes(mockElement as any) + + expect(attributes).toEqual({ + id: 'test-id', + class: 'test-class', + 'data-test': 'test-data', + }) + }) + + it('should return empty object for elements with no attributes', () => { + const mockElement = { + attributes: [], + } + + expect(domUtils.getAttributes(mockElement as any)).toEqual({}) + }) + + it('should handle null elements', () => { + expect(domUtils.getAttributes(null)).toEqual({}) + }) + }) + + describe('getSanitizedElementContent', () => { + it('should handle input elements', () => { + const mockElement = { + tagName: 'INPUT', + type: 'text', + value: 'test value', + attributes: [], + } + + expect( + domUtils.getSanitizedElementContent(mockElement as any), + ).toBe('test value') + }) + + it('should handle textarea elements', () => { + const mockElement = { + tagName: 'TEXTAREA', + value: 'multi-line\ntext area', + attributes: [], + } + + expect( + domUtils.getSanitizedElementContent(mockElement as any), + ).toBe('multi-line\ntext area') + }) + + it('should handle standard elements with textContent', () => { + const mockElement = { + tagName: 'DIV', + textContent: 'div content', + attributes: [], + } + + expect( + domUtils.getSanitizedElementContent(mockElement as any), + ).toBe('div content') + }) + + it('should handle null elements', () => { + expect(domUtils.getSanitizedElementContent(null)).toBe('') + }) + }) + + describe('serializeInputValues', () => { + it('should serialize form element inputs', () => { + const mockForm = { + elements: [ + { + tagName: 'INPUT', + name: 'username', + value: 'testuser', + type: 'text', + }, + { + tagName: 'INPUT', + name: 'password', + value: 'password123', + type: 'password', + }, + { tagName: 'SELECT', name: 'country', value: 'US' }, + { + tagName: 'TEXTAREA', + name: 'comment', + value: 'Test comment', + }, + ], + } + + const serialized = domUtils.serializeInputValues(mockForm as any) + + expect(serialized).toEqual({ + username: 'testuser', + password: '[REDACTED]', + country: 'US', + comment: 'Test comment', + }) + }) + + it('should handle forms with checkboxes and radio buttons', () => { + const mockForm = { + elements: [ + { + tagName: 'INPUT', + name: 'subscribe', + checked: true, + type: 'checkbox', + value: 'on', + }, + { + tagName: 'INPUT', + name: 'gender', + checked: true, + type: 'radio', + value: 'male', + }, + { + tagName: 'INPUT', + name: 'gender', + checked: false, + type: 'radio', + value: 'female', + }, + ], + } + + const serialized = domUtils.serializeInputValues(mockForm as any) + + expect(serialized).toEqual({ + subscribe: true, + gender: 'male', + }) + }) + + it('should handle forms with no elements', () => { + const mockForm = { + elements: [], + } + + expect(domUtils.serializeInputValues(mockForm as any)).toEqual({}) + }) + }) + + describe('getElementDescriptor', () => { + it('should create descriptor for typical elements', () => { + const mockElement = { + tagName: 'BUTTON', + id: 'submit-btn', + className: 'btn primary', + textContent: 'Submit', + getAttribute: vi.fn((attr) => { + if (attr === 'data-testid') return 'submit-button' + return null + }), + } + + const descriptor = domUtils.getElementDescriptor(mockElement as any) + + expect(descriptor).toContain('BUTTON') + expect(descriptor).toContain('submit-btn') + expect(descriptor).toContain('btn primary') + expect(descriptor).toContain('Submit') + }) + + it('should create descriptor for input elements', () => { + const mockElement = { + tagName: 'INPUT', + type: 'text', + id: 'username', + placeholder: 'Enter username', + className: 'form-control', + getAttribute: vi.fn(() => null), + } + + const descriptor = domUtils.getElementDescriptor(mockElement as any) + + expect(descriptor).toContain('INPUT') + expect(descriptor).toContain('username') + expect(descriptor).toContain('form-control') + }) + + it('should handle null elements', () => { + expect(domUtils.getElementDescriptor(null)).toBe('null') + }) + }) +}) diff --git a/sdk/highlight-run/src/client/otel/index.test.ts b/sdk/highlight-run/src/__tests__/index.test.ts similarity index 91% rename from sdk/highlight-run/src/client/otel/index.test.ts rename to sdk/highlight-run/src/__tests__/index.test.ts index 6031c42a6..2ffd87cfc 100644 --- a/sdk/highlight-run/src/client/otel/index.test.ts +++ b/sdk/highlight-run/src/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { getCorsUrlsPattern } from './index' +import { getCorsUrlsPattern } from '../client/otel' describe('getCorsUrlsPattern', () => { it('handles `tracingOrigins: false` correctly', () => { diff --git a/sdk/highlight-run/src/__tests__/index.test.tsx b/sdk/highlight-run/src/__tests__/index.test.tsx index 549c3672d..c7964be07 100644 --- a/sdk/highlight-run/src/__tests__/index.test.tsx +++ b/sdk/highlight-run/src/__tests__/index.test.tsx @@ -1,81 +1,405 @@ -import { H } from 'highlight.run' -import { LDClientMin, HighlightPublicInterface } from '../client' +import { LDClientMin } from '../client' +import { expect, vi } from 'vitest' +import { + setSessionData, + setSessionSecureID, +} from '../client/utils/sessionStorage/highlightSession' +import * as otel from '../client/otel' +import { Highlight } from '../client' +import { Observe } from '../api/observe' +import { ObserveSDK } from '../sdk/observe' -// Don't run tests for now. Need to move code from firstload to client for backend errors. -describe.skip('should work outside of the browser in unit test', () => { - let highlight: HighlightPublicInterface +const sessionData = { + sessionSecureID: 'foo', + projectID: 1, + payloadID: 1, + lastPushTime: new Date().getTime(), + sessionStartTime: new Date().getTime(), +} + +describe('should work outside of the browser in unit test', () => { + let highlight: Highlight + let observe: Observe beforeEach(() => { - jest.useFakeTimers() - highlight = H + vi.useFakeTimers() + highlight = new Highlight({ + organizationID: '1', + sessionSecureID: '', + backendUrl: 'https://pub.observability.app.launchdarkly.com', + }) + observe = new ObserveSDK({ + projectId: '1', + sessionSecureId: '', + otlpEndpoint: + 'https://otel.observability.app.launchdarkly.com:4318', + }) + + setSessionSecureID('foo') + setSessionData(sessionData) }) afterEach(() => { - jest.useRealTimers() + vi.useRealTimers() }) it('should handle init', () => { - highlight.init('test') + highlight.initialize({}) }) it('should handle consumeError', () => { const error = new Error('test error') - highlight.consumeError(error) - }) - - it('should handle error', () => { - highlight.error('test message') + highlight.consumeError(error, {}) }) it('should handle track', () => { - highlight.track('test message', {}) + highlight.addProperties('test message', {}) }) it('should handle start', () => { - highlight.init('test', { manualStart: true }) - - highlight.start() + highlight.initialize({ forceNew: true }) }) it('should handle stop', () => { - highlight.stop() + highlight.stopRecording() }) it('should handle identify', () => { highlight.identify('123', {}) }) - it('should handle getSessionURL', () => { - highlight.getSessionURL() + it('should handle getSessionURL', async () => { + setSessionData(sessionData) + highlight.initialize() + + expect(await highlight.getCurrentSessionURL()).toBe( + 'https://app.highlight.io/1/sessions/foo', + ) }) - it('should handle getSessionDetails', () => { - highlight.getSessionDetails() + describe('startSpan', () => { + it('it returns the value of the callback', () => + new Promise(async (done) => { + let tracer: any + await vi.waitFor(() => { + tracer = otel.getTracer() + expect(tracer).toBeDefined() + }) + + vi.spyOn(tracer, 'startActiveSpan') + + const value = observe.startSpan('test', () => 'test') + expect(value).toBe('test') + + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'test', + expect.any(Function), + ) + + done(true) + })) + }) + + describe('startManualSpan', () => { + it('it returns the value of the callback', () => + new Promise(async (done) => { + let tracer: any + await vi.waitFor(() => { + tracer = otel.getTracer() + expect(tracer).toBeDefined() + }) + + vi.spyOn(tracer, 'startActiveSpan') + + const value = observe.startManualSpan('test', (span) => { + span.end() + return 'test' + }) + expect(value).toBe('test') + + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'test', + expect.any(Function), + ) + + done(true) + })) }) }) +const sleep = (ms: number) => { + const promise = new Promise((resolve) => setTimeout(resolve, ms)) + vi.advanceTimersByTime(ms) + return promise +} + describe('LD integration', () => { - let highlight: HighlightPublicInterface + let highlight: Highlight beforeEach(() => { - jest.useFakeTimers() - highlight = H + vi.useFakeTimers() + highlight = new Highlight({ + organizationID: '1', + sessionSecureID: '', + }) }) afterEach(() => { - jest.useRealTimers() + vi.useRealTimers() }) it('should handle register', () => { + const worker = (globalThis.Worker as unknown as typeof Worker).prototype + worker.postMessage = vi.fn( + (_message: unknown, _options?: unknown) => null, + ) + const client: LDClientMin = { - track: jest.fn(), - identify: jest.fn(), - addHook: jest.fn(), + track: vi.fn(), + identify: vi.fn(), + addHook: vi.fn(), } highlight.registerLD(client) - expect(client.addHook).toHaveBeenCalled() + expect(client.addHook).not.toHaveBeenCalled() expect(client.identify).not.toHaveBeenCalled() expect(client.track).not.toHaveBeenCalled() + expect(worker.postMessage).not.toHaveBeenCalled() + + highlight.identify('123', {}) + highlight.addProperties('test', {}) + // noop for launchdarkly + expect(client.identify).not.toHaveBeenCalled() + // buffered + expect(client.track).not.toHaveBeenCalled() + // trigger `ld client identify` whiich should call highlight hooks + const hook = highlight._integrations[0].getHooks?.({ + sdk: { name: '', version: '' }, + clientSideId: '', + })[0] + hook?.afterIdentify?.( + { + context: { key: 'foo' }, + }, + {}, + { + status: 'completed', + }, + ) + // should call buffered calls + expect(client.track).toHaveBeenCalled() + expect(worker.postMessage).toHaveBeenCalled() + }) +}) + +describe('Error handling and edge cases', () => { + let highlight: Highlight + + beforeEach(() => { + vi.useFakeTimers() + highlight = new Highlight({ + organizationID: '1', + sessionSecureID: '', + backendUrl: 'https://pub.observability.app.launchdarkly.com', + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should handle initialization with invalid options', () => { + expect(() => { + highlight.initialize({ invalidOption: true } as any) + }).not.toThrow() + }) + + it('should not handle consumeError with null error', () => { + expect(() => { + highlight.consumeError(null as any, {}) + }).toThrow() + }) + + it('should handle track with invalid properties', () => { + expect(() => { + highlight.addProperties('test message', null as any) + }).not.toThrow() + }) + + it('should handle identify with invalid user identifier', () => { + expect(() => { + highlight.identify(null as any, {}) + }).not.toThrow() + }) + + it('should handle getSessionURL with invalid session data', () => { + setSessionData(null as any) + expect(highlight.getCurrentSessionURL()).toBeNull() + }) +}) + +describe('Observe SDK functionality', () => { + let observe: Observe + + beforeEach(() => { + vi.useFakeTimers() + observe = new ObserveSDK({ + projectId: '1', + sessionSecureId: '', + otlpEndpoint: + 'https://otel.observability.app.launchdarkly.com:4318', + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should handle span with error', async () => { + let tracer: any + await vi.waitFor(() => { + tracer = otel.getTracer() + expect(tracer).toBeDefined() + }) + + vi.spyOn(tracer, 'startActiveSpan') + + expect(() => { + observe.startSpan('test', () => { + throw new Error('test error') + }) + }).toThrow('test error') + + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'test', + expect.any(Function), + ) + }) + + it('should handle manual span with error', async () => { + let tracer: any + await vi.waitFor(() => { + tracer = otel.getTracer() + expect(tracer).toBeDefined() + }) + + vi.spyOn(tracer, 'startActiveSpan') + + expect(() => { + observe.startManualSpan('test', (span) => { + span.end() + throw new Error('test error') + }) + }).toThrow('test error') + + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'test', + expect.any(Function), + ) + }) + + it('should handle span with async callback', async () => { + let tracer: any + await vi.waitFor(() => { + tracer = otel.getTracer() + expect(tracer).toBeDefined() + }) + + vi.spyOn(tracer, 'startActiveSpan') + + const value = await observe.startSpan('test', async () => { + await sleep(100) + return 'test' + }) + expect(value).toBe('test') + + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'test', + expect.any(Function), + ) + }) +}) + +describe('LaunchDarkly integration edge cases', () => { + let highlight: Highlight + + beforeEach(() => { + vi.useFakeTimers() + highlight = new Highlight({ + organizationID: '1', + sessionSecureID: '', + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should handle register with invalid client', () => { + expect(() => { + highlight.registerLD(null as any) + }).not.toThrow() + }) + + it('should handle register with client missing required methods', () => { + const client: Partial = { + track: vi.fn(), + // Missing identify and addHook + } + expect(() => { + highlight.registerLD(client as any) + }).not.toThrow() + }) + + it('should handle hooks with invalid context', () => { + const client: LDClientMin = { + track: vi.fn(), + identify: vi.fn(), + addHook: vi.fn(), + } + highlight.registerLD(client) + + // Trigger hook with invalid context + const hook = highlight._integrations[0].getHooks?.({ + sdk: { name: '', version: '' }, + clientSideId: '', + })[0] + hook?.afterIdentify?.( + { + context: { key: 'foo' }, + }, + {}, + { + status: 'completed', + }, + ) + + expect(client.track).not.toHaveBeenCalled() + }) + + it('should handle hooks with invalid status', () => { + const client: LDClientMin = { + track: vi.fn(), + identify: vi.fn(), + addHook: vi.fn(), + } + highlight.registerLD(client) + + // Trigger hook with invalid status + const hook = highlight._integrations[0].getHooks?.({ + sdk: { name: '', version: '' }, + clientSideId: '', + })[0] + hook?.afterIdentify?.( + { + context: { key: 'foo' }, + }, + {}, + { + status: 'completed', + }, + ) + + expect(client.track).not.toHaveBeenCalled() }) }) diff --git a/sdk/highlight-run/src/__tests__/integrations.test.ts b/sdk/highlight-run/src/__tests__/integrations.test.ts new file mode 100644 index 000000000..ce8955783 --- /dev/null +++ b/sdk/highlight-run/src/__tests__/integrations.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import * as amplitudeIntegration from '../__mocks__/integrations' +import * as mixpanelIntegration from '../__mocks__/integrations' +import * as segmentIntegration from '../__mocks__/integrations' + +describe('Integrations', () => { + // Mock console to prevent noise + const originalConsole = global.console + + beforeEach(() => { + global.console = { + ...console, + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + }) + + afterEach(() => { + global.console = originalConsole + vi.clearAllMocks() + }) + + describe('Amplitude Integration', () => { + it('should create Amplitude integration', () => { + const integration = + amplitudeIntegration.createAmplitudeIntegration() + + expect(integration).toBeDefined() + expect(integration.name).toBe('Amplitude') + expect(integration.register).toBeTypeOf('function') + }) + + it('should register with Amplitude client', () => { + const integration = + amplitudeIntegration.createAmplitudeIntegration() + + const amplitudeClient = { + logEvent: vi.fn(), + setUserId: vi.fn(), + setUserProperties: vi.fn(), + init: vi.fn(), + } + + // Register Amplitude + integration.register(amplitudeClient as any) + + // Original methods should be stored + expect((integration as any)._originalLogEvent).toBeDefined() + expect((integration as any)._originalSetUserId).toBeDefined() + expect( + (integration as any)._originalSetUserProperties, + ).toBeDefined() + + // Methods should be replaced with our versions + expect(amplitudeClient.logEvent).not.toBe( + (integration as any)._originalLogEvent, + ) + expect(amplitudeClient.setUserId).not.toBe( + (integration as any)._originalSetUserId, + ) + expect(amplitudeClient.setUserProperties).not.toBe( + (integration as any)._originalSetUserProperties, + ) + }) + + it('should handle Amplitude events and pass to Highlight', () => { + const integration = + amplitudeIntegration.createAmplitudeIntegration() + + // Mock Highlight methods + const highlightMethods = { + addProperties: vi.fn(), + identify: vi.fn(), + } + + // Mock Amplitude client + const amplitudeClient = { + logEvent: vi.fn(), + setUserId: vi.fn(), + setUserProperties: vi.fn(), + init: vi.fn(), + } + + // Register with mock Highlight methods + integration.onHighlight(highlightMethods as any) + + // Register Amplitude + integration.register(amplitudeClient as any) + + // Test event tracking + const event = 'test_event' + const properties = { key: 'value' } + amplitudeClient.logEvent(event, properties) + + // Should call Highlight's addProperties + expect(highlightMethods.addProperties).toHaveBeenCalledWith( + event, + properties, + ) + + // Test user identification + const userId = 'user123' + amplitudeClient.setUserId(userId) + + // Should call Highlight's identify + expect(highlightMethods.identify).toHaveBeenCalledWith(userId, {}) + + // Test user properties + const userProps = { role: 'admin', plan: 'premium' } + amplitudeClient.setUserProperties(userProps) + + // Should call Highlight's identify with user properties + expect(highlightMethods.identify).toHaveBeenCalledWith( + expect.any(String), + userProps, + ) + }) + + it('should handle invalid Amplitude clients', () => { + const integration = + amplitudeIntegration.createAmplitudeIntegration() + + // Should not throw when registering null + expect(() => integration.register(null as any)).not.toThrow() + + // Should not throw when registering incomplete client + expect(() => integration.register({} as any)).not.toThrow() + }) + }) + + describe('Mixpanel Integration', () => { + it('should create Mixpanel integration', () => { + const integration = mixpanelIntegration.createMixpanelIntegration() + + expect(integration).toBeDefined() + expect(integration.name).toBe('Mixpanel') + expect(integration.register).toBeTypeOf('function') + }) + + it('should register with Mixpanel client', () => { + const integration = mixpanelIntegration.createMixpanelIntegration() + + const mixpanelClient = { + track: vi.fn(), + identify: vi.fn(), + people: { + set: vi.fn(), + }, + } + + // Register Mixpanel + integration.register(mixpanelClient as any) + + // Original methods should be stored + expect((integration as any)._originalTrack).toBeDefined() + expect((integration as any)._originalIdentify).toBeDefined() + expect((integration as any)._originalPeopleSet).toBeDefined() + + // Methods should be replaced with our versions + expect(mixpanelClient.track).not.toBe( + (integration as any)._originalTrack, + ) + expect(mixpanelClient.identify).not.toBe( + (integration as any)._originalIdentify, + ) + expect(mixpanelClient.people.set).not.toBe( + (integration as any)._originalPeopleSet, + ) + }) + + it('should handle Mixpanel events and pass to Highlight', () => { + const integration = mixpanelIntegration.createMixpanelIntegration() + + // Mock Highlight methods + const highlightMethods = { + addProperties: vi.fn(), + identify: vi.fn(), + } + + // Mock Mixpanel client + const mixpanelClient = { + track: vi.fn(), + identify: vi.fn(), + people: { + set: vi.fn(), + }, + } + + // Register with mock Highlight methods + integration.onHighlight(highlightMethods as any) + + // Register Mixpanel + integration.register(mixpanelClient as any) + + // Test event tracking + const event = 'test_event' + const properties = { key: 'value' } + mixpanelClient.track(event, properties) + + // Should call Highlight's addProperties + expect(highlightMethods.addProperties).toHaveBeenCalledWith( + event, + properties, + ) + + // Test user identification + const userId = 'user123' + mixpanelClient.identify(userId) + + // Should call Highlight's identify + expect(highlightMethods.identify).toHaveBeenCalledWith(userId, {}) + + // Test user properties + const userProps = { role: 'admin', plan: 'premium' } + mixpanelClient.people.set(userProps) + + // Should call Highlight's identify with user properties + expect(highlightMethods.identify).toHaveBeenCalledWith( + expect.any(String), + userProps, + ) + }) + + it('should handle invalid Mixpanel clients', () => { + const integration = mixpanelIntegration.createMixpanelIntegration() + + // Should not throw when registering null + expect(() => integration.register(null as any)).not.toThrow() + + // Should not throw when registering incomplete client + expect(() => integration.register({} as any)).not.toThrow() + + // Should not throw when registering client with missing people + expect(() => + integration.register({ + track: vi.fn(), + identify: vi.fn(), + } as any), + ).not.toThrow() + }) + }) + + describe('Segment Integration', () => { + it('should create Segment integration', () => { + const integration = segmentIntegration.createSegmentIntegration() + + expect(integration).toBeDefined() + expect(integration.name).toBe('Segment') + expect(integration.register).toBeTypeOf('function') + }) + + it('should register with Segment client', () => { + const integration = segmentIntegration.createSegmentIntegration() + + const segmentClient = { + track: vi.fn(), + identify: vi.fn(), + } + + // Register Segment + integration.register(segmentClient as any) + + // Original methods should be stored + expect((integration as any)._originalTrack).toBeDefined() + expect((integration as any)._originalIdentify).toBeDefined() + + // Methods should be replaced with our versions + expect(segmentClient.track).not.toBe( + (integration as any)._originalTrack, + ) + expect(segmentClient.identify).not.toBe( + (integration as any)._originalIdentify, + ) + }) + + it('should handle Segment events and pass to Highlight', () => { + const integration = segmentIntegration.createSegmentIntegration() + + // Mock Highlight methods + const highlightMethods = { + addProperties: vi.fn(), + identify: vi.fn(), + } + + // Mock Segment client + const segmentClient = { + track: vi.fn(), + identify: vi.fn(), + } + + // Register with mock Highlight methods + integration.onHighlight(highlightMethods as any) + + // Register Segment + integration.register(segmentClient as any) + + // Test event tracking + const event = 'test_event' + const properties = { key: 'value' } + segmentClient.track(event, properties) + + // Should call Highlight's addProperties + expect(highlightMethods.addProperties).toHaveBeenCalledWith( + event, + properties, + ) + + // Test user identification + const userId = 'user123' + const traits = { name: 'Test User', email: 'test@example.com' } + segmentClient.identify(userId, traits) + + // Should call Highlight's identify + expect(highlightMethods.identify).toHaveBeenCalledWith( + userId, + traits, + ) + }) + + it('should handle invalid Segment clients', () => { + const integration = segmentIntegration.createSegmentIntegration() + + // Should not throw when registering null + expect(() => integration.register(null as any)).not.toThrow() + + // Should not throw when registering incomplete client + expect(() => integration.register({} as any)).not.toThrow() + }) + }) +}) diff --git a/sdk/highlight-run/src/__tests__/plugins.test.ts b/sdk/highlight-run/src/__tests__/plugins.test.ts new file mode 100644 index 000000000..26339421a --- /dev/null +++ b/sdk/highlight-run/src/__tests__/plugins.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import * as observePlugin from '../__mocks__/plugins' +import * as recordPlugin from '../__mocks__/plugins' +import * as common from '../__mocks__/plugins' + +describe('Plugins', () => { + const originalWindow = global.window + const originalDocument = global.document + + beforeEach(() => { + // Mock window + global.window = { + ...originalWindow, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + location: { + href: 'https://example.com', + hostname: 'example.com', + }, + } as any + + // Mock document + global.document = { + ...originalDocument, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + readyState: 'complete', + } as any + }) + + afterEach(() => { + global.window = originalWindow + global.document = originalDocument + vi.clearAllMocks() + }) + + describe('Common Plugin', () => { + it('should provide plugin versions', () => { + const version = common.getPluginVersion() + expect(version).toBeDefined() + expect(version).toBeTypeOf('string') + }) + + it('should initialize plugin with default options', () => { + const plugin = common.createPlugin({}) + expect(plugin).toBeDefined() + expect(plugin.name).toBeDefined() + expect(plugin.version).toBeDefined() + }) + + it('should initialize plugin with custom options', () => { + const customOptions = { + debug: true, + environmentKey: 'test-environment', + } + + const plugin = common.createPlugin(customOptions) + expect(plugin.options).toMatchObject(customOptions) + }) + + it('should validate plugin name and version', () => { + const plugin = common.createPlugin({}) + expect(plugin.name).toBeTruthy() + expect(plugin.version).toBeTruthy() + }) + }) + + describe('Observe Plugin', () => { + it('should create an observe plugin', () => { + const plugin = observePlugin.createObservePlugin({ + projectId: 'test-project', + debug: true, + }) + + expect(plugin).toBeDefined() + expect(plugin.name).toBe('@highlight-run/observe') + expect(plugin.load).toBeTypeOf('function') + expect(plugin.setup).toBeTypeOf('function') + }) + + it('should validate observe plugin required options', () => { + expect(() => { + observePlugin.createObservePlugin({} as any) + }).toThrow() + + expect(() => { + observePlugin.createObservePlugin({ + projectId: 'test-project', + }) + }).not.toThrow() + }) + + it('should initialize observe plugin with session ID', () => { + const plugin = observePlugin.createObservePlugin({ + projectId: 'test-project', + sessionId: 'test-session', + }) + + expect(plugin.options.sessionId).toBe('test-session') + }) + + it('should initialize observe plugin with OTLP endpoint', () => { + const plugin = observePlugin.createObservePlugin({ + projectId: 'test-project', + otlpEndpoint: 'https://custom-otlp.example.com', + }) + + expect(plugin.options.otlpEndpoint).toBe( + 'https://custom-otlp.example.com', + ) + }) + + it('should handle observe plugin lifecycle methods', () => { + const plugin = observePlugin.createObservePlugin({ + projectId: 'test-project', + }) + + // Mock internal methods + const loadSpy = vi.fn() + const setupSpy = vi.fn() + + // Replace methods with spies + plugin._load = loadSpy + plugin._setup = setupSpy + + // Call lifecycle methods + plugin.load({} as any) + plugin.setup() + + expect(loadSpy).toHaveBeenCalled() + expect(setupSpy).toHaveBeenCalled() + }) + }) + + describe('Record Plugin', () => { + it('should create a record plugin', () => { + const plugin = recordPlugin.createRecordPlugin({ + projectId: 'test-project', + }) + + expect(plugin).toBeDefined() + expect(plugin.name).toBe('@highlight-run/record') + expect(plugin.load).toBeTypeOf('function') + expect(plugin.setup).toBeTypeOf('function') + }) + + it('should validate record plugin required options', () => { + expect(() => { + recordPlugin.createRecordPlugin({} as any) + }).toThrow() + + expect(() => { + recordPlugin.createRecordPlugin({ + projectId: 'test-project', + }) + }).not.toThrow() + }) + + it('should initialize record plugin with session ID', () => { + const plugin = recordPlugin.createRecordPlugin({ + projectId: 'test-project', + sessionId: 'test-session', + }) + + expect(plugin.options.sessionId).toBe('test-session') + }) + + it('should initialize record plugin with custom backend URL', () => { + const plugin = recordPlugin.createRecordPlugin({ + projectId: 'test-project', + backendUrl: 'https://custom-api.example.com', + }) + + expect(plugin.options.backendUrl).toBe( + 'https://custom-api.example.com', + ) + }) + + it('should handle record plugin lifecycle methods', () => { + const plugin = recordPlugin.createRecordPlugin({ + projectId: 'test-project', + }) + + // Mock internal methods + const loadSpy = vi.fn() + const setupSpy = vi.fn() + + // Replace methods with spies + plugin._load = loadSpy + plugin._setup = setupSpy + + // Call lifecycle methods + plugin.load({} as any) + plugin.setup() + + expect(loadSpy).toHaveBeenCalled() + expect(setupSpy).toHaveBeenCalled() + }) + + it('should initialize record plugin with network recording options', () => { + const plugin = recordPlugin.createRecordPlugin({ + projectId: 'test-project', + networkRecording: false, + }) + + expect(plugin.options.networkRecording).toBe(false) + + const pluginWithNetworkRecording = recordPlugin.createRecordPlugin({ + projectId: 'test-project', + networkRecording: true, + }) + + expect(pluginWithNetworkRecording.options.networkRecording).toBe( + true, + ) + }) + }) +}) diff --git a/sdk/highlight-run/src/__tests__/sdk.test.ts b/sdk/highlight-run/src/__tests__/sdk.test.ts new file mode 100644 index 000000000..b025113b3 --- /dev/null +++ b/sdk/highlight-run/src/__tests__/sdk.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { LDObserve } from '../sdk/LDObserve' +import { LDRecord } from '../sdk/LDRecord' +import type { OTelMetric as Metric } from '../client/types/types' +import type { Attributes } from '@opentelemetry/api' +import { ObserveSDK } from '../sdk/observe' +import { RecordSDK } from '../sdk/record' + +describe('SDK', () => { + let observe: typeof LDObserve + let record: typeof LDRecord + let observeImpl: ObserveSDK + let recordImpl: RecordSDK + + beforeEach(() => { + // Reset the instances before each test + observe = LDObserve + record = LDRecord + observeImpl = new ObserveSDK({ + backendUrl: 'https://pub.highlight.io', + otlpEndpoint: 'https://otel.highlight.io', + projectId: '1', + sessionSecureId: 'test-session', + environment: 'test', + }) + recordImpl = new RecordSDK({ + organizationID: '1', + environment: 'test', + sessionSecureID: 'test-session', + }) + observe.load(observeImpl) + record.load(recordImpl) + }) + + describe('Record Methods', () => { + it('should handle start and stop', async () => { + const mockStart = vi.spyOn(recordImpl, 'start') + const mockStop = vi.spyOn(recordImpl, 'stop') + + await record.start() + record.stop() + + expect(mockStart).toHaveBeenCalled() + expect(mockStop).toHaveBeenCalled() + }) + + it('should handle snapshot', async () => { + const mockSnapshot = vi.spyOn(recordImpl, 'snapshot') + + const canvas = document.createElement('canvas') + await record.snapshot(canvas) + + expect(mockSnapshot).toHaveBeenCalledWith(canvas) + }) + }) + + describe('Observe Methods', () => { + it('should handle metric recording', async () => { + const mockRecordGauge = vi.spyOn(observeImpl, 'recordGauge') + const mockRecordCount = vi.spyOn(observeImpl, 'recordCount') + const mockRecordIncr = vi.spyOn(observeImpl, 'recordIncr') + const mockRecordHistogram = vi.spyOn(observeImpl, 'recordHistogram') + const mockRecordUpDownCounter = vi.spyOn( + observeImpl, + 'recordUpDownCounter', + ) + + const metric: Metric = { + name: 'test.metric', + value: 100, + attributes: { test: 'value' }, + } + + observe.recordGauge(metric) + observe.recordCount(metric) + observe.recordIncr({ + name: 'test.metric', + attributes: { test: 'value' } as Attributes, + }) + observe.recordHistogram(metric) + observe.recordUpDownCounter(metric) + + expect(mockRecordGauge).toHaveBeenCalledWith(metric) + expect(mockRecordCount).toHaveBeenCalledWith(metric) + expect(mockRecordIncr).toHaveBeenCalledWith({ + name: 'test.metric', + attributes: { test: 'value' } as Attributes, + }) + expect(mockRecordHistogram).toHaveBeenCalledWith(metric) + expect(mockRecordUpDownCounter).toHaveBeenCalledWith(metric) + }) + + it('should handle error recording', async () => { + const mockRecordError = vi.spyOn(observeImpl, 'recordError') + + const error = new Error('Test error') + const payload = { errorCode: 'E123' } + + observe.recordError(error, 'Error message', payload) + + expect(mockRecordError).toHaveBeenCalledWith( + error, + 'Error message', + payload, + undefined, + undefined, + ) + }) + }) +}) diff --git a/sdk/highlight-run/src/client/listeners/network-listener/utils/utils.test.ts b/sdk/highlight-run/src/__tests__/utils.test.ts similarity index 99% rename from sdk/highlight-run/src/client/listeners/network-listener/utils/utils.test.ts rename to sdk/highlight-run/src/__tests__/utils.test.ts index 0fadfd6d2..c98e47461 100644 --- a/sdk/highlight-run/src/client/listeners/network-listener/utils/utils.test.ts +++ b/sdk/highlight-run/src/__tests__/utils.test.ts @@ -2,7 +2,7 @@ import { normalizeUrl, shouldNetworkRequestBeRecorded, shouldNetworkRequestBeTraced, -} from './utils' +} from '../client/listeners/network-listener/utils/utils' describe('normalizeUrl', () => { vi.stubGlobal('location', { diff --git a/sdk/highlight-run/src/api/observe.ts b/sdk/highlight-run/src/api/observe.ts new file mode 100644 index 000000000..977625611 --- /dev/null +++ b/sdk/highlight-run/src/api/observe.ts @@ -0,0 +1,158 @@ +import type { Attributes, Context, Span, SpanOptions } from '@opentelemetry/api' +import type { LDClientMin } from '../client' +import type { OTelMetric as Metric } from '../client/types/types' +import type { ErrorMessageType } from '../client/types/shared-types' +import { LDPluginEnvironmentMetadata } from '../plugins/plugin' +import { ConsoleMethods } from '../client/types/client' +import { Hook } from '../integrations/launchdarkly' + +export interface Observe { + recordLog: ( + message: any, + level: ConsoleMethods, + metadata?: Attributes, + ) => void + /** + * Record arbitrary metric values via as a Gauge. + * A Gauge records any point-in-time measurement, such as the current CPU utilization %. + * Values with the same metric name and attributes are aggregated via the OTel SDK. + * See https://opentelemetry.io/docs/specs/otel/metrics/data-model/ for more details. + */ + recordGauge: (metric: Metric) => void + /** + * Record arbitrary metric values via as a Counter. + * A Counter efficiently records an increment in a metric, such as number of cache hits. + * Values with the same metric name and attributes are aggregated via the OTel SDK. + * See https://opentelemetry.io/docs/specs/otel/metrics/data-model/ for more details. + */ + recordCount: (metric: Metric) => void + /** + * Record arbitrary metric values via as a Counter. + * A Counter efficiently records an increment in a metric, such as number of cache hits. + * Values with the same metric name and attributes are aggregated via the OTel SDK. + * See https://opentelemetry.io/docs/specs/otel/metrics/data-model/ for more details. + */ + recordIncr: (metric: Omit) => void + /** + * Record arbitrary metric values via as a Histogram. + * A Histogram efficiently records near-by point-in-time measurement into a bucketed aggregate. + * Values with the same metric name and attributes are aggregated via the OTel SDK. + * See https://opentelemetry.io/docs/specs/otel/metrics/data-model/ for more details. + */ + recordHistogram: (metric: Metric) => void + /** + * Record arbitrary metric values via as a UpDownCounter. + * A UpDownCounter efficiently records an increment or decrement in a metric, such as number of paying customers. + * Values with the same metric name and attributes are aggregated via the OTel SDK. + * See https://opentelemetry.io/docs/specs/otel/metrics/data-model/ for more details. + */ + recordUpDownCounter: (metric: Metric) => void + /** + * Starts a new span for tracing in Highlight. The span will be ended when the + * callback function returns. + * + * @example + * ```typescript + * H.startSpan('span-name', callbackFn) + * ``` + * @example + * ```typescript + * H.startSpan('span-name', options, callbackFn) + * ``` + * @example + * ```typescript + * H.startSpan('span-name', options, context, callbackFn) + * ``` + * @example + * ```typescript + * H.startSpan('span-name', async (span) => { + * span.setAttribute('key', 'value') + * await someAsyncFunction() + * }) + * ``` + * + * @param name The name of the span. + * @param options Options for the span. + * @param context The context for the span. + * @param callbackFn The function to run in the span. + */ + startSpan: { + ReturnType>( + name: string, + fn: F, + ): ReturnType + ReturnType>( + name: string, + options: SpanOptions, + fn: F, + ): ReturnType + ReturnType>( + name: string, + options: SpanOptions, + context: Context, + fn: F, + ): ReturnType + } + /** + * Starts a new span for tracing in Highlight. The span will be ended when the + * `end()` is called on the span. It returns whatever is returned from the + * callback function. + * + * @example + * ```typescript + * H.startManualSpan('span-name', options, (span) => { + * span.addEvent('event-name', { key: 'value' }) + * span.setAttribute('key', 'value') + * await someAsyncFunction() + * span.end() + * }) + * ``` + * + * @example + * ```typescript + * const span = H.startManualSpan('span-name', (s) => s) + * span.addEvent('event-name', { key: 'value' }) + * await someAsyncFunction() + * span.end() + * ``` + * + * @param name The name of the span. + * @param options Options for the span. + * @param context The context for the span. + * @param fn The function to run in the span. + */ + startManualSpan: { + ReturnType>( + name: string, + fn: F, + ): ReturnType + ReturnType>( + name: string, + options: SpanOptions, + fn: F, + ): ReturnType + ReturnType>( + name: string, + options: SpanOptions, + context: Context, + fn: F, + ): ReturnType + } + /** + * Calling this method will report an error in Highlight and map it to the current session being recorded. + * A common use case for `H.error` is calling it right outside of an error boundary. + * @see {@link https://docs.highlight.run/grouping-errors} for more information. + */ + recordError: ( + error: Error, + message?: string, + payload?: { [key: string]: string }, + source?: string, + type?: ErrorMessageType, + ) => void + register( + client: LDClientMin, + environmentMetadata: LDPluginEnvironmentMetadata, + ): void + getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[] +} diff --git a/sdk/highlight-run/src/api/record.ts b/sdk/highlight-run/src/api/record.ts new file mode 100644 index 000000000..9e4e45ac0 --- /dev/null +++ b/sdk/highlight-run/src/api/record.ts @@ -0,0 +1,30 @@ +import type { SessionDetails, StartOptions } from '../client/types/types' +import type { LDClientMin } from '../integrations/launchdarkly/types/LDClient' +import type { LDPluginEnvironmentMetadata } from '../plugins/plugin' +import { Hook } from '../integrations/launchdarkly' + +export interface Record { + /** + * Start the session when running in `manualStart` mode. + * Can be used to force start a new session. + * @param options the session start options. + */ + start: (options?: StartOptions) => Promise + /** + * Stop the session recording. + */ + stop: () => void + /** + * Snapshot an HTML element in WebGL manual snapshotting mode. + * See {@link https://www.highlight.io/docs/getting-started/browser/replay-configuration/canvas#manual-snapshotting} + * for more information. + */ + snapshot: (element: HTMLCanvasElement) => Promise + getSession: () => SessionDetails | null + getRecordingState: () => 'NotRecording' | 'Recording' + register( + client: LDClientMin, + environmentMetadata: LDPluginEnvironmentMetadata, + ): void + getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[] +} diff --git a/sdk/highlight-run/src/client/__tests__/index.test.tsx b/sdk/highlight-run/src/client/__tests__/index.test.tsx deleted file mode 100644 index 15c3cd94a..000000000 --- a/sdk/highlight-run/src/client/__tests__/index.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Highlight } from '../index' -import { LDClientMin } from '../integrations/launchdarkly/types/LDClient' -import { vi } from 'vitest' - -describe('LD integration', () => { - let highlight: Highlight - - beforeEach(() => { - vi.useFakeTimers() - highlight = new Highlight({ - organizationID: '', - sessionSecureID: '', - }) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('should handle register', () => { - const worker = (globalThis.Worker as unknown as typeof Worker).prototype - worker.postMessage = vi.fn( - (_message: unknown, _options?: unknown) => null, - ) - - const client: LDClientMin = { - track: vi.fn(), - identify: vi.fn(), - addHook: vi.fn(), - } - highlight.registerLD(client) - - expect(client.addHook).not.toHaveBeenCalled() - expect(client.identify).not.toHaveBeenCalled() - expect(client.track).not.toHaveBeenCalled() - expect(worker.postMessage).not.toHaveBeenCalled() - - highlight.identify('123', {}) - highlight.addProperties('test', {}) - // noop for launchdarkly - expect(client.identify).not.toHaveBeenCalled() - expect(client.track).toHaveBeenCalled() - expect(worker.postMessage).toHaveBeenCalled() - }) -}) diff --git a/sdk/highlight-run/src/client/__tests__/setup.ts b/sdk/highlight-run/src/client/__tests__/setup.ts deleted file mode 100644 index afe10e0d3..000000000 --- a/sdk/highlight-run/src/client/__tests__/setup.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeAll, vi } from 'vitest' -import 'vitest-canvas-mock' - -vi.mock('import.meta.env.REACT_APP_PUBLIC_GRAPH_URI', () => ({ - default: 'localhost:8082', -})) - -class WorkerStub implements Worker { - postMessage(_message: unknown, _options?: unknown): void { - throw new Error('Method not implemented.') - } - terminate(): void { - throw new Error('Method not implemented.') - } - addEventListener( - _type: unknown, - _listener: unknown, - _options?: unknown, - ): void { - throw new Error('Method not implemented.') - } - removeEventListener( - _type: unknown, - _listener: unknown, - _options?: unknown, - ): void { - throw new Error('Method not implemented.') - } - dispatchEvent(_event: Event): boolean { - throw new Error('Method not implemented.') - } - - onerror!: ((this: AbstractWorker, ev: ErrorEvent) => any) | null - onmessage!: ((this: Worker, ev: MessageEvent) => any) | null - onmessageerror!: ((this: Worker, ev: MessageEvent) => any) | null -} - -beforeAll(() => { - globalThis.Worker = WorkerStub as unknown as typeof Worker -}) diff --git a/sdk/highlight-run/src/client/index.tsx b/sdk/highlight-run/src/client/index.tsx index 7a239b7a1..cabbbe835 100644 --- a/sdk/highlight-run/src/client/index.tsx +++ b/sdk/highlight-run/src/client/index.tsx @@ -70,7 +70,7 @@ import { NetworkPerformancePayload, } from './listeners/network-listener/performance-listener' import { Logger } from './logger' -import { getMeter, getTracer, setupBrowserTracing } from './otel' +import { BROWSER_METER_NAME, getTracer, setupBrowserTracing } from './otel' import { HighlightIframeMessage, HighlightIframeReponse, @@ -97,6 +97,7 @@ import { import { SESSION_STORAGE_KEYS } from './utils/sessionStorage/sessionStorageKeys' import { getItem, + LocalStorageKeys, removeItem, setCookieWriteEnabled, setItem, @@ -112,6 +113,7 @@ import { Counter, Gauge, Histogram, + metrics, UpDownCounter, } from '@opentelemetry/api' import { IntegrationClient } from '../integrations' @@ -123,10 +125,6 @@ export const HighlightWarning = (context: string, msg: any) => { console.warn(`Highlight Warning: (${context}): `, { output: msg }) } -enum LOCAL_STORAGE_KEYS { - CLIENT_ID = 'highlightClientID', -} - export type HighlightClassOptions = { organizationID: number | string debug?: boolean | DebugOptions @@ -608,11 +606,11 @@ export class Highlight { setSessionSecureID('') setSessionData(this.sessionData) - let clientID = getItem(LOCAL_STORAGE_KEYS['CLIENT_ID']) + let clientID = getItem(LocalStorageKeys['CLIENT_ID']) if (!clientID) { clientID = GenerateSecureID() - setItem(LOCAL_STORAGE_KEYS['CLIENT_ID'], clientID) + setItem(LocalStorageKeys['CLIENT_ID'], clientID) } // Duplicate of logic inside FirstLoadListeners.setupNetworkListener, @@ -1217,9 +1215,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, } recordGauge(metric: RecordMetric) { - const meter = typeof getMeter === 'function' ? getMeter() : undefined - if (!meter) return - + const meter = metrics.getMeter(BROWSER_METER_NAME) let gauge = this._gauges.get(metric.name) if (!gauge) { gauge = meter.createGauge(metric.name) @@ -1237,9 +1233,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, } recordCount(metric: RecordMetric) { - const meter = typeof getMeter === 'function' ? getMeter() : undefined - if (!meter) return - + const meter = metrics.getMeter(BROWSER_METER_NAME) let counter = this._counters.get(metric.name) if (!counter) { counter = meter.createCounter(metric.name) @@ -1258,9 +1252,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, } recordHistogram(metric: RecordMetric) { - const meter = typeof getMeter === 'function' ? getMeter() : undefined - if (!meter) return - + const meter = metrics.getMeter(BROWSER_METER_NAME) let histogram = this._histograms.get(metric.name) if (!histogram) { histogram = meter.createHistogram(metric.name) @@ -1275,9 +1267,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, } recordUpDownCounter(metric: RecordMetric) { - const meter = typeof getMeter === 'function' ? getMeter() : undefined - if (!meter) return - + const meter = metrics.getMeter(BROWSER_METER_NAME) let up_down_counter = this._up_down_counters.get(metric.name) if (!up_down_counter) { up_down_counter = meter.createUpDownCounter(metric.name) @@ -1576,12 +1566,11 @@ declare global { defaultDebug: any } } + export { - FirstLoadListeners, GenerateSecureID, getPreviousSessionData, getTracer, - getMeter, MetricCategory, setupBrowserTracing, } diff --git a/sdk/highlight-run/src/client/logger.ts b/sdk/highlight-run/src/client/logger.ts index bd72a117a..3bcb02053 100644 --- a/sdk/highlight-run/src/client/logger.ts +++ b/sdk/highlight-run/src/client/logger.ts @@ -16,4 +16,12 @@ export class Logger { console.log.apply(console, [prefix, ...data]) } } + + warn(...data: any[]) { + let prefix = `[${Date.now()}]` + if (this.name) { + prefix += ` - ${this.name}` + } + console.warn.apply(console, [prefix, ...data]) + } } diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index f052b4eef..f63d0dcfd 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -1,4 +1,5 @@ import * as api from '@opentelemetry/api' +import { Span } from '@opentelemetry/api' import { CompositePropagator, W3CBaggagePropagator, @@ -53,6 +54,8 @@ import { import { IntegrationClient } from '../../integrations' import { LD_METRIC_NAME_DOCUMENT_LOAD } from '../../integrations/launchdarkly' +export type Callback = (span?: Span) => any + export type BrowserTracingConfig = { projectId: string | number sessionSecureId: string @@ -73,6 +76,7 @@ let providers: { } = {} let otelConfig: BrowserTracingConfig | undefined const RECORD_ATTRIBUTE = 'highlight.record' +export const LOG_SPAN_NAME = 'launchdarkly.js.log' export const setupBrowserTracing = (config: BrowserTracingConfig) => { if (providers.tracerProvider !== undefined) { diff --git a/sdk/highlight-run/src/client/types/observe.ts b/sdk/highlight-run/src/client/types/observe.ts new file mode 100644 index 000000000..85f6f5c09 --- /dev/null +++ b/sdk/highlight-run/src/client/types/observe.ts @@ -0,0 +1,64 @@ +import type { + ConsoleMethods, + NetworkRecordingOptions, + OtelOptions, +} from './client' +import type { CommonOptions } from './types' + +export declare type ObserveOptions = CommonOptions & { + /** + * Specifies where the backend of the app lives. If specified, Highlight will attach the + * X-Highlight-Request header to outgoing requests whose destination URLs match a substring + * or regexp from this list, so that backend errors can be linked back to the session. + * If 'true' is specified, all requests to the current domain will be matched. + * @example tracingOrigins: ['localhost', /^\//, 'backend.myapp.com'] + */ + tracingOrigins?: boolean | (string | RegExp)[] + /** + * Specifies how and what Highlight records from network requests and responses. + */ + networkRecording?: boolean | NetworkRecordingOptions + /** + * Specifies whether Highlight will record console messages. + * @default false + */ + disableConsoleRecording?: boolean + /** + * Specifies whether Highlight will report `console.error` invocations as Highlight Errors. + * @default false + */ + reportConsoleErrors?: boolean + /** + * Specifies which console methods to record. + * The value here will be ignored if `disabledConsoleRecording` is `true`. + * @default All console methods. + * @example consoleMethodsToRecord: ['log', 'info', 'error'] + */ + consoleMethodsToRecord?: ConsoleMethods[] + /** + * Specifies whether to record performance metrics (e.g. FPS, device memory). + * @default true + */ + enablePerformanceRecording?: boolean + /** + * Specifies the environment your application is running in. + * This is useful to distinguish whether your session was recorded on localhost or in production. + * @default 'production' + */ + environment?: 'development' | 'staging' | 'production' | string + /** + * Specifies whether window.Promise should be patched + * to record the stack trace of promise rejections. + * @default true + */ + enablePromisePatch?: boolean + /** + * OTLP options for OpenTelemetry tracing. Instrumentations are enabled by default. + */ + otel?: OtelOptions & { + /** + * OTLP HTTP endpoint for OpenTelemetry tracing. + */ + otlpEndpoint?: string + } +} diff --git a/sdk/highlight-run/src/client/types/record.ts b/sdk/highlight-run/src/client/types/record.ts new file mode 100644 index 000000000..4840de1f5 --- /dev/null +++ b/sdk/highlight-run/src/client/types/record.ts @@ -0,0 +1,103 @@ +import type { IntegrationOptions, SessionShortcutOptions } from './client' +import type { + CommonOptions, + PrivacySettingOption, + SamplingStrategy, +} from './types' + +export declare type RecordOptions = CommonOptions & { + /** + * Specifies where the backend of the app lives. If specified, Highlight will attach the + * X-Highlight-Request header to outgoing requests whose destination URLs match a substring + * or regexp from this list, so that backend errors can be linked back to the session. + * If 'true' is specified, all requests to the current domain will be matched. + * @example tracingOrigins: ['localhost', /^\//, 'backend.myapp.com'] + */ + tracingOrigins?: boolean | (string | RegExp)[] + /** + * Specifies if Highlight should not automatically start recording when the app starts. + * This should be used with `H.start()` and `H.stop()` if you want to control when Highlight records. + * @default false + */ + manualStart?: boolean + /** + * If set, Highlight will not record when your app is not visible (in the background). + * By default, Highlight will record in the background. + * @default false + */ + disableBackgroundRecording?: boolean + enableSegmentIntegration?: boolean + /** + * Specifies the environment your application is running in. + * This is useful to distinguish whether your session was recorded on localhost or in production. + * @default 'production' + */ + environment?: 'development' | 'staging' | 'production' | string + /** + * Specifies how much data Highlight should redact during recording. + * strict - Highlight will redact all text data on the page. + * default - Highlight will redact text data on the page that is associated with personal identifiable data. + * none - Highlight will not redact any text data on the page. + * // Redacted text will be randomized. Instead of seeing "Hello World" in a recording, you will see "1fds1 j59a0". + * @see {@link https://docs.highlight.run/docs/privacy} for more information. + */ + privacySetting?: PrivacySettingOption + + /** + * Specifies whether to record canvas elements or not. + * @default false + */ + enableCanvasRecording?: boolean + /** + * Configure the recording sampling options, eg. how frequently we record canvas updates. + */ + samplingStrategy?: SamplingStrategy + /** + * Specifies whether to inline images into the recording. + * This means that images that are local to the client (eg. client-generated blob: urls) + * will be serialized into the recording and will be valid on replay. + * This will also use canvas snapshotting to inline