@brainfish-ai/web-tracker 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
File without changes
@@ -0,0 +1,2 @@
1
+ declare function captureScreenshot(domBody: HTMLElement): Promise<string>;
2
+ export { captureScreenshot };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ declare function redactTextContent(text: string): string;
2
+ declare function redactPIIInDOM(element: Element): void;
3
+ export { redactPIIInDOM, redactTextContent };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ declare function screenshot(): Promise<string | undefined>;
2
+ export { screenshot };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@brainfish-ai/web-tracker",
3
+ "version": "0.0.1-alpha.0",
4
+ "main": "dist/index.js",
5
+ "description": "Brainfish Tracker for Web",
6
+ "private": false,
7
+ "scripts": {
8
+ "start": "vite",
9
+ "dev": "vite",
10
+ "build": "vite build",
11
+ "serve": "vite preview --port 6006",
12
+ "test": "vitest"
13
+ },
14
+ "license": "MIT",
15
+ "devDependencies": {
16
+ "rollup-plugin-terser": "^7.0.2",
17
+ "rollup-plugin-visualizer": "^5.12.0",
18
+ "typescript": "^5.4.5",
19
+ "vite": "^5.2.12",
20
+ "vite-plugin-compression": "^0.5.1",
21
+ "vite-plugin-dts": "^4.0.3"
22
+ },
23
+ "dependencies": {
24
+ "@brainfish-ai/tracker-sdk": "*",
25
+ "html-to-image": "^1.11.11",
26
+ "redact-pii": "^3.4.0",
27
+ "rrweb": "^2.0.0-alpha.4"
28
+ },
29
+ "keywords": [
30
+ "typescript",
31
+ "web",
32
+ "brainfish",
33
+ "in-app",
34
+ "tracker"
35
+ ]
36
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
package/src/cdn.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { Tracker } from './index';
2
+
3
+ declare global {
4
+ interface Window {
5
+ tracker: {
6
+ q?: [string, ...any[]];
7
+ (method: string, ...args: any[]): void;
8
+ };
9
+ }
10
+ }
11
+
12
+ ((window) => {
13
+ if (window.tracker && 'q' in window.tracker) {
14
+ const queue = window.tracker.q || [];
15
+ const tracker = new Tracker(queue.shift()[1]) as any;
16
+ queue.forEach((item) => {
17
+ if (item[0] in tracker) {
18
+ tracker[item[0]](...item.slice(1));
19
+ }
20
+ });
21
+
22
+ window.tracker = (t, ...args) => {
23
+ const fn = tracker[t] ? tracker[t].bind(tracker) : undefined;
24
+ if (typeof fn === 'function') {
25
+ fn(...args);
26
+ } else {
27
+ console.warn(`tracker.js: ${t} is not a function`);
28
+ }
29
+ };
30
+ }
31
+ })(window);
package/src/index.ts ADDED
@@ -0,0 +1,190 @@
1
+ /* eslint-disable @typescript-eslint/unbound-method */
2
+
3
+ import type {
4
+ TrackerSdkOptions,
5
+ TrackProperties,
6
+ } from '@brainfish-ai/tracker-sdk';
7
+ import { TrackerSdk } from '@brainfish-ai/tracker-sdk';
8
+ import { screenshot } from './utils/snapshot';
9
+
10
+ export type * from '@brainfish-ai/tracker-sdk';
11
+
12
+ export type TrackerOptions = TrackerSdkOptions & {
13
+ trackOutgoingLinks?: boolean;
14
+ trackScreenViews?: boolean;
15
+ trackAttributes?: boolean;
16
+ trackHashChanges?: boolean;
17
+ };
18
+
19
+ function toCamelCase(str: string) {
20
+ return str.replace(/([-_][a-z])/gi, ($1) =>
21
+ $1.toUpperCase().replace('-', '').replace('_', ''),
22
+ );
23
+ }
24
+
25
+ export class Tracker extends TrackerSdk {
26
+ private lastPath = '';
27
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
28
+
29
+ constructor(public options: TrackerOptions) {
30
+ super({
31
+ sdk: 'web',
32
+ sdkVersion: __PACKAGE_VERSION__,
33
+ ...options,
34
+ });
35
+
36
+ if (!this.isServer()) {
37
+ this.setGlobalProperties({
38
+ __referrer: document.referrer,
39
+ });
40
+
41
+ if (this.options.trackOutgoingLinks) {
42
+ this.trackOutgoingLinks();
43
+ }
44
+
45
+ if (this.options.trackScreenViews) {
46
+ this.trackScreenViews();
47
+ }
48
+
49
+ if (this.options.trackAttributes) {
50
+ this.trackAttributes();
51
+ }
52
+ }
53
+ }
54
+
55
+ private debounce(func: () => void, delay: number) {
56
+ this.debounceTimer && clearTimeout(this.debounceTimer);
57
+ this.debounceTimer = setTimeout(func, delay);
58
+ }
59
+
60
+ private isServer() {
61
+ return typeof document === 'undefined';
62
+ }
63
+
64
+ public trackOutgoingLinks() {
65
+ if (this.isServer()) {
66
+ return;
67
+ }
68
+
69
+ document.addEventListener('click', (event) => {
70
+ const target = event.target as HTMLElement;
71
+ const link = target.closest('a');
72
+ if (link && target) {
73
+ const href = link.getAttribute('href');
74
+ if (href?.startsWith('http')) {
75
+ super.track('link_out', {
76
+ href,
77
+ text:
78
+ link.innerText ||
79
+ link.getAttribute('title') ||
80
+ target.getAttribute('alt') ||
81
+ target.getAttribute('title'),
82
+ });
83
+ }
84
+ }
85
+ });
86
+ }
87
+
88
+ public trackScreenViews() {
89
+ if (this.isServer()) {
90
+ return;
91
+ }
92
+
93
+ this.screenView();
94
+
95
+ const oldPushState = history.pushState;
96
+ history.pushState = function pushState(...args) {
97
+ const ret = oldPushState.apply(this, args);
98
+ window.dispatchEvent(new Event('pushstate'));
99
+ window.dispatchEvent(new Event('locationchange'));
100
+ return ret;
101
+ };
102
+
103
+ const oldReplaceState = history.replaceState;
104
+ history.replaceState = function replaceState(...args) {
105
+ const ret = oldReplaceState.apply(this, args);
106
+ window.dispatchEvent(new Event('replacestate'));
107
+ window.dispatchEvent(new Event('locationchange'));
108
+ return ret;
109
+ };
110
+
111
+ window.addEventListener('popstate', function () {
112
+ window.dispatchEvent(new Event('locationchange'));
113
+ });
114
+
115
+ const eventHandler = () => this.debounce(() => this.screenView(), 50);
116
+
117
+ if (this.options.trackHashChanges) {
118
+ window.addEventListener('hashchange', eventHandler);
119
+ } else {
120
+ window.addEventListener('locationchange', eventHandler);
121
+ }
122
+ }
123
+
124
+ public trackAttributes() {
125
+ if (this.isServer()) {
126
+ return;
127
+ }
128
+
129
+ document.addEventListener('click', (event) => {
130
+ const target = event.target as HTMLElement;
131
+ const btn = target.closest('button');
132
+ const anchor = target.closest('a');
133
+ const element = btn?.getAttribute('data-track')
134
+ ? btn
135
+ : anchor?.getAttribute('data-track')
136
+ ? anchor
137
+ : null;
138
+ if (element) {
139
+ const properties: Record<string, unknown> = {};
140
+ for (const attr of element.attributes) {
141
+ if (attr.name.startsWith('data-') && attr.name !== 'data-track') {
142
+ properties[toCamelCase(attr.name.replace(/^data-/, ''))] =
143
+ attr.value;
144
+ }
145
+ }
146
+ const name = element.getAttribute('data-track');
147
+ if (name) {
148
+ super.track(name, properties);
149
+ }
150
+ }
151
+ });
152
+ }
153
+
154
+ async screenView(properties?: TrackProperties): Promise<void>;
155
+ async screenView(path: string, properties?: TrackProperties): Promise<void>;
156
+ async screenView(
157
+ pathOrProperties?: string | TrackProperties,
158
+ propertiesOrUndefined?: TrackProperties,
159
+ ): Promise<void> {
160
+ if (this.isServer()) {
161
+ return;
162
+ }
163
+
164
+ let path: string;
165
+ let properties: TrackProperties | undefined;
166
+
167
+ if (typeof pathOrProperties === 'string') {
168
+ path = pathOrProperties;
169
+ properties = propertiesOrUndefined;
170
+ } else {
171
+ path = window.location.href;
172
+ properties = pathOrProperties;
173
+ }
174
+
175
+ if (this.lastPath === path) {
176
+ return;
177
+ }
178
+
179
+ // capture screenshot
180
+ const snapshot = await screenshot();
181
+
182
+ this.lastPath = path;
183
+ super.track('screen_view', {
184
+ ...(properties ?? {}),
185
+ screenshot: snapshot,
186
+ __path: path,
187
+ __title: document.title,
188
+ });
189
+ }
190
+ }
@@ -0,0 +1 @@
1
+ // Setup for Jest tests
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { captureScreenshot } from './capture';
3
+ import { toPng } from 'html-to-image';
4
+
5
+ // Mock the html-to-image module
6
+ vi.mock('html-to-image', () => ({
7
+ toPng: vi.fn(),
8
+ }));
9
+
10
+ describe('captureScreenshot', () => {
11
+ let mockDomBody: HTMLElement;
12
+
13
+ beforeEach(() => {
14
+ // Create a mock DOM body
15
+ mockDomBody = document.createElement('div');
16
+ document.body.appendChild(mockDomBody);
17
+
18
+ // Mock document properties
19
+ vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(1000);
20
+ vi.spyOn(document.documentElement, 'scrollWidth', 'get').mockReturnValue(800);
21
+
22
+ // Mock styleSheets
23
+ Object.defineProperty(document, 'styleSheets', {
24
+ value: [
25
+ {
26
+ cssRules: [
27
+ { cssText: 'body { background-color: white; }' },
28
+ { cssText: 'h1 { color: black; }' },
29
+ ],
30
+ },
31
+ ],
32
+ configurable: true,
33
+ });
34
+
35
+ // Mock toPng function
36
+ vi.mocked(toPng).mockResolvedValue('mock-image-data');
37
+ });
38
+
39
+ afterEach(() => {
40
+ document.body.removeChild(mockDomBody);
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ it('should capture a screenshot of the entire page', async () => {
45
+ const result = await captureScreenshot(mockDomBody);
46
+
47
+ expect(result).toBe('mock-image-data');
48
+ expect(toPng).toHaveBeenCalledWith(mockDomBody, expect.objectContaining({
49
+ quality: 0.7,
50
+ width: 800,
51
+ height: 1000,
52
+ style: { transform: 'scale(1)' },
53
+ skipFonts: true,
54
+ cacheBust: true,
55
+ backgroundColor: 'white',
56
+ fetchRequestInit: { mode: 'cors' },
57
+ }));
58
+ });
59
+
60
+ it('should handle errors when accessing stylesheet rules', async () => {
61
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
62
+
63
+ // Mock styleSheets to throw an error
64
+ Object.defineProperty(document, 'styleSheets', {
65
+ get: () => ({
66
+ [Symbol.iterator]: function* () {
67
+ yield {
68
+ cssRules: {
69
+ [Symbol.iterator]: function* () {
70
+ throw new Error('Access denied');
71
+ },
72
+ },
73
+ };
74
+ },
75
+ }),
76
+ configurable: true,
77
+ });
78
+
79
+ await captureScreenshot(mockDomBody);
80
+
81
+ expect(consoleLogSpy).toHaveBeenCalledWith('Error accessing stylesheet rules:', expect.any(Error));
82
+ });
83
+ });
@@ -0,0 +1,51 @@
1
+ import { toPng } from 'html-to-image';
2
+
3
+ // Function to capture a screenshot of the entire page
4
+ async function captureScreenshot(domBody: HTMLElement): Promise<string> {
5
+ const height = document.documentElement.scrollHeight;
6
+ const width = document.documentElement.scrollWidth;
7
+
8
+ // Collect all stylesheet rules
9
+ const styles = Array.from(document.styleSheets)
10
+ .map(styleSheet => {
11
+ try {
12
+ return Array.from(styleSheet.cssRules)
13
+ .map(rule => rule.cssText)
14
+ .join('');
15
+ } catch (e) {
16
+ console.log('Error accessing stylesheet rules:', e);
17
+ return '';
18
+ }
19
+ })
20
+ .join('\n');
21
+
22
+ // Create a new style element with all the rules
23
+ const styleElement = document.createElement('style');
24
+ styleElement.textContent = styles;
25
+ domBody.appendChild(styleElement);
26
+
27
+ // Capture the screenshot
28
+ const image = await toPng(domBody, {
29
+ quality: 0.7, // Set quality to 0.7
30
+ width, // Set width to the scroll width
31
+ height, // Set height to the scroll height
32
+ style: {
33
+ transform: 'scale(1)', // Set scale to 1 to avoid scaling
34
+ },
35
+ skipFonts: true, // Avoid embedding web fonts to bypass SecurityError
36
+ cacheBust: true, // Prevent caching
37
+ backgroundColor: 'white', // Set background color to white
38
+ fetchRequestInit: {
39
+ mode: 'cors', // Enable CORS
40
+ },
41
+ })
42
+
43
+ // Remove the style element
44
+ domBody.removeChild(styleElement);
45
+
46
+ return image;
47
+ }
48
+
49
+ export { captureScreenshot };
50
+
51
+
@@ -0,0 +1,74 @@
1
+ import { redactTextContent, redactPIIInDOM } from './redact';
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+
4
+ describe('redactTextContent', () => {
5
+ it('should redact email addresses', () => {
6
+ const text = 'Contact me at john.doe@example.com';
7
+ const redacted = redactTextContent(text);
8
+ expect(redacted).toBe('Contact me at EMAIL_ADDRESS');
9
+ });
10
+
11
+ it('should redact phone numbers', () => {
12
+ const text = 'My phone number is 123-456-7890';
13
+ const redacted = redactTextContent(text);
14
+ expect(redacted).toBe('My phone number is PHONE_NUMBER');
15
+ });
16
+
17
+ it('should redact SSNs', () => {
18
+ const text = 'SSN: 123-45-6789';
19
+ const redacted = redactTextContent(text);
20
+ expect(redacted).toBe('SSN: US_SOCIAL_SECURITY_NUMBER');
21
+ });
22
+
23
+ it('should redact passport numbers', () => {
24
+ const text = 'Passport number: A1234567';
25
+ const redacted = redactTextContent(text);
26
+ expect(redacted).toBe('Passport number: PASSPORT_NUMBER');
27
+ });
28
+
29
+ it('should return the original text if no PII is found', () => {
30
+ const text = 'No PII here';
31
+ const redacted = redactTextContent(text);
32
+ expect(redacted).toBe(text);
33
+ });
34
+
35
+ it('should redact credit card numbers', () => {
36
+ const text = 'Credit Card: 1234-5678-1234-5678';
37
+ const redacted = redactTextContent(text);
38
+ expect(redacted).toBe('Credit Card: CREDIT_CARD_NUMBER');
39
+ });
40
+ });
41
+
42
+ describe('redactPIIInDOM', () => {
43
+ beforeEach(() => {
44
+ document.body.innerHTML = `
45
+ <div id="content">
46
+ <p>Email: john.doe@example.com</p>
47
+ <p>Phone: 123-456-7890</p>
48
+ <p>SSN: 123-45-6789</p>
49
+ <p>Passport: A1234567</p>
50
+ <p>No PII here</p>
51
+ </div>
52
+ `;
53
+ });
54
+
55
+ it('should redact PII in text nodes', () => {
56
+ const element = document.getElementById('content') as Element;
57
+ redactPIIInDOM(element);
58
+
59
+ expect(element.innerHTML).toContain('Email: EMAIL_ADDRESS');
60
+ expect(element.innerHTML).toContain('Phone: PHONE_NUMBER');
61
+ expect(element.innerHTML).toContain('SSN: US_SOCIAL_SECURITY_NUMBER');
62
+ expect(element.innerHTML).toContain('Passport: PASSPORT_NUMBER');
63
+ expect(element.innerHTML).toContain('No PII here');
64
+ });
65
+
66
+ it('should not change text content without PII', () => {
67
+ const element = document.querySelector('p:last-child') as Element;
68
+ const originalText = element.textContent;
69
+
70
+ redactPIIInDOM(element);
71
+
72
+ expect(element.textContent).toBe(originalText);
73
+ });
74
+ });
@@ -0,0 +1,57 @@
1
+ import { SyncRedactor } from "redact-pii";
2
+
3
+ const customRedactors = {
4
+ before: [
5
+ {
6
+ regexpPattern: /\b[A-Z]\d{7}\b/g,
7
+ replaceWith: 'PASSPORT_NUMBER'
8
+ },
9
+ {
10
+ regexpPattern: /(?<=password: )(.*)/gi,
11
+ replaceWith: '[REDACTED]'
12
+ }
13
+ ],
14
+ };
15
+
16
+ // Function to redact PII in text content
17
+ function redactTextContent(text: string): string {
18
+ const redactor = new SyncRedactor({
19
+ builtInRedactors: {
20
+ names: {
21
+ replaceWith: 'ANONYMOUS_PERSON'
22
+ }
23
+ },
24
+ customRedactors
25
+ });
26
+ return redactor.redact(text);
27
+ }
28
+
29
+ // Function to recursively find and redact PII in text nodes
30
+ function redactPIIInDOM(element: Element) {
31
+ const treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
32
+ let currentNode;
33
+
34
+ while ((currentNode = treeWalker.nextNode())) {
35
+ const originalText = currentNode.nodeValue as string;
36
+ const redactedText = redactTextContent(originalText);
37
+
38
+ if (originalText !== redactedText) {
39
+ currentNode.nodeValue = redactedText;
40
+ }
41
+ }
42
+
43
+ // Redact PII in input fields
44
+ redactPIIInInputs(element);
45
+ }
46
+
47
+ // Function to redact PII from passwords in input fields
48
+ function redactPIIInInputs(element: Element) {
49
+ const inputs = Array.from(element.querySelectorAll('input[type="password"]'));
50
+ inputs.forEach(input => {
51
+ const passwordInput = input as HTMLInputElement;
52
+ passwordInput.value = '[REDACTED]';
53
+ });
54
+ }
55
+
56
+ export { redactPIIInDOM, redactTextContent };
57
+
@@ -0,0 +1,35 @@
1
+ import { captureScreenshot } from "./capture";
2
+ import { redactPIIInDOM } from "./redact";
3
+
4
+ async function screenshot() {
5
+ try {
6
+ // Wait for page to load
7
+ await new Promise((resolve) => {
8
+ if (document.readyState === 'complete') {
9
+ resolve(null);
10
+ } else {
11
+ window.addEventListener('load', resolve);
12
+ }
13
+ });
14
+
15
+ // Clone the document body to avoid modifying the original
16
+ const clonedBody = document.body.cloneNode(true) as HTMLElement;
17
+
18
+ // Redact PII in text content
19
+ redactPIIInDOM(clonedBody);
20
+
21
+ // Capture the screenshot
22
+ const imageDataUrl = await captureScreenshot(clonedBody);
23
+
24
+ // if local env, print the imageDataUrl to the console
25
+ if (window.location.hostname === 'localhost') {
26
+ console.log(imageDataUrl);
27
+ }
28
+
29
+ return imageDataUrl;
30
+ } catch (error) {
31
+ console.error('An error occurred:', error);
32
+ }
33
+ }
34
+
35
+ export { screenshot };
@@ -0,0 +1 @@
1
+ declare const __PACKAGE_VERSION__: string;
@@ -0,0 +1,9 @@
1
+ const { withAnimations } = require('animated-tailwindcss')
2
+
3
+ module.exports = withAnimations({
4
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5
+ theme: {
6
+ extend: {}
7
+ },
8
+ plugins: []
9
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "node",
7
+ "allowSyntheticDefaultImports": true,
8
+ "esModuleInterop": true,
9
+ "types": ["vite/client", "jest"],
10
+ "baseUrl": ".",
11
+ "declaration": true
12
+ }
13
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { defineConfig } from 'vite';
2
+ import { terser } from 'rollup-plugin-terser';
3
+ import dts from 'vite-plugin-dts';
4
+ import compression from 'vite-plugin-compression';
5
+ import { visualizer } from 'rollup-plugin-visualizer';
6
+
7
+ export default defineConfig({
8
+ plugins: [
9
+ dts({
10
+ insertTypesEntry: true,
11
+ include: ['src'],
12
+ }),
13
+ compression(),
14
+ visualizer(),
15
+ ],
16
+ build: {
17
+ target: 'esnext',
18
+ sourcemap: false,
19
+ lib: {
20
+ entry: ['src/index.ts', 'src/cdn.ts'],
21
+ formats: ['cjs', 'es'],
22
+ fileName: (format, entryName) =>
23
+ `${entryName}${format === 'es' ? '' : '.cjs'}.js`,
24
+ },
25
+ minify: 'terser',
26
+ terserOptions: {
27
+ compress: {
28
+ drop_console: true,
29
+ drop_debugger: true,
30
+ },
31
+ },
32
+ rollupOptions: {
33
+ plugins: [terser()],
34
+ },
35
+ },
36
+ test: {
37
+ environment: 'jsdom',
38
+ },
39
+ define: {
40
+ __PACKAGE_VERSION__: JSON.stringify(process.env.npm_package_version),
41
+ },
42
+ optimizeDeps: {
43
+ include: ['@brainfish-ai/tracker-sdk'],
44
+ }
45
+ });