@moblog/browser 0.0.0-stage → 0.1.1
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/README.md +83 -2
- package/dist/client.d.ts +54 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +383 -0
- package/dist/types.d.ts +49 -0
- package/dist/util.d.ts +14 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @moblog/browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send logs and errors from web pages to [moblog](https://moblog.dev). About 3.3 KB gzipped, no dependencies.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @moblog/browser
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import * as moblog from "@moblog/browser";
|
|
11
|
+
|
|
12
|
+
moblog.init({
|
|
13
|
+
key: "mob_ingest_...", // an ingest key that allows this site
|
|
14
|
+
release: "2.4.1", // or a git SHA
|
|
15
|
+
environment: "production",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
moblog.info("checkout opened", { cart: { items: 3 } });
|
|
19
|
+
moblog.setUser("user-48213"); // undefined when they sign out
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await pay();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
moblog.captureError(err, { provider: "stripe" });
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## The key
|
|
29
|
+
|
|
30
|
+
Use an **ingest key** (`mob_ingest_...`): it can only send events, so it is safe in a page, which makes it public. Allow your sites on it, in the web app's Keys page or with `moblog keys update <id> --add-site https://app.example.com`: requests from other sites are refused. A secret key (`mob_secret_...`) is never sent: the SDK refuses it.
|
|
31
|
+
|
|
32
|
+
If your site sets a Content Security Policy, allow the ingest host in `connect-src`.
|
|
33
|
+
|
|
34
|
+
## What it sends
|
|
35
|
+
|
|
36
|
+
- Your events, with the level, the page's path (the server drops query strings and masks IDs: `/orders/:id`), a session ID and the event's place in the session.
|
|
37
|
+
- Uncaught errors and unhandled promise rejections, with their stacks (`captureErrors: false` turns this off). The same error repeated within 5 seconds is sent once, and at most 30 errors a minute.
|
|
38
|
+
- A "page view" event when the path changes, in single-page apps too (`trackPages: false`).
|
|
39
|
+
- The browser, system and kind of device, which the server reads from the user agent (and doesn't store). An anonymous random device ID, kept in `localStorage`. The language and time zone.
|
|
40
|
+
|
|
41
|
+
No cookies. With `persist: false`, nothing is written to `localStorage` or `sessionStorage`: the device ID and unsent events last as long as the page.
|
|
42
|
+
|
|
43
|
+
## Options
|
|
44
|
+
|
|
45
|
+
| Option | Default | |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `key` | required | an ingest key |
|
|
48
|
+
| `endpoint` | `https://ingest.moblog.dev` | the ingest URL (beta: `https://ingest.beta.moblog.dev`) |
|
|
49
|
+
| `app` | the page's host | the app's ID |
|
|
50
|
+
| `release`, `environment` | | the deployed version and its environment |
|
|
51
|
+
| `level` | `"info"` | the least severe level sent |
|
|
52
|
+
| `beforeSend` | | `(event) => event \| null`: change an event or drop it, before it is queued. A filter that throws drops the event. |
|
|
53
|
+
| `captureErrors` | `true` | uncaught errors and unhandled rejections |
|
|
54
|
+
| `captureConsole` | `false` | `true` for `console.warn` and `console.error`, or a list of levels |
|
|
55
|
+
| `trackPages` | `true` | a page view when the path changes |
|
|
56
|
+
| `persist` | `true` | keep the device ID and unsent events in storage |
|
|
57
|
+
| `flushInterval` | `5000` | ms between sends |
|
|
58
|
+
| `sessionTimeout` | `30` | minutes without events that end a session |
|
|
59
|
+
|
|
60
|
+
## Delivery
|
|
61
|
+
|
|
62
|
+
Events are queued and sent every few seconds, or at 100 events. Failed sends are retried with backoff (`Retry-After` is respected), keeping the batch's ID, so a retried batch is stored once. Up to 1,000 unsent events are kept, the newest. When the page is hidden or closed, what is queued goes through `navigator.sendBeacon`; what the browser won't take is kept in `localStorage` and sent on the next visit. When the organization is over its monthly quota, only errors are sent until it resets.
|
|
63
|
+
|
|
64
|
+
`await moblog.flush()` sends what is queued now; `client.shutdown()` stops capturing and sends what is left. Calling `init` on the server (a server render) does nothing.
|
|
65
|
+
|
|
66
|
+
## More than one client
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { Client } from "@moblog/browser";
|
|
70
|
+
const other = new Client({ key: "mob_ingest_...", trackPages: false, captureErrors: false });
|
|
71
|
+
other.log("warn", "from a widget");
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Developing
|
|
75
|
+
|
|
76
|
+
`pnpm test` (unit tests, jsdom), `pnpm build`, `pnpm size` (fails above 5 KB gzipped), `pnpm e2e` (Chromium: a page on one origin, an ingest stand-in on another).
|
|
77
|
+
|
|
78
|
+
## Publishing
|
|
79
|
+
|
|
80
|
+
Published by hand, from an up-to-date `main`, by a member of the npm organization `moblog`:
|
|
81
|
+
|
|
82
|
+
1. Set the version in `package.json` and `VERSION` in `src/client.ts` (a test checks they match), add it to `CHANGELOG.md`, and merge that.
|
|
83
|
+
2. `npm login` (once), then from `packages/browser`: `npm publish`. It runs the type check, the tests, the build and the size check first (`prepublishOnly`); `npm publish --dry-run` shows what would be sent.
|
|
84
|
+
3. Tag the commit: `git tag browser-v0.1.1 && git push origin browser-v0.1.1`.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Attributes, Level, Options } from "./types";
|
|
2
|
+
export declare const VERSION = "0.1.1";
|
|
3
|
+
/** A moblog client: queues events, sends them in batches, and keeps them until they are stored. */
|
|
4
|
+
export declare class Client {
|
|
5
|
+
private readonly o;
|
|
6
|
+
private readonly url;
|
|
7
|
+
private readonly local;
|
|
8
|
+
private readonly tab;
|
|
9
|
+
private readonly device;
|
|
10
|
+
private readonly on;
|
|
11
|
+
private queue;
|
|
12
|
+
private batches;
|
|
13
|
+
private inflight?;
|
|
14
|
+
private retryAt;
|
|
15
|
+
private backoff;
|
|
16
|
+
private quotaUntil;
|
|
17
|
+
private sess?;
|
|
18
|
+
private user?;
|
|
19
|
+
private attrs;
|
|
20
|
+
private page;
|
|
21
|
+
private errors;
|
|
22
|
+
private lastError;
|
|
23
|
+
private inConsole;
|
|
24
|
+
private readonly warned;
|
|
25
|
+
private readonly undo;
|
|
26
|
+
private readonly say;
|
|
27
|
+
constructor(options: Options);
|
|
28
|
+
/** Queues an event. */
|
|
29
|
+
log(level: Level, message: unknown, attributes?: Attributes): void;
|
|
30
|
+
/** Queues an error event with its stack. */
|
|
31
|
+
captureError(error: unknown, attributes?: Attributes): void;
|
|
32
|
+
/** The signed-in user's ID for the next events; undefined when they sign out. */
|
|
33
|
+
setUser(id: string | undefined): void;
|
|
34
|
+
/** Attributes added to every next event. */
|
|
35
|
+
setAttributes(attributes: Attributes): void;
|
|
36
|
+
/** Sends what is queued; resolves when there is nothing left to send now. */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
/** Stops capturing and sends what is left. */
|
|
39
|
+
shutdown(): Promise<void>;
|
|
40
|
+
private capture;
|
|
41
|
+
private pageView;
|
|
42
|
+
private wrapConsole;
|
|
43
|
+
private listen;
|
|
44
|
+
private session;
|
|
45
|
+
private trim;
|
|
46
|
+
private batchUp;
|
|
47
|
+
private save;
|
|
48
|
+
private body;
|
|
49
|
+
/** Sends a batch: true when it is done with (stored or refused for good), false to retry later. */
|
|
50
|
+
private send;
|
|
51
|
+
private later;
|
|
52
|
+
/** The page is going away: hand what is queued to the browser, which sends it after the page is gone. */
|
|
53
|
+
private beacon;
|
|
54
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Client } from "./client";
|
|
2
|
+
import type { Attributes, Level, Options } from "./types";
|
|
3
|
+
export { Client, VERSION } from "./client";
|
|
4
|
+
export type { Attributes, Level, MoblogEvent, Options } from "./types";
|
|
5
|
+
/** Starts sending: call once, as early as the page can. A second call replaces the first client. */
|
|
6
|
+
export declare function init(options: Options): Client;
|
|
7
|
+
export declare const log: (level: Level, message: unknown, attributes?: Attributes) => void | undefined;
|
|
8
|
+
export declare const debug: (message: unknown, attributes?: Attributes) => void | undefined;
|
|
9
|
+
export declare const info: (message: unknown, attributes?: Attributes) => void | undefined;
|
|
10
|
+
export declare const warn: (message: unknown, attributes?: Attributes) => void | undefined;
|
|
11
|
+
export declare const error: (message: unknown, attributes?: Attributes) => void | undefined;
|
|
12
|
+
export declare const captureError: (err: unknown, attributes?: Attributes) => void | undefined;
|
|
13
|
+
export declare const setUser: (id: string | undefined) => void | undefined;
|
|
14
|
+
export declare const setAttributes: (attributes: Attributes) => void | undefined;
|
|
15
|
+
export declare const flush: () => Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
//#region src/util.ts
|
|
2
|
+
/** A random UUID; crypto.randomUUID needs a secure context, getRandomValues doesn't. */
|
|
3
|
+
function uuid() {
|
|
4
|
+
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
5
|
+
return format(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)), 64);
|
|
6
|
+
}
|
|
7
|
+
/** A UUIDv7: the time in Unix milliseconds, then random bits, so IDs sort by when they were made. */
|
|
8
|
+
function uuid7(ms) {
|
|
9
|
+
const b = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
10
|
+
const t = Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : 0;
|
|
11
|
+
for (let i = 0; i < 6; i++) b[i] = Math.floor(t / 2 ** (40 - 8 * i)) % 256;
|
|
12
|
+
return format(b, 112);
|
|
13
|
+
}
|
|
14
|
+
/** 16 bytes as a UUID of a version (0x40, 0x70), with RFC 9562's variant. */
|
|
15
|
+
function format(b, version) {
|
|
16
|
+
b[6] = b[6] & 15 | version;
|
|
17
|
+
b[8] = b[8] & 63 | 128;
|
|
18
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
19
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
20
|
+
}
|
|
21
|
+
/** Storage that may be missing (privacy modes, sandboxed frames) or full: failures are ignored. */
|
|
22
|
+
function store(kind, on) {
|
|
23
|
+
let s;
|
|
24
|
+
try {
|
|
25
|
+
s = on ? window[kind] : void 0;
|
|
26
|
+
} catch {}
|
|
27
|
+
const mem = /* @__PURE__ */ new Map();
|
|
28
|
+
return {
|
|
29
|
+
get(k) {
|
|
30
|
+
try {
|
|
31
|
+
return s ? s.getItem(k) : mem.get(k) ?? null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
set(k, v) {
|
|
37
|
+
try {
|
|
38
|
+
if (s) s.setItem(k, v);
|
|
39
|
+
else mem.set(k, v);
|
|
40
|
+
} catch {
|
|
41
|
+
try {
|
|
42
|
+
s?.removeItem(k);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** JSON.parse that answers undefined instead of throwing. */
|
|
49
|
+
function parse(s) {
|
|
50
|
+
try {
|
|
51
|
+
return s ? JSON.parse(s) : void 0;
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** A value as text for a message: strings as they are, errors by name and message, the rest as JSON. */
|
|
57
|
+
function text(v) {
|
|
58
|
+
if (typeof v === "string") return v;
|
|
59
|
+
if (v instanceof Error) return `${v.name}: ${v.message}`;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.stringify(v) ?? String(v);
|
|
62
|
+
} catch {
|
|
63
|
+
return String(v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/client.ts
|
|
68
|
+
var VERSION = "0.1.1";
|
|
69
|
+
var LEVELS = [
|
|
70
|
+
"trace",
|
|
71
|
+
"debug",
|
|
72
|
+
"info",
|
|
73
|
+
"warn",
|
|
74
|
+
"error",
|
|
75
|
+
"fatal"
|
|
76
|
+
];
|
|
77
|
+
var MAX_QUEUE = 1e3;
|
|
78
|
+
var MAX_BATCH = 100;
|
|
79
|
+
var MAX_BODY = 6e4;
|
|
80
|
+
var MAX_TEXT = 8e3;
|
|
81
|
+
var ERRORS_PER_MINUTE = 30;
|
|
82
|
+
var rank = (l) => LEVELS.indexOf(l);
|
|
83
|
+
/** Whether a body fits keepalive and beacon requests (counted in UTF-8 bytes). */
|
|
84
|
+
var small = (body) => body.length * 3 < MAX_BODY || new TextEncoder().encode(body).length < MAX_BODY;
|
|
85
|
+
/** A moblog client: queues events, sends them in batches, and keeps them until they are stored. */
|
|
86
|
+
var Client = class {
|
|
87
|
+
constructor(options) {
|
|
88
|
+
this.queue = [];
|
|
89
|
+
this.batches = [];
|
|
90
|
+
this.retryAt = 0;
|
|
91
|
+
this.backoff = 0;
|
|
92
|
+
this.quotaUntil = 0;
|
|
93
|
+
this.attrs = {};
|
|
94
|
+
this.page = "";
|
|
95
|
+
this.errors = [];
|
|
96
|
+
this.lastError = /* @__PURE__ */ new Map();
|
|
97
|
+
this.inConsole = false;
|
|
98
|
+
this.warned = /* @__PURE__ */ new Set();
|
|
99
|
+
this.undo = [];
|
|
100
|
+
this.say = typeof console === "undefined" ? () => {} : console.warn.bind(console);
|
|
101
|
+
this.o = options;
|
|
102
|
+
this.url = `${(options.endpoint ?? "https://ingest.moblog.dev").replace(/\/+$/, "")}/v1/ingest`;
|
|
103
|
+
const persist = options.persist !== false;
|
|
104
|
+
this.local = store("localStorage", persist);
|
|
105
|
+
this.tab = store("sessionStorage", persist);
|
|
106
|
+
this.device = this.local.get("moblog.device") ?? uuid();
|
|
107
|
+
this.local.set("moblog.device", this.device);
|
|
108
|
+
this.on = typeof window !== "undefined" && /^mob_ingest_/.test(options.key);
|
|
109
|
+
if (!this.on) {
|
|
110
|
+
if (typeof window !== "undefined") this.say("moblog: use an ingest key (mob_ingest_...); nothing is sent");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.batches = parse(this.local.get("moblog.queue")) ?? [];
|
|
114
|
+
const every = setInterval(() => void this.flush(), options.flushInterval ?? 5e3);
|
|
115
|
+
this.undo.push(() => clearInterval(every));
|
|
116
|
+
const hidden = () => document.visibilityState === "hidden" && this.beacon();
|
|
117
|
+
this.listen(document, "visibilitychange", hidden);
|
|
118
|
+
this.listen(window, "pagehide", () => this.beacon());
|
|
119
|
+
if (options.captureErrors !== false) {
|
|
120
|
+
this.listen(window, "error", (e) => {
|
|
121
|
+
const ev = e;
|
|
122
|
+
this.capture(ev.error ?? ev.message, ev.filename ? { "error.source": `${ev.filename}:${ev.lineno}:${ev.colno}` } : {});
|
|
123
|
+
});
|
|
124
|
+
this.listen(window, "unhandledrejection", (e) => this.capture(e.reason, {}, "Unhandled rejection: "));
|
|
125
|
+
}
|
|
126
|
+
const c = options.captureConsole;
|
|
127
|
+
for (const lv of c === true ? ["warn", "error"] : c || []) this.wrapConsole(lv);
|
|
128
|
+
if (options.trackPages !== false) {
|
|
129
|
+
const track = () => this.pageView();
|
|
130
|
+
for (const m of ["pushState", "replaceState"]) {
|
|
131
|
+
const orig = history[m];
|
|
132
|
+
history[m] = function(...args) {
|
|
133
|
+
orig.apply(this, args);
|
|
134
|
+
track();
|
|
135
|
+
};
|
|
136
|
+
this.undo.push(() => {
|
|
137
|
+
history[m] = orig;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
this.listen(window, "popstate", track);
|
|
141
|
+
track();
|
|
142
|
+
}
|
|
143
|
+
if (this.batches.length) this.flush();
|
|
144
|
+
}
|
|
145
|
+
/** Queues an event. */
|
|
146
|
+
log(level, message, attributes) {
|
|
147
|
+
if (!this.on || rank(level) < rank(this.o.level ?? "info")) return;
|
|
148
|
+
if (Date.now() < this.quotaUntil && rank(level) < rank("error")) return;
|
|
149
|
+
const s = this.session();
|
|
150
|
+
const attrs = {
|
|
151
|
+
...this.attrs,
|
|
152
|
+
...attributes
|
|
153
|
+
};
|
|
154
|
+
let e = {
|
|
155
|
+
timestamp: Date.now(),
|
|
156
|
+
level,
|
|
157
|
+
message: text(message).slice(0, MAX_TEXT),
|
|
158
|
+
session_id: s.id,
|
|
159
|
+
seq: s.seq++,
|
|
160
|
+
page: location.pathname,
|
|
161
|
+
user_id: this.user,
|
|
162
|
+
attributes: Object.keys(attrs).length ? attrs : void 0
|
|
163
|
+
};
|
|
164
|
+
this.tab.set("moblog.session", JSON.stringify(s));
|
|
165
|
+
if (this.o.beforeSend) try {
|
|
166
|
+
e = this.o.beforeSend(e);
|
|
167
|
+
} catch {
|
|
168
|
+
e = null;
|
|
169
|
+
}
|
|
170
|
+
if (!e) return;
|
|
171
|
+
if (!e.event_id) e.event_id = uuid7(e.timestamp);
|
|
172
|
+
try {
|
|
173
|
+
JSON.stringify(e);
|
|
174
|
+
} catch {
|
|
175
|
+
e.attributes = { "moblog.error": "attributes are not JSON" };
|
|
176
|
+
}
|
|
177
|
+
this.queue.push(e);
|
|
178
|
+
this.trim();
|
|
179
|
+
if (this.queue.length >= MAX_BATCH) this.flush();
|
|
180
|
+
}
|
|
181
|
+
/** Queues an error event with its stack. */
|
|
182
|
+
captureError(error, attributes) {
|
|
183
|
+
this.capture(error, attributes ?? {});
|
|
184
|
+
}
|
|
185
|
+
/** The signed-in user's ID for the next events; undefined when they sign out. */
|
|
186
|
+
setUser(id) {
|
|
187
|
+
this.user = id;
|
|
188
|
+
}
|
|
189
|
+
/** Attributes added to every next event. */
|
|
190
|
+
setAttributes(attributes) {
|
|
191
|
+
this.attrs = {
|
|
192
|
+
...this.attrs,
|
|
193
|
+
...attributes
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Sends what is queued; resolves when there is nothing left to send now. */
|
|
197
|
+
async flush() {
|
|
198
|
+
if (!this.on) return;
|
|
199
|
+
this.batchUp();
|
|
200
|
+
if (this.inflight || Date.now() < this.retryAt) return;
|
|
201
|
+
try {
|
|
202
|
+
for (let b = this.batches[0]; b; b = this.batches[0]) {
|
|
203
|
+
this.inflight = b;
|
|
204
|
+
if (!await this.send(b)) break;
|
|
205
|
+
this.batches = this.batches.filter((x) => x !== b);
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
this.inflight = void 0;
|
|
209
|
+
this.save();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Stops capturing and sends what is left. */
|
|
213
|
+
shutdown() {
|
|
214
|
+
for (const f of this.undo.splice(0)) f();
|
|
215
|
+
return this.flush();
|
|
216
|
+
}
|
|
217
|
+
capture(error, attributes, prefix = "") {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const message = prefix + text(error);
|
|
220
|
+
if (now - (this.lastError.get(message) ?? 0) < 5e3) return;
|
|
221
|
+
this.errors = this.errors.filter((t) => now - t < 6e4);
|
|
222
|
+
if (this.errors.length >= ERRORS_PER_MINUTE) return;
|
|
223
|
+
this.errors.push(now);
|
|
224
|
+
if (this.lastError.size > 100) this.lastError.clear();
|
|
225
|
+
this.lastError.set(message, now);
|
|
226
|
+
const err = error instanceof Error ? error : void 0;
|
|
227
|
+
this.log("error", message, {
|
|
228
|
+
"error.type": err?.name,
|
|
229
|
+
"error.stack": err?.stack?.slice(0, MAX_TEXT),
|
|
230
|
+
...attributes
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
pageView() {
|
|
234
|
+
const path = location.pathname;
|
|
235
|
+
if (path === this.page) return;
|
|
236
|
+
const from = this.page;
|
|
237
|
+
this.page = path;
|
|
238
|
+
this.log("info", "page view", from ? { "page.from": from } : void 0);
|
|
239
|
+
}
|
|
240
|
+
wrapConsole(lv) {
|
|
241
|
+
const orig = console[lv];
|
|
242
|
+
console[lv] = (...args) => {
|
|
243
|
+
orig.apply(console, args);
|
|
244
|
+
if (this.inConsole) return;
|
|
245
|
+
this.inConsole = true;
|
|
246
|
+
try {
|
|
247
|
+
this.log(lv, args.map(text).join(" "), { source: "console" });
|
|
248
|
+
} finally {
|
|
249
|
+
this.inConsole = false;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
this.undo.push(() => {
|
|
253
|
+
console[lv] = orig;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
listen(target, type, fn) {
|
|
257
|
+
target.addEventListener(type, fn);
|
|
258
|
+
this.undo.push(() => target.removeEventListener(type, fn));
|
|
259
|
+
}
|
|
260
|
+
session() {
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
let s = this.sess ?? parse(this.tab.get("moblog.session"));
|
|
263
|
+
if (!s || now - s.last > (this.o.sessionTimeout ?? 30) * 6e4) s = {
|
|
264
|
+
id: uuid(),
|
|
265
|
+
last: now,
|
|
266
|
+
seq: 0
|
|
267
|
+
};
|
|
268
|
+
s.last = now;
|
|
269
|
+
this.sess = s;
|
|
270
|
+
return s;
|
|
271
|
+
}
|
|
272
|
+
trim() {
|
|
273
|
+
let over = this.queue.length + this.batches.reduce((n, b) => n + b.events.length, 0) - MAX_QUEUE;
|
|
274
|
+
for (const b of this.batches) {
|
|
275
|
+
if (over <= 0 || b === this.inflight) continue;
|
|
276
|
+
const n = Math.min(over, b.events.length);
|
|
277
|
+
b.events.splice(0, n);
|
|
278
|
+
over -= n;
|
|
279
|
+
}
|
|
280
|
+
this.batches = this.batches.filter((b) => b.events.length);
|
|
281
|
+
if (over > 0) this.queue.splice(0, over);
|
|
282
|
+
}
|
|
283
|
+
batchUp() {
|
|
284
|
+
while (this.queue.length) this.batches.push({
|
|
285
|
+
id: uuid(),
|
|
286
|
+
events: this.queue.splice(0, MAX_BATCH)
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
save() {
|
|
290
|
+
this.local.set("moblog.queue", JSON.stringify(this.batches));
|
|
291
|
+
}
|
|
292
|
+
body(b) {
|
|
293
|
+
return JSON.stringify({
|
|
294
|
+
batch_id: b.id,
|
|
295
|
+
sent_at: Date.now(),
|
|
296
|
+
resource: {
|
|
297
|
+
app: {
|
|
298
|
+
id: this.o.app ?? location.host,
|
|
299
|
+
version: this.o.release,
|
|
300
|
+
environment: this.o.environment
|
|
301
|
+
},
|
|
302
|
+
device: {
|
|
303
|
+
id: this.device,
|
|
304
|
+
platform: "web",
|
|
305
|
+
locale: navigator.language,
|
|
306
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
307
|
+
},
|
|
308
|
+
sdk: {
|
|
309
|
+
name: "moblog-browser",
|
|
310
|
+
version: VERSION
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
events: b.events
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** Sends a batch: true when it is done with (stored or refused for good), false to retry later. */
|
|
317
|
+
async send(b) {
|
|
318
|
+
const body = this.body(b);
|
|
319
|
+
let res;
|
|
320
|
+
try {
|
|
321
|
+
res = await fetch(this.url, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: {
|
|
324
|
+
Authorization: `Bearer ${this.o.key}`,
|
|
325
|
+
"Content-Type": "application/json"
|
|
326
|
+
},
|
|
327
|
+
body,
|
|
328
|
+
keepalive: small(body),
|
|
329
|
+
credentials: "omit"
|
|
330
|
+
});
|
|
331
|
+
} catch {
|
|
332
|
+
return this.later(0);
|
|
333
|
+
}
|
|
334
|
+
const reset = res.headers.get("Moblog-Quota-Reset");
|
|
335
|
+
this.quotaUntil = res.headers.get("Moblog-Quota") === "exceeded" && reset ? Date.parse(reset) || 0 : 0;
|
|
336
|
+
if (res.ok) {
|
|
337
|
+
this.backoff = 0;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
if (res.status === 408 || res.status === 429 || res.status >= 500) return this.later(Number(res.headers.get("Retry-After")) * 1e3 || 0);
|
|
341
|
+
if (!this.warned.has(String(res.status))) {
|
|
342
|
+
this.warned.add(String(res.status));
|
|
343
|
+
this.say(`moblog: events refused (${res.status}): ${await res.text().catch(() => "")}`);
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
later(ms) {
|
|
348
|
+
this.backoff = Math.min(Math.max(this.backoff * 2, 1e3), 3e5);
|
|
349
|
+
this.retryAt = Date.now() + Math.max(ms, this.backoff);
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
/** The page is going away: hand what is queued to the browser, which sends it after the page is gone. */
|
|
353
|
+
beacon() {
|
|
354
|
+
this.batchUp();
|
|
355
|
+
const url = `${this.url}?key=${encodeURIComponent(this.o.key)}`;
|
|
356
|
+
this.batches = this.batches.filter((b) => {
|
|
357
|
+
if (b === this.inflight) return true;
|
|
358
|
+
const body = this.body(b);
|
|
359
|
+
return !(small(body) && navigator.sendBeacon?.(url, body));
|
|
360
|
+
});
|
|
361
|
+
this.save();
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/index.ts
|
|
366
|
+
var client;
|
|
367
|
+
/** Starts sending: call once, as early as the page can. A second call replaces the first client. */
|
|
368
|
+
function init(options) {
|
|
369
|
+
client?.shutdown();
|
|
370
|
+
client = new Client(options);
|
|
371
|
+
return client;
|
|
372
|
+
}
|
|
373
|
+
var log = (level, message, attributes) => client?.log(level, message, attributes);
|
|
374
|
+
var debug = (message, attributes) => log("debug", message, attributes);
|
|
375
|
+
var info = (message, attributes) => log("info", message, attributes);
|
|
376
|
+
var warn = (message, attributes) => log("warn", message, attributes);
|
|
377
|
+
var error = (message, attributes) => log("error", message, attributes);
|
|
378
|
+
var captureError = (err, attributes) => client?.captureError(err, attributes);
|
|
379
|
+
var setUser = (id) => client?.setUser(id);
|
|
380
|
+
var setAttributes = (attributes) => client?.setAttributes(attributes);
|
|
381
|
+
var flush = () => client?.flush() ?? Promise.resolve();
|
|
382
|
+
//#endregion
|
|
383
|
+
export { Client, VERSION, captureError, debug, error, flush, info, init, log, setAttributes, setUser, warn };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type Level = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
2
|
+
/** Attribute values: nested objects become dotted keys (cart.items) on the server. */
|
|
3
|
+
export type Attributes = Record<string, unknown>;
|
|
4
|
+
/** An event as it is sent; beforeSend may change it. */
|
|
5
|
+
export interface MoblogEvent {
|
|
6
|
+
/** Unix milliseconds. */
|
|
7
|
+
timestamp: number;
|
|
8
|
+
/** A UUID the SDK gives the event after beforeSend (unless it set one): ingest stores an event once by it. */
|
|
9
|
+
event_id?: string;
|
|
10
|
+
level: Level;
|
|
11
|
+
message: string;
|
|
12
|
+
session_id: string;
|
|
13
|
+
/** Position in the session: orders events even when the clock is wrong. */
|
|
14
|
+
seq: number;
|
|
15
|
+
/** The page's path; the server drops query strings and masks IDs. */
|
|
16
|
+
page?: string;
|
|
17
|
+
user_id?: string;
|
|
18
|
+
attributes?: Attributes;
|
|
19
|
+
}
|
|
20
|
+
export interface Options {
|
|
21
|
+
/** An ingest key (mob_ingest_...) that allows this site. */
|
|
22
|
+
key: string;
|
|
23
|
+
/** The ingest URL. Default: https://ingest.moblog.dev */
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
/** The app's ID, e.g. "shop-web". Default: the page's host. */
|
|
26
|
+
app?: string;
|
|
27
|
+
/** The deployed version, e.g. a git SHA or "2.4.1". */
|
|
28
|
+
release?: string;
|
|
29
|
+
environment?: string;
|
|
30
|
+
/** Change or drop (return null) each event before it is queued: remove personal data here. */
|
|
31
|
+
beforeSend?: (event: MoblogEvent) => MoblogEvent | null | undefined | false;
|
|
32
|
+
/** Least severe level sent. Default: "info". */
|
|
33
|
+
level?: Level;
|
|
34
|
+
/** Uncaught errors and unhandled promise rejections. Default: true. */
|
|
35
|
+
captureErrors?: boolean;
|
|
36
|
+
/** Also send console calls of these levels. Default: none. */
|
|
37
|
+
captureConsole?: boolean | Array<"debug" | "info" | "warn" | "error">;
|
|
38
|
+
/** An event when the page (path) changes, in single-page apps too. Default: true. */
|
|
39
|
+
trackPages?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Keep an anonymous device ID and unsent events in localStorage, so they
|
|
42
|
+
* survive closing the tab. Default: true; false keeps them in memory only.
|
|
43
|
+
*/
|
|
44
|
+
persist?: boolean;
|
|
45
|
+
/** How often queued events are sent, in milliseconds. Default: 5000. */
|
|
46
|
+
flushInterval?: number;
|
|
47
|
+
/** A session ends after this many minutes without events. Default: 30. */
|
|
48
|
+
sessionTimeout?: number;
|
|
49
|
+
}
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A random UUID; crypto.randomUUID needs a secure context, getRandomValues doesn't. */
|
|
2
|
+
export declare function uuid(): string;
|
|
3
|
+
/** A UUIDv7: the time in Unix milliseconds, then random bits, so IDs sort by when they were made. */
|
|
4
|
+
export declare function uuid7(ms: number): string;
|
|
5
|
+
/** Storage that may be missing (privacy modes, sandboxed frames) or full: failures are ignored. */
|
|
6
|
+
export declare function store(kind: "localStorage" | "sessionStorage", on: boolean): {
|
|
7
|
+
get(k: string): string | null;
|
|
8
|
+
set(k: string, v: string): void;
|
|
9
|
+
};
|
|
10
|
+
export type Store = ReturnType<typeof store>;
|
|
11
|
+
/** JSON.parse that answers undefined instead of throwing. */
|
|
12
|
+
export declare function parse<T>(s: string | null): T | undefined;
|
|
13
|
+
/** A value as text for a message: strings as they are, errors by name and message, the rest as JSON. */
|
|
14
|
+
export declare function text(v: unknown): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moblog/browser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Send logs and errors from web pages to moblog.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/thiagodmont/moblog.git",
|
|
21
|
+
"directory": "packages/browser"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "rm -rf dist && vite build && tsc -p tsconfig.build.json",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"size": "node scripts/size.mjs",
|
|
31
|
+
"e2e": "playwright test",
|
|
32
|
+
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build && pnpm size"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@playwright/test": "1.63.0",
|
|
36
|
+
"jsdom": "30.1.1",
|
|
37
|
+
"typescript": "7.0.2",
|
|
38
|
+
"vite": "8.3.4",
|
|
39
|
+
"vitest": "5.0.4"
|
|
40
|
+
}
|
|
41
|
+
}
|