@senzops/web 1.0.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.
- package/PUBLISHING.md +77 -0
- package/README.md +119 -0
- package/dist/index.d.mts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.global.js +1 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +19 -0
- package/src/index.ts +184 -0
package/PUBLISHING.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# **How to Publish @senzops/web**
|
|
2
|
+
|
|
3
|
+
This guide details the steps to publish the web agent to the public NPM registry.
|
|
4
|
+
|
|
5
|
+
## **Prerequisites**
|
|
6
|
+
|
|
7
|
+
1. **NPM Account:** You must have an account on [npmjs.com](https://www.npmjs.com/).
|
|
8
|
+
2. **Organization:** You must create an organization named senzops on NPM (since the package is scoped @senzops/web).
|
|
9
|
+
- Go to NPM -> Click Profile Picture -> **+ Add Organization**.
|
|
10
|
+
- Name it senzops.
|
|
11
|
+
|
|
12
|
+
## **Step 1: Login to NPM**
|
|
13
|
+
|
|
14
|
+
In your terminal, inside the web-agent folder:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
_Follow the browser prompts to authenticate._
|
|
21
|
+
|
|
22
|
+
## **Step 2: Prepare the Build**
|
|
23
|
+
|
|
24
|
+
Ensure the code is clean, dependencies are installed, and the build passes.
|
|
25
|
+
|
|
26
|
+
# 1. Install dependencies
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
# 2. Build the package (creates /dist folder)
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## **Step 3: Versioning**
|
|
39
|
+
|
|
40
|
+
Update the version number in package.json. You should follow **Semantic Versioning** (Major.Minor.Patch).
|
|
41
|
+
|
|
42
|
+
- **Patch (Bug fix):** 1.0.0 -> 1.0.1
|
|
43
|
+
- **Minor (New feature):** 1.0.0 -> 1.1.0
|
|
44
|
+
- **Major (Breaking change):** 1.0.0 -> 2.0.0
|
|
45
|
+
|
|
46
|
+
You can do this manually or use the npm command:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm version patch
|
|
50
|
+
# or
|
|
51
|
+
npm version minor
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## **Step 4: Publishing**
|
|
55
|
+
|
|
56
|
+
Because this is a **scoped package** (@senzops/...), NPM tries to publish it as private by default (which requires a paid account). To publish it as **public** (free), you must use the access flag.
|
|
57
|
+
|
|
58
|
+
**Run this command:**
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npm publish --access public
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## **Step 5: Verification**
|
|
65
|
+
|
|
66
|
+
1. Go to https://www.npmjs.com/package/@senzops/web.
|
|
67
|
+
2. Check if the version matches the one you just pushed.
|
|
68
|
+
3. Check the dist/ files are included in the "Code" tab.
|
|
69
|
+
|
|
70
|
+
## **Step 6: CDN Update (Optional)**
|
|
71
|
+
|
|
72
|
+
If you are hosting the script via a CDN (like jsDelivr or unpkg), they usually pick up the new NPM version automatically within a few minutes.
|
|
73
|
+
|
|
74
|
+
- **Unpkg:** https://unpkg.com/@senzops/web@latest/dist/index.global.js
|
|
75
|
+
- **jsDelivr:** https://cdn.jsdelivr.net/npm/@senzops/web@latest/dist/index.global.js
|
|
76
|
+
|
|
77
|
+
You can map your custom domain cdn.senzor.dev to one of these URLs via CNAME records.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# **@senzops/web**
|
|
2
|
+
|
|
3
|
+
The official, lightweight, and privacy-conscious web analytics SDK for **Senzor**.
|
|
4
|
+
|
|
5
|
+
**Senzor Web** is a tiny (< 2KB gzipped) TypeScript agent designed to track page views, visitor sessions, and engagement duration without impacting your website's performance. It works seamlessly with Single Page Applications (SPAs) like React, Next.js, and Vue.
|
|
6
|
+
|
|
7
|
+
## **🚀 Installation**
|
|
8
|
+
|
|
9
|
+
### **Option 1: NPM (Recommended for React/Vue/Next.js)**
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @senzops/web
|
|
13
|
+
# or
|
|
14
|
+
yarn add @senzops/web
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### **Option 2: CDN (HTML Script Tag)**
|
|
18
|
+
|
|
19
|
+
Add this to the <head> of your website:
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<script src="https://cdn.jsdelivr.net/gh/senzops/web-agent/dist/index.global.js"></script>
|
|
23
|
+
<script>
|
|
24
|
+
window.Senzor.init({
|
|
25
|
+
webId: "YOUR_WEB_ID_HERE",
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## **🛠 Usage**
|
|
31
|
+
|
|
32
|
+
### **In React / Next.js**
|
|
33
|
+
|
|
34
|
+
Initialize the agent once in your root layout or main app component.
|
|
35
|
+
|
|
36
|
+
```jsx
|
|
37
|
+
import { useEffect } from "react";
|
|
38
|
+
import { Senzor } from "@senzops/web";
|
|
39
|
+
|
|
40
|
+
export default function App({ Component, pageProps }) {
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
Senzor.init({
|
|
43
|
+
webId: "req_123456789", // Get this from your Senzor Dashboard
|
|
44
|
+
// endpoint: '[https://custom-api.com](https://custom-api.com)' // Optional: For self-hosting
|
|
45
|
+
});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return <Component {...pageProps} />;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## **🧠 Working Principle**
|
|
53
|
+
|
|
54
|
+
The Senzor Agent is designed to be **"Fire and Forget"**. It operates asynchronously to ensure it never blocks the main thread or slows down page loads.
|
|
55
|
+
|
|
56
|
+
### **1. Identity & Sessions**
|
|
57
|
+
|
|
58
|
+
- **Visitor ID:** When a user visits, we generate a random UUID and store it in localStorage. This allows us to track unique visitors over a 1-year period.
|
|
59
|
+
- **Session ID:** We generate a UUID in sessionStorage. This persists across tab reloads but clears when the browser/tab is closed, allowing us to calculate **Bounce Rates** and **Session Duration**.
|
|
60
|
+
- **Privacy:** We do **not** use cookies. All data is first-party.
|
|
61
|
+
|
|
62
|
+
### **2. Event Tracking**
|
|
63
|
+
|
|
64
|
+
The agent listens for specific browser events to capture accurate metrics:
|
|
65
|
+
|
|
66
|
+
- **Initialization:** Sends a pageview event immediately.
|
|
67
|
+
- **History API (pushState):** Automatically detects route changes in SPAs (e.g., clicking a Link in Next.js) and sends a new pageview.
|
|
68
|
+
- **Visibility Change:** If a user minimizes the tab or switches to another tab, we pause the "Duration" timer and send a ping.
|
|
69
|
+
|
|
70
|
+
### **3. Duration & The "Ping"**
|
|
71
|
+
|
|
72
|
+
Calculating how long a user spends on a page is difficult because users often close tabs abruptly. Senzor solves this with a **Heartbeat/Ping mechanism**:
|
|
73
|
+
|
|
74
|
+
1. When a page loads, we start a timer (startTime).
|
|
75
|
+
2. When the user navigates away (beforeunload) or hides the tab (visibilitychange), we calculate duration = Now - startTime.
|
|
76
|
+
3. We send a ping event with this duration.
|
|
77
|
+
4. **The Backend** receives this ping and updates the _previous_ pageview entry in the database, incrementing its duration.
|
|
78
|
+
|
|
79
|
+
### **4. Data Transmission**
|
|
80
|
+
|
|
81
|
+
We prioritize data reliability using **navigator.sendBeacon**:
|
|
82
|
+
|
|
83
|
+
- **Reliability:** sendBeacon queues data to be sent by the browser even _after_ the page has unloaded/closed. This ensures we don't lose data when users close the tab.
|
|
84
|
+
- **Fallback:** If sendBeacon is unavailable, we fall back to a standard fetch request with keepalive: true.
|
|
85
|
+
|
|
86
|
+
## **⚙️ Configuration Options**
|
|
87
|
+
|
|
88
|
+
| Option | Type | Default | Description |
|
|
89
|
+
| :------- | :----- | :---------------- | :---------------------------------------------------------------------- |
|
|
90
|
+
| webId | string | **Required** | The unique ID of your website generated in the Senzor Dashboard. |
|
|
91
|
+
| endpoint | string | api.senzor.dev... | URL of the ingestion API. Use this if you are self-hosting the backend. |
|
|
92
|
+
|
|
93
|
+
## **📦 Development**
|
|
94
|
+
|
|
95
|
+
To build the agent locally:
|
|
96
|
+
|
|
97
|
+
1. **Clone & Install**
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
git clone https://github.com/Senzops/web-agent.git
|
|
101
|
+
cd web-agent
|
|
102
|
+
npm install
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
2. Build
|
|
106
|
+
Uses tsup to bundle for ESM, CJS, and IIFE (Global variable).
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npm run build
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
3. **Output**
|
|
113
|
+
- dist/index.js (CommonJS)
|
|
114
|
+
- dist/index.mjs (ES Modules)
|
|
115
|
+
- dist/index.global.js (Browser Script)
|
|
116
|
+
|
|
117
|
+
## **📄 License**
|
|
118
|
+
|
|
119
|
+
MIT © [Senzor](https://senzor.dev)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface Config {
|
|
2
|
+
webId: string;
|
|
3
|
+
endpoint?: string;
|
|
4
|
+
}
|
|
5
|
+
declare class SenzorWebAgent {
|
|
6
|
+
private config;
|
|
7
|
+
private startTime;
|
|
8
|
+
private endpoint;
|
|
9
|
+
constructor();
|
|
10
|
+
init(config: Config): void;
|
|
11
|
+
private initSession;
|
|
12
|
+
private getIds;
|
|
13
|
+
private trackPageView;
|
|
14
|
+
private trackPing;
|
|
15
|
+
private send;
|
|
16
|
+
private fallbackSend;
|
|
17
|
+
private setupListeners;
|
|
18
|
+
}
|
|
19
|
+
declare const Senzor: SenzorWebAgent;
|
|
20
|
+
|
|
21
|
+
export { Senzor };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface Config {
|
|
2
|
+
webId: string;
|
|
3
|
+
endpoint?: string;
|
|
4
|
+
}
|
|
5
|
+
declare class SenzorWebAgent {
|
|
6
|
+
private config;
|
|
7
|
+
private startTime;
|
|
8
|
+
private endpoint;
|
|
9
|
+
constructor();
|
|
10
|
+
init(config: Config): void;
|
|
11
|
+
private initSession;
|
|
12
|
+
private getIds;
|
|
13
|
+
private trackPageView;
|
|
14
|
+
private trackPing;
|
|
15
|
+
private send;
|
|
16
|
+
private fallbackSend;
|
|
17
|
+
private setupListeners;
|
|
18
|
+
}
|
|
19
|
+
declare const Senzor: SenzorWebAgent;
|
|
20
|
+
|
|
21
|
+
export { Senzor };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{function o(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let e=Math.random()*16|0;return(n==="x"?e:e&3|8).toString(16)})}var i=class{config;startTime;endpoint;constructor(){this.config={webId:"",endpoint:"https://api.senzor.dev/api/ingest/web"},this.startTime=Date.now(),this.endpoint=""}init(e){if(this.config={...this.config,...e},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required to initialize analytics.");return}this.initSession(),this.trackPageView(),this.setupListeners()}initSession(){let e=localStorage.getItem("senzor_vid");e||(e=o(),localStorage.setItem("senzor_vid",e));let t=sessionStorage.getItem("senzor_sid");t||(t=o(),sessionStorage.setItem("senzor_sid",t))}getIds(){return{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:sessionStorage.getItem("senzor_sid")||"unknown"}}trackPageView(){this.startTime=Date.now();let e={type:"pageview",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};this.send(e)}trackPing(){let e=Math.floor((Date.now()-this.startTime)/1e3);if(e<1)return;let t={type:"ping",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,duration:e};this.send(t)}send(e){if(navigator.sendBeacon){let t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon(this.endpoint,t)||this.fallbackSend(e)}else this.fallbackSend(e)}fallbackSend(e){fetch(this.endpoint,{method:"POST",body:JSON.stringify(e),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(t=>console.error("[Senzor] Failed to send telemetry:",t))}setupListeners(){let e=history.pushState;history.pushState=(...t)=>{this.trackPing(),e.apply(history,t),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():this.startTime=Date.now()}),window.addEventListener("beforeunload",()=>{this.trackPing()})}},s=new i;typeof window<"u"&&(window.Senzor=s);})();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var s=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var p=Object.getOwnPropertyNames;var g=Object.prototype.hasOwnProperty;var h=(i,e)=>{for(var t in e)s(i,t,{get:e[t],enumerable:!0})},l=(i,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of p(e))!g.call(i,n)&&n!==t&&s(i,n,{get:()=>e[n],enumerable:!(o=c(e,n))||o.enumerable});return i};var w=i=>l(s({},"__esModule",{value:!0}),i);var f={};h(f,{Senzor:()=>d});module.exports=w(f);function a(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,i=>{let e=Math.random()*16|0;return(i==="x"?e:e&3|8).toString(16)})}var r=class{config;startTime;endpoint;constructor(){this.config={webId:"",endpoint:"https://api.senzor.dev/api/ingest/web"},this.startTime=Date.now(),this.endpoint=""}init(e){if(this.config={...this.config,...e},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required to initialize analytics.");return}this.initSession(),this.trackPageView(),this.setupListeners()}initSession(){let e=localStorage.getItem("senzor_vid");e||(e=a(),localStorage.setItem("senzor_vid",e));let t=sessionStorage.getItem("senzor_sid");t||(t=a(),sessionStorage.setItem("senzor_sid",t))}getIds(){return{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:sessionStorage.getItem("senzor_sid")||"unknown"}}trackPageView(){this.startTime=Date.now();let e={type:"pageview",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};this.send(e)}trackPing(){let e=Math.floor((Date.now()-this.startTime)/1e3);if(e<1)return;let t={type:"ping",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,duration:e};this.send(t)}send(e){if(navigator.sendBeacon){let t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon(this.endpoint,t)||this.fallbackSend(e)}else this.fallbackSend(e)}fallbackSend(e){fetch(this.endpoint,{method:"POST",body:JSON.stringify(e),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(t=>console.error("[Senzor] Failed to send telemetry:",t))}setupListeners(){let e=history.pushState;history.pushState=(...t)=>{this.trackPing(),e.apply(history,t),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():this.startTime=Date.now()}),window.addEventListener("beforeunload",()=>{this.trackPing()})}},d=new r;typeof window<"u"&&(window.Senzor=d);0&&(module.exports={Senzor});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function o(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let e=Math.random()*16|0;return(n==="x"?e:e&3|8).toString(16)})}var i=class{config;startTime;endpoint;constructor(){this.config={webId:"",endpoint:"https://api.senzor.dev/api/ingest/web"},this.startTime=Date.now(),this.endpoint=""}init(e){if(this.config={...this.config,...e},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required to initialize analytics.");return}this.initSession(),this.trackPageView(),this.setupListeners()}initSession(){let e=localStorage.getItem("senzor_vid");e||(e=o(),localStorage.setItem("senzor_vid",e));let t=sessionStorage.getItem("senzor_sid");t||(t=o(),sessionStorage.setItem("senzor_sid",t))}getIds(){return{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:sessionStorage.getItem("senzor_sid")||"unknown"}}trackPageView(){this.startTime=Date.now();let e={type:"pageview",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};this.send(e)}trackPing(){let e=Math.floor((Date.now()-this.startTime)/1e3);if(e<1)return;let t={type:"ping",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,duration:e};this.send(t)}send(e){if(navigator.sendBeacon){let t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon(this.endpoint,t)||this.fallbackSend(e)}else this.fallbackSend(e)}fallbackSend(e){fetch(this.endpoint,{method:"POST",body:JSON.stringify(e),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(t=>console.error("[Senzor] Failed to send telemetry:",t))}setupListeners(){let e=history.pushState;history.pushState=(...t)=>{this.trackPing(),e.apply(history,t),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():this.startTime=Date.now()}),window.addEventListener("beforeunload",()=>{this.trackPing()})}},s=new i;typeof window<"u"&&(window.Senzor=s);export{s as Senzor};
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@senzops/web",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Senzor Web Analytics SDK",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup src/index.ts --format cjs,esm,iife --dts --clean --minify"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/uuid": "^10.0.0",
|
|
12
|
+
"tsup": "^8.0.0",
|
|
13
|
+
"typescript": "^5.0.0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Remove the external uuid import causing the crash
|
|
2
|
+
// import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
|
|
4
|
+
interface Config {
|
|
5
|
+
webId: string;
|
|
6
|
+
endpoint?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Payload {
|
|
10
|
+
type: 'pageview' | 'ping';
|
|
11
|
+
webId: string;
|
|
12
|
+
visitorId: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
url: string;
|
|
15
|
+
path: string;
|
|
16
|
+
referrer: string;
|
|
17
|
+
width: number;
|
|
18
|
+
timezone: string;
|
|
19
|
+
duration?: number; // Only for pings/unload
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Native UUID Helper (Fixes "require('crypto')" error) ---
|
|
23
|
+
function generateUUID(): string {
|
|
24
|
+
// Use native crypto API if available (Modern Browsers)
|
|
25
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
26
|
+
return crypto.randomUUID();
|
|
27
|
+
}
|
|
28
|
+
// Fallback for older environments
|
|
29
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
30
|
+
const r = (Math.random() * 16) | 0;
|
|
31
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
32
|
+
return v.toString(16);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class SenzorWebAgent {
|
|
37
|
+
private config: Config;
|
|
38
|
+
private startTime: number;
|
|
39
|
+
private endpoint: string;
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
this.config = { webId: '', endpoint: 'https://api.senzor.dev/api/ingest/web' };
|
|
43
|
+
this.startTime = Date.now();
|
|
44
|
+
this.endpoint = '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public init(config: Config) {
|
|
48
|
+
this.config = { ...this.config, ...config };
|
|
49
|
+
// Allow overriding endpoint for self-hosting or dev
|
|
50
|
+
this.endpoint = this.config.endpoint || 'https://api.senzor.dev/api/ingest/web';
|
|
51
|
+
|
|
52
|
+
if (!this.config.webId) {
|
|
53
|
+
console.error('[Senzor] WebId is required to initialize analytics.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 1. Initialize Session
|
|
58
|
+
this.initSession();
|
|
59
|
+
|
|
60
|
+
// 2. Track Initial Page View
|
|
61
|
+
this.trackPageView();
|
|
62
|
+
|
|
63
|
+
// 3. Setup Listeners
|
|
64
|
+
this.setupListeners();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private initSession() {
|
|
68
|
+
// Persistent Visitor ID (1 year)
|
|
69
|
+
let vid = localStorage.getItem('senzor_vid');
|
|
70
|
+
if (!vid) {
|
|
71
|
+
vid = generateUUID();
|
|
72
|
+
localStorage.setItem('senzor_vid', vid!);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Session ID (Expires when browser closes)
|
|
76
|
+
let sid = sessionStorage.getItem('senzor_sid');
|
|
77
|
+
if (!sid) {
|
|
78
|
+
sid = generateUUID();
|
|
79
|
+
sessionStorage.setItem('senzor_sid', sid!);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private getIds() {
|
|
84
|
+
return {
|
|
85
|
+
visitorId: localStorage.getItem('senzor_vid') || 'unknown',
|
|
86
|
+
sessionId: sessionStorage.getItem('senzor_sid') || 'unknown'
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private trackPageView() {
|
|
91
|
+
this.startTime = Date.now(); // Reset timer for new page
|
|
92
|
+
const payload: Payload = {
|
|
93
|
+
type: 'pageview',
|
|
94
|
+
webId: this.config.webId,
|
|
95
|
+
...this.getIds(),
|
|
96
|
+
url: window.location.href,
|
|
97
|
+
path: window.location.pathname,
|
|
98
|
+
referrer: document.referrer,
|
|
99
|
+
width: window.innerWidth,
|
|
100
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
this.send(payload);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Captures time spent on page when user leaves or hides tab
|
|
107
|
+
private trackPing() {
|
|
108
|
+
const duration = Math.floor((Date.now() - this.startTime) / 1000);
|
|
109
|
+
if (duration < 1) return; // Ignore accidental bounces
|
|
110
|
+
|
|
111
|
+
const payload: Payload = {
|
|
112
|
+
type: 'ping',
|
|
113
|
+
webId: this.config.webId,
|
|
114
|
+
...this.getIds(),
|
|
115
|
+
url: window.location.href,
|
|
116
|
+
path: window.location.pathname,
|
|
117
|
+
referrer: document.referrer,
|
|
118
|
+
width: window.innerWidth,
|
|
119
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
120
|
+
duration: duration
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
this.send(payload);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private send(data: Payload) {
|
|
127
|
+
// Use sendBeacon for reliability during unload, fallback to fetch
|
|
128
|
+
if (navigator.sendBeacon) {
|
|
129
|
+
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
|
130
|
+
// sendBeacon returns false if it fails (e.g. payload too large)
|
|
131
|
+
const success = navigator.sendBeacon(this.endpoint, blob);
|
|
132
|
+
if (!success) this.fallbackSend(data);
|
|
133
|
+
} else {
|
|
134
|
+
this.fallbackSend(data);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private fallbackSend(data: Payload) {
|
|
139
|
+
fetch(this.endpoint, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
body: JSON.stringify(data),
|
|
142
|
+
keepalive: true,
|
|
143
|
+
headers: { 'Content-Type': 'application/json' }
|
|
144
|
+
}).catch(err => console.error('[Senzor] Failed to send telemetry:', err));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private setupListeners() {
|
|
148
|
+
// 1. History API Support (SPA - React/Next.js/Vue)
|
|
149
|
+
const originalPushState = history.pushState;
|
|
150
|
+
history.pushState = (...args) => {
|
|
151
|
+
this.trackPing(); // Send duration for previous page
|
|
152
|
+
originalPushState.apply(history, args);
|
|
153
|
+
this.trackPageView(); // Track new page
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
window.addEventListener('popstate', () => {
|
|
157
|
+
this.trackPing();
|
|
158
|
+
this.trackPageView();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 2. Visibility Change (Tab switch / Minimize)
|
|
162
|
+
document.addEventListener('visibilitychange', () => {
|
|
163
|
+
if (document.visibilityState === 'hidden') {
|
|
164
|
+
this.trackPing();
|
|
165
|
+
} else {
|
|
166
|
+
// User came back, reset timer so we don't count background time
|
|
167
|
+
this.startTime = Date.now();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 3. Before Unload (Closing tab)
|
|
172
|
+
window.addEventListener('beforeunload', () => {
|
|
173
|
+
this.trackPing();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Export Singleton
|
|
179
|
+
export const Senzor = new SenzorWebAgent();
|
|
180
|
+
|
|
181
|
+
// Allow window access for script tag usage
|
|
182
|
+
if (typeof window !== 'undefined') {
|
|
183
|
+
(window as any).Senzor = Senzor;
|
|
184
|
+
}
|