vantmetry 0.0.1 → 0.0.3
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/LICENSE +21 -0
- package/README.md +24 -2
- package/core/init.d.ts +2 -0
- package/core/listeners.d.ts +2 -0
- package/core/privacy.d.ts +9 -0
- package/core/singleton.d.ts +4 -0
- package/core/tracker.d.ts +21 -0
- package/core/transport.d.ts +10 -0
- package/core/types.d.ts +41 -0
- package/index.d.ts +6 -0
- package/index.js +28 -1
- package/init-DdlNmKDT.js +270 -0
- package/next/index.d.ts +11 -0
- package/next/index.js +21 -0
- package/package.json +59 -6
- package/react/index.d.ts +32 -0
- package/react/index.js +37 -0
- package/src/core/init.ts +13 -0
- package/src/core/listeners.ts +76 -0
- package/src/core/privacy.ts +123 -0
- package/src/core/singleton.ts +18 -0
- package/src/core/tracker.ts +146 -0
- package/src/core/transport.ts +82 -0
- package/src/core/types.ts +47 -0
- package/src/index.ts +28 -0
- package/src/next/index.tsx +29 -0
- package/src/next/next-script.d.ts +16 -0
- package/src/react/index.tsx +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vantmetry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vantmetry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lightweight browser error tracking. Captures JavaScript errors, console errors, and unhandled promise rejections with zero blocking of the main thread.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i vantmetry
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use the CDN script tag — see [vantmetry.com/docs](https://vantmetry.com/docs).
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { init } from 'vantmetry';
|
|
17
|
+
|
|
18
|
+
init({ publicKey: 'vpk_your_key' });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's it. Errors are auto-captured from that point on. See [vantmetry.com/docs](https://vantmetry.com/docs) for full configuration, React/Next.js integrations, manual logging, and PII masking details.
|
|
22
|
+
|
|
23
|
+
## Source
|
|
24
|
+
|
|
25
|
+
The full TypeScript source is in the `src/` directory of this package and on [GitHub](https://github.com/vantmetry/tracker).
|
package/core/init.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masks sensitive patterns in a string.
|
|
3
|
+
*/
|
|
4
|
+
export declare function maskPII(input: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Deeply masks PII in an object or array.
|
|
7
|
+
* Handles sensitive keys by fully redacting their values.
|
|
8
|
+
*/
|
|
9
|
+
export declare function maskObjectPII<T>(obj: T, seen?: WeakSet<object>): T;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { LogPayload, VantmetryInstance, LogDetails, VantmetryConfig } from './types';
|
|
2
|
+
export declare class VantmetryTracker implements VantmetryInstance {
|
|
3
|
+
private buffer;
|
|
4
|
+
private flushTimer;
|
|
5
|
+
private transport;
|
|
6
|
+
isReady: boolean;
|
|
7
|
+
private sentErrors;
|
|
8
|
+
private eventsThisSecond;
|
|
9
|
+
private lastResetTime;
|
|
10
|
+
private readonly TTL_MS;
|
|
11
|
+
private readonly MAX_EVENTS_PER_SEC;
|
|
12
|
+
constructor(config: VantmetryConfig);
|
|
13
|
+
error(message: string | unknown, details?: LogDetails): void;
|
|
14
|
+
warn(message: string, details?: LogDetails): void;
|
|
15
|
+
info(message: string, details?: LogDetails): void;
|
|
16
|
+
debug(message: string, details?: LogDetails): void;
|
|
17
|
+
flush(): Promise<void>;
|
|
18
|
+
private addToBuffer;
|
|
19
|
+
captureAutoError(payload: LogPayload): void;
|
|
20
|
+
private getSignature;
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { VantmetryConfig } from './types';
|
|
2
|
+
export declare class TransportManager {
|
|
3
|
+
private wtSession;
|
|
4
|
+
private readonly endpoint;
|
|
5
|
+
private readonly wtEndpoint;
|
|
6
|
+
private readonly debug;
|
|
7
|
+
constructor(config: VantmetryConfig);
|
|
8
|
+
private initWT;
|
|
9
|
+
send(payload: string): Promise<void>;
|
|
10
|
+
}
|
package/core/types.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const LogLevel: {
|
|
2
|
+
readonly ERROR: "ERROR";
|
|
3
|
+
readonly INFO: "INFO";
|
|
4
|
+
readonly WARN: "WARN";
|
|
5
|
+
readonly DEBUG: "DEBUG";
|
|
6
|
+
};
|
|
7
|
+
export type VantmetryLogLevel = (typeof LogLevel)[keyof typeof LogLevel];
|
|
8
|
+
export type LogDetails = Record<string, unknown>;
|
|
9
|
+
export interface VantmetryInstance {
|
|
10
|
+
isReady: boolean;
|
|
11
|
+
error: (message: string | unknown, details?: LogDetails) => void;
|
|
12
|
+
warn: (message: string, details?: LogDetails) => void;
|
|
13
|
+
info: (message: string, details?: LogDetails) => void;
|
|
14
|
+
debug: (message: string, details?: LogDetails) => void;
|
|
15
|
+
flush: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
Vantmetry?: VantmetryInstance;
|
|
20
|
+
onVantmetryReady?: (instance: VantmetryInstance) => void;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export interface LogPayload {
|
|
24
|
+
message: string | Event | unknown;
|
|
25
|
+
severity: VantmetryLogLevel;
|
|
26
|
+
type?: string;
|
|
27
|
+
stack?: string;
|
|
28
|
+
loc?: string;
|
|
29
|
+
trace_id?: string;
|
|
30
|
+
details?: LogDetails;
|
|
31
|
+
}
|
|
32
|
+
export interface LogItem extends LogPayload {
|
|
33
|
+
count: number;
|
|
34
|
+
ts: number;
|
|
35
|
+
url: string;
|
|
36
|
+
ua: string;
|
|
37
|
+
}
|
|
38
|
+
export interface VantmetryConfig {
|
|
39
|
+
publicKey: string;
|
|
40
|
+
ingestorUrl?: string;
|
|
41
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { VantmetryInstance } from './core/types';
|
|
2
|
+
export { init } from './core/init';
|
|
3
|
+
export { VantmetryTracker } from './core/tracker';
|
|
4
|
+
export { initGlobalListeners } from './core/listeners';
|
|
5
|
+
export type { VantmetryConfig, LogDetails, VantmetryInstance, VantmetryLogLevel } from './core/types';
|
|
6
|
+
export declare const logger: VantmetryInstance;
|
package/index.js
CHANGED
|
@@ -1 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
import { g as t } from "./init-DdlNmKDT.js";
|
|
2
|
+
import { V as i, i as g, a as f } from "./init-DdlNmKDT.js";
|
|
3
|
+
const n = {
|
|
4
|
+
get isReady() {
|
|
5
|
+
return t().isReady;
|
|
6
|
+
},
|
|
7
|
+
error(r, e) {
|
|
8
|
+
t().error(r, e);
|
|
9
|
+
},
|
|
10
|
+
warn(r, e) {
|
|
11
|
+
t().warn(r, e);
|
|
12
|
+
},
|
|
13
|
+
info(r, e) {
|
|
14
|
+
t().info(r, e);
|
|
15
|
+
},
|
|
16
|
+
debug(r, e) {
|
|
17
|
+
t().debug(r, e);
|
|
18
|
+
},
|
|
19
|
+
flush() {
|
|
20
|
+
return t().flush();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export {
|
|
24
|
+
i as VantmetryTracker,
|
|
25
|
+
g as init,
|
|
26
|
+
f as initGlobalListeners,
|
|
27
|
+
n as logger
|
|
28
|
+
};
|
package/init-DdlNmKDT.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
let f = null;
|
|
2
|
+
function E(n) {
|
|
3
|
+
f = n;
|
|
4
|
+
}
|
|
5
|
+
function A() {
|
|
6
|
+
if (!f)
|
|
7
|
+
throw new Error("[Vantmetry] Not initialized. Call init() before using logger.");
|
|
8
|
+
return f;
|
|
9
|
+
}
|
|
10
|
+
function T() {
|
|
11
|
+
return f !== null;
|
|
12
|
+
}
|
|
13
|
+
const c = {
|
|
14
|
+
ERROR: "ERROR",
|
|
15
|
+
INFO: "INFO",
|
|
16
|
+
WARN: "WARN",
|
|
17
|
+
DEBUG: "DEBUG"
|
|
18
|
+
}, b = "https://ingestor.vantmetry.com:4433";
|
|
19
|
+
class S {
|
|
20
|
+
wtSession = null;
|
|
21
|
+
endpoint;
|
|
22
|
+
wtEndpoint;
|
|
23
|
+
debug;
|
|
24
|
+
constructor(e) {
|
|
25
|
+
const t = (e.ingestorUrl ?? b).replace(/\/$/, "");
|
|
26
|
+
this.endpoint = `${t}/api/ingestor/push/tcp?public_key=${e.publicKey}`, this.wtEndpoint = `${t}/api/ingestor/push/udp?public_key=${e.publicKey}`;
|
|
27
|
+
try {
|
|
28
|
+
this.debug = typeof window < "u" && !!window.localStorage?.getItem("vantmetry_debug");
|
|
29
|
+
} catch {
|
|
30
|
+
this.debug = !1;
|
|
31
|
+
}
|
|
32
|
+
this.initWT();
|
|
33
|
+
}
|
|
34
|
+
async initWT() {
|
|
35
|
+
if ("WebTransport" in window) {
|
|
36
|
+
await new Promise((e) => setTimeout(e, 200));
|
|
37
|
+
try {
|
|
38
|
+
this.wtSession = new WebTransport(this.wtEndpoint), await this.wtSession.ready, this.debug && console.log("WT: Connected");
|
|
39
|
+
} catch (e) {
|
|
40
|
+
this.debug && console.warn("WT: Failed, falling back to beacon", e), this.wtSession = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async send(e) {
|
|
45
|
+
if (this.wtSession)
|
|
46
|
+
try {
|
|
47
|
+
const s = (await this.wtSession.createUnidirectionalStream()).getWriter();
|
|
48
|
+
await s.write(new TextEncoder().encode(e)), await s.close();
|
|
49
|
+
return;
|
|
50
|
+
} catch (t) {
|
|
51
|
+
this.debug && console.warn("WT: Failed, falling back to beacon", t), this.wtSession = null;
|
|
52
|
+
}
|
|
53
|
+
if (typeof navigator < "u" && navigator.sendBeacon) {
|
|
54
|
+
const t = new Blob([e], { type: "application/json" });
|
|
55
|
+
if (navigator.sendBeacon(this.endpoint, t))
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
fetch(this.endpoint, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
body: e,
|
|
61
|
+
keepalive: !0,
|
|
62
|
+
headers: { "Content-Type": "application/json" }
|
|
63
|
+
}).catch((t) => {
|
|
64
|
+
this.debug && console.error("Vantmetry send failed", t);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const l = {
|
|
69
|
+
// Matches standard email formats
|
|
70
|
+
email: /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g,
|
|
71
|
+
// Matches standard CC groupings: 4-4-4-[3-4] with required separators, or Amex 4-6-5 format.
|
|
72
|
+
creditCard: /\b(?:\d{4}[-\s]){3}\d{3,4}\b|\b\d{4}[-\s]\d{6}[-\s]\d{5}\b/g,
|
|
73
|
+
// US Social Security Numbers (SSN): 3-2-4 format
|
|
74
|
+
ssn: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g,
|
|
75
|
+
// JWT Tokens (Header.Payload.Signature format starts with 'ey')
|
|
76
|
+
jwt: /\bey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
|
|
77
|
+
// Generic secret detection (Bearer tokens, Basic auth, API keys)
|
|
78
|
+
authHeader: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9\-._~+/=]+/gi
|
|
79
|
+
}, p = /* @__PURE__ */ new Set([
|
|
80
|
+
"password",
|
|
81
|
+
"passwd",
|
|
82
|
+
"pwd",
|
|
83
|
+
"secret",
|
|
84
|
+
"token",
|
|
85
|
+
"authorization",
|
|
86
|
+
"api_key",
|
|
87
|
+
"apikey",
|
|
88
|
+
"access_token",
|
|
89
|
+
"refresh_token",
|
|
90
|
+
"session",
|
|
91
|
+
"cookie",
|
|
92
|
+
"credentials",
|
|
93
|
+
"client_secret",
|
|
94
|
+
"auth"
|
|
95
|
+
]);
|
|
96
|
+
function d(n) {
|
|
97
|
+
let e = n;
|
|
98
|
+
return e = e.replace(l.email, (t) => {
|
|
99
|
+
const s = t.split("@");
|
|
100
|
+
if (s.length !== 2)
|
|
101
|
+
return t;
|
|
102
|
+
const [r, a] = s;
|
|
103
|
+
return `${r.charAt(0)}***@${a}`;
|
|
104
|
+
}), e = e.replace(l.creditCard, (t) => {
|
|
105
|
+
const s = t.replace(/[-\s]/g, ""), r = s.slice(-4);
|
|
106
|
+
return "*".repeat(s.length - 4) + r;
|
|
107
|
+
}), e = e.replace(l.ssn, (t) => `***-**-${t.slice(-4)}`), e = e.replace(l.jwt, "[JWT REDACTED]"), e = e.replace(l.authHeader, "$1 [TOKEN REDACTED]"), e;
|
|
108
|
+
}
|
|
109
|
+
function h(n, e = /* @__PURE__ */ new WeakSet()) {
|
|
110
|
+
if (typeof n == "string")
|
|
111
|
+
return d(n);
|
|
112
|
+
if (!n || typeof n != "object")
|
|
113
|
+
return n;
|
|
114
|
+
if (e.has(n))
|
|
115
|
+
return "[Circular]";
|
|
116
|
+
if (e.add(n), Array.isArray(n))
|
|
117
|
+
return n.map((s) => h(s, e));
|
|
118
|
+
const t = {};
|
|
119
|
+
for (const [s, r] of Object.entries(n)) {
|
|
120
|
+
const a = s.toLowerCase();
|
|
121
|
+
if (p.has(a) || Array.from(p).some((i) => a.includes(i))) {
|
|
122
|
+
t[s] = "[REDACTED]";
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
typeof r == "string" ? t[s] = d(r) : typeof r == "object" && r !== null ? t[s] = h(r, e) : t[s] = r;
|
|
126
|
+
}
|
|
127
|
+
return t;
|
|
128
|
+
}
|
|
129
|
+
const R = 50, k = 2e3;
|
|
130
|
+
class v {
|
|
131
|
+
buffer = [];
|
|
132
|
+
flushTimer = null;
|
|
133
|
+
transport;
|
|
134
|
+
isReady = !0;
|
|
135
|
+
// Deduplication & Circuit Breaker
|
|
136
|
+
sentErrors = /* @__PURE__ */ new Map();
|
|
137
|
+
eventsThisSecond = 0;
|
|
138
|
+
lastResetTime = Date.now();
|
|
139
|
+
TTL_MS = 6e4;
|
|
140
|
+
MAX_EVENTS_PER_SEC = 100;
|
|
141
|
+
constructor(e) {
|
|
142
|
+
this.transport = new S(e);
|
|
143
|
+
}
|
|
144
|
+
// --- Public API ---
|
|
145
|
+
error(e, t) {
|
|
146
|
+
this.addToBuffer({ severity: c.ERROR, type: "manual", message: e, details: t });
|
|
147
|
+
}
|
|
148
|
+
warn(e, t) {
|
|
149
|
+
this.addToBuffer({ severity: c.WARN, type: "manual", message: e, details: t });
|
|
150
|
+
}
|
|
151
|
+
info(e, t) {
|
|
152
|
+
this.addToBuffer({ severity: c.INFO, type: "manual", message: e, details: t });
|
|
153
|
+
}
|
|
154
|
+
debug(e, t) {
|
|
155
|
+
this.addToBuffer({ severity: c.DEBUG, type: "manual", message: e, details: t });
|
|
156
|
+
}
|
|
157
|
+
async flush() {
|
|
158
|
+
if (this.buffer.length === 0)
|
|
159
|
+
return;
|
|
160
|
+
const e = Date.now();
|
|
161
|
+
for (const s of this.buffer)
|
|
162
|
+
this.sentErrors.set(this.getSignature(s), e);
|
|
163
|
+
for (const [s, r] of this.sentErrors.entries())
|
|
164
|
+
e - r >= this.TTL_MS && this.sentErrors.delete(s);
|
|
165
|
+
const t = JSON.stringify(this.buffer);
|
|
166
|
+
this.buffer = [], this.flushTimer && (clearTimeout(this.flushTimer), this.flushTimer = null), await this.transport.send(t);
|
|
167
|
+
}
|
|
168
|
+
// --- Internal Logic ---
|
|
169
|
+
addToBuffer(e) {
|
|
170
|
+
if (!this.isReady)
|
|
171
|
+
return;
|
|
172
|
+
const t = Date.now();
|
|
173
|
+
if (t - this.lastResetTime > 1e3 && (this.eventsThisSecond = 0, this.lastResetTime = t), this.eventsThisSecond++, this.eventsThisSecond > this.MAX_EVENTS_PER_SEC) {
|
|
174
|
+
this.isReady = !1, console.error("[Vantmetry] Logging disabled to save browser CPU due to infinite loop detection.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const s = this.getSignature(e), r = this.sentErrors.get(s);
|
|
178
|
+
if (r && t - r < this.TTL_MS)
|
|
179
|
+
return;
|
|
180
|
+
const a = this.buffer.find((m) => this.getSignature(m) === s);
|
|
181
|
+
if (a) {
|
|
182
|
+
a.count = (a.count || 1) + 1;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let { message: i, stack: o } = e;
|
|
186
|
+
const { details: u } = e;
|
|
187
|
+
i instanceof Error && (o = o ?? i.stack, i = i.message || String(i));
|
|
188
|
+
const g = typeof i == "string" ? d(i) : i, y = typeof u == "object" ? h(u) : u, w = typeof o == "string" ? d(o) : o;
|
|
189
|
+
this.buffer.push({
|
|
190
|
+
...e,
|
|
191
|
+
message: g,
|
|
192
|
+
details: y,
|
|
193
|
+
stack: w,
|
|
194
|
+
count: 1,
|
|
195
|
+
ts: Date.now(),
|
|
196
|
+
url: window.location.href,
|
|
197
|
+
ua: navigator.userAgent
|
|
198
|
+
}), this.buffer.length >= R ? this.flush() : this.flushTimer || (this.flushTimer = setTimeout(() => {
|
|
199
|
+
this.flush();
|
|
200
|
+
}, k));
|
|
201
|
+
}
|
|
202
|
+
captureAutoError(e) {
|
|
203
|
+
this.addToBuffer(e);
|
|
204
|
+
}
|
|
205
|
+
getSignature(e) {
|
|
206
|
+
return `${e.type}:${e.severity}:${e.message}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function _(n) {
|
|
210
|
+
const e = console.error;
|
|
211
|
+
console.error = function(...t) {
|
|
212
|
+
if (e.apply(console, t), !n._isCapturingConsoleError) {
|
|
213
|
+
n._isCapturingConsoleError = !0;
|
|
214
|
+
try {
|
|
215
|
+
let s, r;
|
|
216
|
+
const a = t.findIndex((o) => o instanceof Error), i = t[a];
|
|
217
|
+
if (i) {
|
|
218
|
+
const o = t.slice(0, a).filter((u) => typeof u == "string").join(" ");
|
|
219
|
+
s = o ? `${o}: ${i.message || String(i)}` : i.message || String(i), r = i.stack;
|
|
220
|
+
} else
|
|
221
|
+
s = t.map((o) => {
|
|
222
|
+
if (typeof o == "string") return o;
|
|
223
|
+
try {
|
|
224
|
+
return JSON.stringify(o);
|
|
225
|
+
} catch {
|
|
226
|
+
return String(o);
|
|
227
|
+
}
|
|
228
|
+
}).join(" ");
|
|
229
|
+
n.captureAutoError({
|
|
230
|
+
type: "console.error",
|
|
231
|
+
message: s || "Unknown console.error",
|
|
232
|
+
stack: r,
|
|
233
|
+
severity: c.ERROR
|
|
234
|
+
});
|
|
235
|
+
} finally {
|
|
236
|
+
n._isCapturingConsoleError = !1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}, window.addEventListener("error", function(t) {
|
|
240
|
+
n.captureAutoError({
|
|
241
|
+
type: "crash",
|
|
242
|
+
message: t.message || "Script error.",
|
|
243
|
+
stack: t.error?.stack,
|
|
244
|
+
loc: `${t.filename}:${t.lineno}:${t.colno}`,
|
|
245
|
+
severity: c.ERROR
|
|
246
|
+
});
|
|
247
|
+
}, { capture: !0 }), window.addEventListener("unhandledrejection", function(t) {
|
|
248
|
+
const s = t.reason, r = s instanceof Error;
|
|
249
|
+
n.captureAutoError({
|
|
250
|
+
type: "promise",
|
|
251
|
+
message: r ? s.message : String(s),
|
|
252
|
+
stack: r ? s.stack : new Error(`Unhandled rejection: ${String(s)}`).stack,
|
|
253
|
+
severity: c.ERROR
|
|
254
|
+
});
|
|
255
|
+
}, { capture: !0 }), document.addEventListener("visibilitychange", function() {
|
|
256
|
+
document.visibilityState === "hidden" && n.flush();
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function C(n) {
|
|
260
|
+
if (T())
|
|
261
|
+
return;
|
|
262
|
+
const e = new v(n);
|
|
263
|
+
E(e), _(e);
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
v as V,
|
|
267
|
+
_ as a,
|
|
268
|
+
A as g,
|
|
269
|
+
C as i
|
|
270
|
+
};
|
package/next/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { VantmetryConfig } from '../index';
|
|
2
|
+
/**
|
|
3
|
+
* Drop this into your Next.js layout or _document to load the tracker via CDN.
|
|
4
|
+
* Uses next/script so Next.js controls placement and loading strategy.
|
|
5
|
+
*/
|
|
6
|
+
export declare function VantmetryScript({ publicKey, ingestorUrl }: VantmetryConfig): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
/**
|
|
8
|
+
* SSR-safe wrapper around init(). Use this in _app.tsx, app/layout.tsx client
|
|
9
|
+
* components, or anywhere module-level code may run on the server.
|
|
10
|
+
*/
|
|
11
|
+
export declare function initVantmetry(config: VantmetryConfig): void;
|
package/next/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as i } from "react/jsx-runtime";
|
|
2
|
+
import n from "next/script";
|
|
3
|
+
import { i as e } from "../init-DdlNmKDT.js";
|
|
4
|
+
function m({ publicKey: t, ingestorUrl: r }) {
|
|
5
|
+
return /* @__PURE__ */ i(
|
|
6
|
+
n,
|
|
7
|
+
{
|
|
8
|
+
strategy: "afterInteractive",
|
|
9
|
+
"data-public-key": t,
|
|
10
|
+
"data-ingestor-url": r,
|
|
11
|
+
src: "https://cdn.vantmetry.com/tracker.js"
|
|
12
|
+
}
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function f(t) {
|
|
16
|
+
typeof window > "u" || e(t);
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
m as VantmetryScript,
|
|
20
|
+
f as initVantmetry
|
|
21
|
+
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantmetry",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Lightweight frontend error tracking with minimal browser impact.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./index.js",
|
|
11
|
+
"types": "./index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./react": {
|
|
14
|
+
"import": "./react/index.js",
|
|
15
|
+
"types": "./react/index.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./next": {
|
|
18
|
+
"import": "./next/index.js",
|
|
19
|
+
"types": "./next/index.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"files": [
|
|
24
|
+
"*.js",
|
|
25
|
+
"*.d.ts",
|
|
26
|
+
"core",
|
|
27
|
+
"react",
|
|
28
|
+
"next",
|
|
29
|
+
"src",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
6
33
|
"keywords": [
|
|
7
|
-
"error-
|
|
34
|
+
"error-tracking",
|
|
35
|
+
"observability",
|
|
36
|
+
"frontend",
|
|
37
|
+
"browser",
|
|
38
|
+
"privacy"
|
|
8
39
|
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/vantmetry/tracker.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://vantmetry.com",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org"
|
|
48
|
+
},
|
|
9
49
|
"author": "Vantmetry Team",
|
|
10
|
-
"license": "MIT"
|
|
11
|
-
}
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"dependencies": {},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"react": ">=18.0.0",
|
|
54
|
+
"next": ">=13.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"react": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"next": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
package/react/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { default as React, Component, ReactNode } from 'react';
|
|
2
|
+
import { VantmetryConfig, VantmetryInstance } from '../index';
|
|
3
|
+
interface VantmetryProviderProps extends VantmetryConfig {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Initializes Vantmetry and makes the logger available via {@link useLogger}.
|
|
8
|
+
*/
|
|
9
|
+
export declare function VantmetryProvider({ publicKey, ingestorUrl, children }: VantmetryProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
/**
|
|
11
|
+
* Returns the Vantmetry logger. Must be called inside a {@link VantmetryProvider}.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useLogger(): VantmetryInstance;
|
|
14
|
+
interface ErrorBoundaryProps {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
/** Rendered when a render error is caught. Defaults to null (renders nothing). */
|
|
17
|
+
fallback?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
interface ErrorBoundaryState {
|
|
20
|
+
hasError: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Catches React render errors, logs them via Vantmetry, and renders the fallback.
|
|
24
|
+
* Place around any subtree you want to protect.
|
|
25
|
+
*/
|
|
26
|
+
export declare class VantmetryErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
27
|
+
constructor(props: ErrorBoundaryProps);
|
|
28
|
+
static getDerivedStateFromError(_error: Error): ErrorBoundaryState;
|
|
29
|
+
componentDidCatch(error: Error, info: React.ErrorInfo): void;
|
|
30
|
+
render(): ReactNode;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
package/react/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as s } from "react/jsx-runtime";
|
|
2
|
+
import { createContext as i, Component as a, useEffect as u, useContext as c } from "react";
|
|
3
|
+
import { logger as o } from "../index.js";
|
|
4
|
+
import { i as m } from "../init-DdlNmKDT.js";
|
|
5
|
+
const n = i(null);
|
|
6
|
+
function h({ publicKey: t, ingestorUrl: r, children: e }) {
|
|
7
|
+
return u(() => {
|
|
8
|
+
m({ publicKey: t, ingestorUrl: r });
|
|
9
|
+
}, []), /* @__PURE__ */ s(n.Provider, { value: o, children: e });
|
|
10
|
+
}
|
|
11
|
+
function y() {
|
|
12
|
+
const t = c(n);
|
|
13
|
+
if (t === null)
|
|
14
|
+
throw new Error(
|
|
15
|
+
'useLogger() was called outside of VantmetryProvider. Wrap your app with <VantmetryProvider publicKey="..." /> to use this hook.'
|
|
16
|
+
);
|
|
17
|
+
return t;
|
|
18
|
+
}
|
|
19
|
+
class g extends a {
|
|
20
|
+
constructor(r) {
|
|
21
|
+
super(r), this.state = { hasError: !1 };
|
|
22
|
+
}
|
|
23
|
+
static getDerivedStateFromError(r) {
|
|
24
|
+
return { hasError: !0 };
|
|
25
|
+
}
|
|
26
|
+
componentDidCatch(r, e) {
|
|
27
|
+
o.error(r, { componentStack: e.componentStack ?? void 0 });
|
|
28
|
+
}
|
|
29
|
+
render() {
|
|
30
|
+
return this.state.hasError ? this.props.fallback ?? null : this.props.children;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
g as VantmetryErrorBoundary,
|
|
35
|
+
h as VantmetryProvider,
|
|
36
|
+
y as useLogger
|
|
37
|
+
};
|
package/src/core/init.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { VantmetryTracker } from './tracker';
|
|
2
|
+
import { initGlobalListeners } from './listeners';
|
|
3
|
+
import { setInstance, isInitialized } from './singleton';
|
|
4
|
+
import type { VantmetryConfig } from './types';
|
|
5
|
+
|
|
6
|
+
export function init(config: VantmetryConfig): void {
|
|
7
|
+
if (isInitialized()) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const tracker = new VantmetryTracker(config);
|
|
11
|
+
setInstance(tracker);
|
|
12
|
+
initGlobalListeners(tracker);
|
|
13
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { VantmetryTracker } from './tracker';
|
|
2
|
+
import { LogLevel } from './types';
|
|
3
|
+
|
|
4
|
+
export function initGlobalListeners(tracker: VantmetryTracker) {
|
|
5
|
+
const originalConsoleError = console.error;
|
|
6
|
+
console.error = function (...args: unknown[]) {
|
|
7
|
+
originalConsoleError.apply(console, args);
|
|
8
|
+
|
|
9
|
+
if ((tracker as unknown as Record<string, unknown>)['_isCapturingConsoleError']) return;
|
|
10
|
+
(tracker as unknown as Record<string, unknown>)['_isCapturingConsoleError'] = true;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
let message: string;
|
|
14
|
+
let stack: string | undefined;
|
|
15
|
+
|
|
16
|
+
const errorIndex = args.findIndex((arg) => arg instanceof Error);
|
|
17
|
+
const errorObj = args[errorIndex] as Error | undefined;
|
|
18
|
+
if (errorObj) {
|
|
19
|
+
const prefix = args
|
|
20
|
+
.slice(0, errorIndex)
|
|
21
|
+
.filter((a) => typeof a === 'string')
|
|
22
|
+
.join(' ');
|
|
23
|
+
message = prefix ? `${prefix}: ${errorObj.message || String(errorObj)}` : errorObj.message || String(errorObj);
|
|
24
|
+
stack = errorObj.stack;
|
|
25
|
+
} else {
|
|
26
|
+
message = args
|
|
27
|
+
.map((arg) => {
|
|
28
|
+
if (typeof arg === 'string') return arg;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(arg);
|
|
31
|
+
} catch {
|
|
32
|
+
return String(arg);
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
.join(' ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
tracker.captureAutoError({
|
|
39
|
+
type: 'console.error',
|
|
40
|
+
message: message || 'Unknown console.error',
|
|
41
|
+
stack: stack,
|
|
42
|
+
severity: LogLevel.ERROR,
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
(tracker as unknown as Record<string, unknown>)['_isCapturingConsoleError'] = false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
window.addEventListener('error', function (event: ErrorEvent) {
|
|
50
|
+
tracker.captureAutoError({
|
|
51
|
+
type: 'crash',
|
|
52
|
+
message: event.message || 'Script error.',
|
|
53
|
+
stack: event.error?.stack,
|
|
54
|
+
loc: `${event.filename}:${event.lineno}:${event.colno}`,
|
|
55
|
+
severity: LogLevel.ERROR,
|
|
56
|
+
});
|
|
57
|
+
}, { capture: true });
|
|
58
|
+
|
|
59
|
+
window.addEventListener('unhandledrejection', function (event: PromiseRejectionEvent) {
|
|
60
|
+
const reason = event.reason;
|
|
61
|
+
const isError = reason instanceof Error;
|
|
62
|
+
tracker.captureAutoError({
|
|
63
|
+
type: 'promise',
|
|
64
|
+
message: isError ? reason.message : String(reason),
|
|
65
|
+
stack: isError ? reason.stack : new Error(`Unhandled rejection: ${String(reason)}`).stack,
|
|
66
|
+
severity: LogLevel.ERROR,
|
|
67
|
+
});
|
|
68
|
+
}, { capture: true });
|
|
69
|
+
|
|
70
|
+
// Flush on page unload
|
|
71
|
+
document.addEventListener('visibilitychange', function () {
|
|
72
|
+
if (document.visibilityState === 'hidden') {
|
|
73
|
+
void tracker.flush();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex collection for PII and credential masking.
|
|
3
|
+
*/
|
|
4
|
+
const PATTERNS = {
|
|
5
|
+
// Matches standard email formats
|
|
6
|
+
email: /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g,
|
|
7
|
+
|
|
8
|
+
// Matches standard CC groupings: 4-4-4-[3-4] with required separators, or Amex 4-6-5 format.
|
|
9
|
+
creditCard: /\b(?:\d{4}[-\s]){3}\d{3,4}\b|\b\d{4}[-\s]\d{6}[-\s]\d{5}\b/g,
|
|
10
|
+
|
|
11
|
+
// US Social Security Numbers (SSN): 3-2-4 format
|
|
12
|
+
ssn: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g,
|
|
13
|
+
|
|
14
|
+
// JWT Tokens (Header.Payload.Signature format starts with 'ey')
|
|
15
|
+
jwt: /\bey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
|
|
16
|
+
|
|
17
|
+
// Generic secret detection (Bearer tokens, Basic auth, API keys)
|
|
18
|
+
authHeader: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9\-._~+/=]+/gi,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Keys that suggest the value is highly sensitive and should be completely redacted
|
|
22
|
+
const SENSITIVE_KEYS = new Set([
|
|
23
|
+
'password',
|
|
24
|
+
'passwd',
|
|
25
|
+
'pwd',
|
|
26
|
+
'secret',
|
|
27
|
+
'token',
|
|
28
|
+
'authorization',
|
|
29
|
+
'api_key',
|
|
30
|
+
'apikey',
|
|
31
|
+
'access_token',
|
|
32
|
+
'refresh_token',
|
|
33
|
+
'session',
|
|
34
|
+
'cookie',
|
|
35
|
+
'credentials',
|
|
36
|
+
'client_secret',
|
|
37
|
+
'auth',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Masks sensitive patterns in a string.
|
|
42
|
+
*/
|
|
43
|
+
export function maskPII(input: string): string {
|
|
44
|
+
let masked = input;
|
|
45
|
+
|
|
46
|
+
// Mask emails: user@example.com -> u***@example.com
|
|
47
|
+
masked = masked.replace(PATTERNS.email, (match) => {
|
|
48
|
+
const parts = match.split('@');
|
|
49
|
+
if (parts.length !== 2) {
|
|
50
|
+
return match;
|
|
51
|
+
}
|
|
52
|
+
const [user, domain] = parts;
|
|
53
|
+
const visibleUser = user.charAt(0);
|
|
54
|
+
return `${visibleUser}***@${domain}`;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Mask credit cards: replaces all but the last 4 digits.
|
|
58
|
+
masked = masked.replace(PATTERNS.creditCard, (match) => {
|
|
59
|
+
const cleaned = match.replace(/[-\s]/g, '');
|
|
60
|
+
const last4 = cleaned.slice(-4);
|
|
61
|
+
return '*'.repeat(cleaned.length - 4) + last4;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Mask SSNs: replaces first 5 digits -> ***-**-1234
|
|
65
|
+
masked = masked.replace(PATTERNS.ssn, (match) => {
|
|
66
|
+
const last4 = match.slice(-4);
|
|
67
|
+
return `***-**-${last4}`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Mask JWTs: eyJhb... -> [JWT REDACTED]
|
|
71
|
+
masked = masked.replace(PATTERNS.jwt, '[JWT REDACTED]');
|
|
72
|
+
|
|
73
|
+
// Mask Auth Headers
|
|
74
|
+
masked = masked.replace(PATTERNS.authHeader, '$1 [TOKEN REDACTED]');
|
|
75
|
+
|
|
76
|
+
return masked;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deeply masks PII in an object or array.
|
|
81
|
+
* Handles sensitive keys by fully redacting their values.
|
|
82
|
+
*/
|
|
83
|
+
export function maskObjectPII<T>(obj: T, seen = new WeakSet()): T {
|
|
84
|
+
// Handle primitives and nulls
|
|
85
|
+
if (typeof obj === 'string') {
|
|
86
|
+
return maskPII(obj) as unknown as T;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!obj || typeof obj !== 'object') {
|
|
90
|
+
return obj;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Prevent circular reference infinite loops
|
|
94
|
+
if (seen.has(obj as object)) {
|
|
95
|
+
return '[Circular]' as unknown as T;
|
|
96
|
+
}
|
|
97
|
+
seen.add(obj as object);
|
|
98
|
+
|
|
99
|
+
if (Array.isArray(obj)) {
|
|
100
|
+
return obj.map((item) => maskObjectPII(item, seen)) as unknown as T;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result: Record<string, unknown> = {};
|
|
104
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
105
|
+
const lowerKey = key.toLowerCase();
|
|
106
|
+
|
|
107
|
+
// Completely redact known sensitive keys
|
|
108
|
+
if (SENSITIVE_KEYS.has(lowerKey) || Array.from(SENSITIVE_KEYS).some((k) => lowerKey.includes(k))) {
|
|
109
|
+
result[key] = '[REDACTED]';
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof value === 'string') {
|
|
114
|
+
result[key] = maskPII(value);
|
|
115
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
116
|
+
result[key] = maskObjectPII(value, seen);
|
|
117
|
+
} else {
|
|
118
|
+
result[key] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result as T;
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { VantmetryTracker } from './tracker';
|
|
2
|
+
|
|
3
|
+
let instance: VantmetryTracker | null = null;
|
|
4
|
+
|
|
5
|
+
export function setInstance(tracker: VantmetryTracker): void {
|
|
6
|
+
instance = tracker;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getInstance(): VantmetryTracker {
|
|
10
|
+
if (!instance) {
|
|
11
|
+
throw new Error('[Vantmetry] Not initialized. Call init() before using logger.');
|
|
12
|
+
}
|
|
13
|
+
return instance;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isInitialized(): boolean {
|
|
17
|
+
return instance !== null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { LogItem, LogPayload, VantmetryInstance, LogDetails, VantmetryConfig } from './types';
|
|
2
|
+
import { LogLevel } from './types';
|
|
3
|
+
import { TransportManager } from './transport';
|
|
4
|
+
import { maskPII, maskObjectPII } from './privacy';
|
|
5
|
+
|
|
6
|
+
const BATCH_LIMIT = 50;
|
|
7
|
+
const FLUSH_INTERVAL = 2000;
|
|
8
|
+
|
|
9
|
+
export class VantmetryTracker implements VantmetryInstance {
|
|
10
|
+
private buffer: Array<LogItem> = [];
|
|
11
|
+
private flushTimer: number | null = null;
|
|
12
|
+
private transport: TransportManager;
|
|
13
|
+
public isReady = true;
|
|
14
|
+
|
|
15
|
+
// Deduplication & Circuit Breaker
|
|
16
|
+
private sentErrors = new Map<string, number>();
|
|
17
|
+
private eventsThisSecond = 0;
|
|
18
|
+
private lastResetTime = Date.now();
|
|
19
|
+
private readonly TTL_MS = 60000;
|
|
20
|
+
private readonly MAX_EVENTS_PER_SEC = 100;
|
|
21
|
+
|
|
22
|
+
constructor(config: VantmetryConfig) {
|
|
23
|
+
this.transport = new TransportManager(config);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Public API ---
|
|
27
|
+
|
|
28
|
+
public error(message: string | unknown, details?: LogDetails) {
|
|
29
|
+
this.addToBuffer({ severity: LogLevel.ERROR, type: 'manual', message, details });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public warn(message: string, details?: LogDetails) {
|
|
33
|
+
this.addToBuffer({ severity: LogLevel.WARN, type: 'manual', message, details });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public info(message: string, details?: LogDetails) {
|
|
37
|
+
this.addToBuffer({ severity: LogLevel.INFO, type: 'manual', message, details });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public debug(message: string, details?: LogDetails) {
|
|
41
|
+
this.addToBuffer({ severity: LogLevel.DEBUG, type: 'manual', message, details });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async flush() {
|
|
45
|
+
if (this.buffer.length === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
for (const item of this.buffer) {
|
|
51
|
+
this.sentErrors.set(this.getSignature(item), now);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Clean up expired TTLs
|
|
55
|
+
for (const [key, timestamp] of this.sentErrors.entries()) {
|
|
56
|
+
if (now - timestamp >= this.TTL_MS) {
|
|
57
|
+
this.sentErrors.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dataPayload = JSON.stringify(this.buffer);
|
|
62
|
+
this.buffer = []; // Clear immediately
|
|
63
|
+
|
|
64
|
+
if (this.flushTimer) {
|
|
65
|
+
clearTimeout(this.flushTimer);
|
|
66
|
+
this.flushTimer = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await this.transport.send(dataPayload);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Internal Logic ---
|
|
73
|
+
|
|
74
|
+
private addToBuffer(payload: LogPayload) {
|
|
75
|
+
if (!this.isReady) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
|
|
81
|
+
// Emergency Circuit Breaker (Infinite Loop Protection)
|
|
82
|
+
if (now - this.lastResetTime > 1000) {
|
|
83
|
+
this.eventsThisSecond = 0;
|
|
84
|
+
this.lastResetTime = now;
|
|
85
|
+
}
|
|
86
|
+
this.eventsThisSecond++;
|
|
87
|
+
|
|
88
|
+
if (this.eventsThisSecond > this.MAX_EVENTS_PER_SEC) {
|
|
89
|
+
this.isReady = false;
|
|
90
|
+
console.error('[Vantmetry] Logging disabled to save browser CPU due to infinite loop detection.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const signature = this.getSignature(payload);
|
|
95
|
+
|
|
96
|
+
// Cross-flush Suppression
|
|
97
|
+
const lastSent = this.sentErrors.get(signature);
|
|
98
|
+
if (lastSent && now - lastSent < this.TTL_MS) {
|
|
99
|
+
return; // Silently drop exact duplicates within TTL
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Intra-buffer Deduplication
|
|
103
|
+
const existing = this.buffer.find((item) => this.getSignature(item) === signature);
|
|
104
|
+
if (existing) {
|
|
105
|
+
existing.count = (existing.count || 1) + 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let { message, stack } = payload;
|
|
110
|
+
const { details } = payload;
|
|
111
|
+
|
|
112
|
+
if (message instanceof Error) {
|
|
113
|
+
stack = stack ?? message.stack;
|
|
114
|
+
message = message.message || String(message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const maskedMessage = typeof message === 'string' ? maskPII(message) : message;
|
|
118
|
+
const maskedDetails = typeof details === 'object' ? maskObjectPII(details) : details;
|
|
119
|
+
const maskedStack = typeof stack === 'string' ? maskPII(stack) : stack;
|
|
120
|
+
|
|
121
|
+
this.buffer.push({
|
|
122
|
+
...payload,
|
|
123
|
+
message: maskedMessage,
|
|
124
|
+
details: maskedDetails,
|
|
125
|
+
stack: maskedStack,
|
|
126
|
+
count: 1,
|
|
127
|
+
ts: Date.now(),
|
|
128
|
+
url: window.location.href,
|
|
129
|
+
ua: navigator.userAgent,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (this.buffer.length >= BATCH_LIMIT) {
|
|
133
|
+
void this.flush();
|
|
134
|
+
} else if (!this.flushTimer) {
|
|
135
|
+
this.flushTimer = setTimeout(() => void this.flush(), FLUSH_INTERVAL) as unknown as number;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public captureAutoError(payload: LogPayload) {
|
|
140
|
+
this.addToBuffer(payload);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private getSignature(item: { type?: string; severity: string; message: unknown }): string {
|
|
144
|
+
return `${item.type}:${item.severity}:${item.message}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { VantmetryConfig } from './types';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INGESTOR_URL = 'https://ingestor.vantmetry.com:4433';
|
|
4
|
+
|
|
5
|
+
export class TransportManager {
|
|
6
|
+
private wtSession: WebTransport | null = null;
|
|
7
|
+
private readonly endpoint: string;
|
|
8
|
+
private readonly wtEndpoint: string;
|
|
9
|
+
private readonly debug: boolean;
|
|
10
|
+
|
|
11
|
+
constructor(config: VantmetryConfig) {
|
|
12
|
+
const base = (config.ingestorUrl ?? DEFAULT_INGESTOR_URL).replace(/\/$/, '');
|
|
13
|
+
this.endpoint = `${base}/api/ingestor/push/tcp?public_key=${config.publicKey}`;
|
|
14
|
+
this.wtEndpoint = `${base}/api/ingestor/push/udp?public_key=${config.publicKey}`;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
this.debug = typeof window !== 'undefined' && !!window.localStorage?.getItem('vantmetry_debug');
|
|
18
|
+
} catch {
|
|
19
|
+
this.debug = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
void this.initWT();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async initWT() {
|
|
26
|
+
if (!('WebTransport' in window)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Give browser a short moment to process Alt-Svc from previous visits
|
|
31
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
32
|
+
|
|
33
|
+
// Now attempt WebTransport - browser should know to use HTTP/3
|
|
34
|
+
try {
|
|
35
|
+
this.wtSession = new WebTransport(this.wtEndpoint);
|
|
36
|
+
await this.wtSession.ready;
|
|
37
|
+
if (this.debug) {
|
|
38
|
+
console.log('WT: Connected');
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (this.debug) {
|
|
42
|
+
console.warn('WT: Failed, falling back to beacon', err);
|
|
43
|
+
}
|
|
44
|
+
this.wtSession = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public async send(payload: string): Promise<void> {
|
|
49
|
+
if (this.wtSession) {
|
|
50
|
+
try {
|
|
51
|
+
const stream = await this.wtSession.createUnidirectionalStream();
|
|
52
|
+
const writer = stream.getWriter();
|
|
53
|
+
await writer.write(new TextEncoder().encode(payload));
|
|
54
|
+
await writer.close();
|
|
55
|
+
return;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (this.debug) {
|
|
58
|
+
console.warn('WT: Failed, falling back to beacon', err);
|
|
59
|
+
}
|
|
60
|
+
this.wtSession = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
65
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
66
|
+
if (navigator.sendBeacon(this.endpoint, blob)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fetch(this.endpoint, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: payload,
|
|
74
|
+
keepalive: true,
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
}).catch((error) => {
|
|
77
|
+
if (this.debug) {
|
|
78
|
+
console.error('Vantmetry send failed', error);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const LogLevel = {
|
|
2
|
+
ERROR: 'ERROR',
|
|
3
|
+
INFO: 'INFO',
|
|
4
|
+
WARN: 'WARN',
|
|
5
|
+
DEBUG: 'DEBUG',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export type VantmetryLogLevel = (typeof LogLevel)[keyof typeof LogLevel];
|
|
9
|
+
export type LogDetails = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export interface VantmetryInstance {
|
|
12
|
+
isReady: boolean;
|
|
13
|
+
error: (message: string | unknown, details?: LogDetails) => void;
|
|
14
|
+
warn: (message: string, details?: LogDetails) => void;
|
|
15
|
+
info: (message: string, details?: LogDetails) => void;
|
|
16
|
+
debug: (message: string, details?: LogDetails) => void;
|
|
17
|
+
flush: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
interface Window {
|
|
22
|
+
Vantmetry?: VantmetryInstance;
|
|
23
|
+
onVantmetryReady?: (instance: VantmetryInstance) => void;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LogPayload {
|
|
28
|
+
message: string | Event | unknown;
|
|
29
|
+
severity: VantmetryLogLevel;
|
|
30
|
+
type?: string;
|
|
31
|
+
stack?: string;
|
|
32
|
+
loc?: string;
|
|
33
|
+
trace_id?: string;
|
|
34
|
+
details?: LogDetails;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LogItem extends LogPayload {
|
|
38
|
+
count: number;
|
|
39
|
+
ts: number;
|
|
40
|
+
url: string;
|
|
41
|
+
ua: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VantmetryConfig {
|
|
45
|
+
publicKey: string;
|
|
46
|
+
ingestorUrl?: string;
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getInstance } from './core/singleton';
|
|
2
|
+
import type { VantmetryInstance } from './core/types';
|
|
3
|
+
|
|
4
|
+
export { init } from './core/init';
|
|
5
|
+
export { VantmetryTracker } from './core/tracker';
|
|
6
|
+
export { initGlobalListeners } from './core/listeners';
|
|
7
|
+
export type { VantmetryConfig, LogDetails, VantmetryInstance, VantmetryLogLevel } from './core/types';
|
|
8
|
+
|
|
9
|
+
export const logger: VantmetryInstance = {
|
|
10
|
+
get isReady() {
|
|
11
|
+
return getInstance().isReady;
|
|
12
|
+
},
|
|
13
|
+
error(message, details) {
|
|
14
|
+
getInstance().error(message, details);
|
|
15
|
+
},
|
|
16
|
+
warn(message, details) {
|
|
17
|
+
getInstance().warn(message, details);
|
|
18
|
+
},
|
|
19
|
+
info(message, details) {
|
|
20
|
+
getInstance().info(message, details);
|
|
21
|
+
},
|
|
22
|
+
debug(message, details) {
|
|
23
|
+
getInstance().debug(message, details);
|
|
24
|
+
},
|
|
25
|
+
flush() {
|
|
26
|
+
return getInstance().flush();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Script from 'next/script';
|
|
3
|
+
import { init, type VantmetryConfig } from '../index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Drop this into your Next.js layout or _document to load the tracker via CDN.
|
|
7
|
+
* Uses next/script so Next.js controls placement and loading strategy.
|
|
8
|
+
*/
|
|
9
|
+
export function VantmetryScript({ publicKey, ingestorUrl }: VantmetryConfig) {
|
|
10
|
+
return (
|
|
11
|
+
<Script
|
|
12
|
+
strategy="afterInteractive"
|
|
13
|
+
data-public-key={publicKey}
|
|
14
|
+
data-ingestor-url={ingestorUrl}
|
|
15
|
+
src="https://cdn.vantmetry.com/tracker.js"
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* SSR-safe wrapper around init(). Use this in _app.tsx, app/layout.tsx client
|
|
22
|
+
* components, or anywhere module-level code may run on the server.
|
|
23
|
+
*/
|
|
24
|
+
export function initVantmetry(config: VantmetryConfig): void {
|
|
25
|
+
if (typeof window === 'undefined') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
init(config);
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare module 'next/script' {
|
|
2
|
+
import type { FC } from 'react';
|
|
3
|
+
|
|
4
|
+
interface ScriptProps {
|
|
5
|
+
id?: string;
|
|
6
|
+
src?: string;
|
|
7
|
+
strategy?: 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker';
|
|
8
|
+
onLoad?: () => void;
|
|
9
|
+
onReady?: () => void;
|
|
10
|
+
onError?: (error: Error) => void;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Script: FC<ScriptProps>;
|
|
15
|
+
export default Script;
|
|
16
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, Component, type ReactNode } from 'react';
|
|
2
|
+
import { init, logger, type VantmetryConfig, type VantmetryInstance } from '../index';
|
|
3
|
+
|
|
4
|
+
const VantmetryContext = createContext<VantmetryInstance | null>(null);
|
|
5
|
+
|
|
6
|
+
interface VantmetryProviderProps extends VantmetryConfig {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Initializes Vantmetry and makes the logger available via {@link useLogger}.
|
|
12
|
+
*/
|
|
13
|
+
export function VantmetryProvider({ publicKey, ingestorUrl, children }: VantmetryProviderProps) {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
init({ publicKey, ingestorUrl });
|
|
16
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
17
|
+
|
|
18
|
+
return <VantmetryContext.Provider value={logger}>{children}</VantmetryContext.Provider>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns the Vantmetry logger. Must be called inside a {@link VantmetryProvider}.
|
|
23
|
+
*/
|
|
24
|
+
export function useLogger(): VantmetryInstance {
|
|
25
|
+
const context = useContext(VantmetryContext);
|
|
26
|
+
if (context === null) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'useLogger() was called outside of VantmetryProvider. Wrap your app with <VantmetryProvider publicKey="..." /> to use this hook.',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return context;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ErrorBoundaryProps {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
/** Rendered when a render error is caught. Defaults to null (renders nothing). */
|
|
37
|
+
fallback?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ErrorBoundaryState {
|
|
41
|
+
hasError: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Catches React render errors, logs them via Vantmetry, and renders the fallback.
|
|
46
|
+
* Place around any subtree you want to protect.
|
|
47
|
+
*/
|
|
48
|
+
export class VantmetryErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
49
|
+
constructor(props: ErrorBoundaryProps) {
|
|
50
|
+
super(props);
|
|
51
|
+
this.state = { hasError: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static getDerivedStateFromError(_error: Error): ErrorBoundaryState {
|
|
55
|
+
return { hasError: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
|
59
|
+
logger.error(error, { componentStack: info.componentStack ?? undefined });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override render(): ReactNode {
|
|
63
|
+
if (this.state.hasError) {
|
|
64
|
+
return this.props.fallback ?? null;
|
|
65
|
+
}
|
|
66
|
+
return this.props.children;
|
|
67
|
+
}
|
|
68
|
+
}
|