@universelle/sentinel-report 0.2.0 → 0.3.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/CHANGELOG.md +28 -0
- package/README.md +18 -0
- package/dist/core.d.ts +23 -3
- package/dist/core.js +74 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/react.d.ts +6 -3
- package/dist/react.js +5 -2
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog — @universelle/sentinel-report
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
### Privacy
|
|
6
|
+
For sites whose paths carry secrets (an invitation token in `/u/<token>`, a personal link, internal ids), three
|
|
7
|
+
options in `createReporter`, `useSentinelReport` and `capture()` clean everything that leaves the page. Recording
|
|
8
|
+
stays raw in memory; the cleaning happens when the context is built (`context()`, `send()`):
|
|
9
|
+
|
|
10
|
+
- **`route(pathname) => template`**. Applies to `page.path`, to every entry of `trail` and to every `network[].path`.
|
|
11
|
+
It always receives the path without its query string or fragment. If it throws or returns something that isn't a
|
|
12
|
+
string, that path goes as `"/(otra)"`, never raw. Consecutive pages with the same template become one entry.
|
|
13
|
+
- **The title.** With `route`, `page.title` goes as `""`, because the title can carry the page's data. `title: true`
|
|
14
|
+
sends it anyway.
|
|
15
|
+
- **Console messages.** With `route`, every absolute URL becomes its origin (`https://host/`). Every path at the start
|
|
16
|
+
of the message, or after a space, a bracket or a quote, goes through `route`. Then **`redact(message)`**, if given,
|
|
17
|
+
for anything else (UUIDs, tokens…). If `redact` throws, the message goes empty.
|
|
18
|
+
- **Without these options nothing changes**: the body is the same as in 0.2.0.
|
|
19
|
+
|
|
20
|
+
Unchanged: the body's format, the endpoint, `credentials: "omit"`, the limit of 20 entries, no dependencies and no
|
|
21
|
+
install scripts.
|
|
22
|
+
|
|
23
|
+
## 0.2.0 — 2026-10-24
|
|
24
|
+
- SENTINEL's mark for your own branding: `<SentinelMark>`, `<sentinel-mark>`, `markSvg()` and `MARK_SHAPES`.
|
|
25
|
+
|
|
26
|
+
## 0.1.0 — 2026-10-24
|
|
27
|
+
- First release: the core (`capture`, `createReporter`), the `<sentinel-report>` web component and React
|
|
28
|
+
(`<ReportButton>`, `useSentinelReport`).
|
package/README.md
CHANGED
|
@@ -125,6 +125,24 @@ import { createReporter } from "@universelle/sentinel-report";
|
|
|
125
125
|
await createReporter({ site: "your-site" }).send({ kind: "wish", text: "Export the table" });
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
## Privacy: paths with secrets
|
|
129
|
+
|
|
130
|
+
If your paths carry secrets (an invitation token in `/u/<token>`, a personal link, internal ids), pass `route`. It
|
|
131
|
+
turns each real path into its template, so SENTINEL only receives templates. It applies to the page, every page of
|
|
132
|
+
the trail, every failed request and the paths and URLs inside console messages, and the page's title is dropped.
|
|
133
|
+
`redact` cleans console messages further.
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
const route = (pathname: string) => toTemplate(pathname) ?? "/(otra)"; // e.g. "/u/[invitation]"
|
|
137
|
+
const redact = (message: string) => message.replace(UUID, "[id]");
|
|
138
|
+
|
|
139
|
+
const { send } = useSentinelReport({ site: "your-site", route, redact });
|
|
140
|
+
// or: createReporter({ site: "your-site", route, redact }); or once for the page: capture({ route, redact })
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A `route` that throws, or doesn't return a string, sends `"/(otra)"`, never the raw path. Without these options,
|
|
144
|
+
everything is sent as in 0.2.0. See [CHANGELOG.md](CHANGELOG.md).
|
|
145
|
+
|
|
128
146
|
## Branding: SENTINEL's mark
|
|
129
147
|
|
|
130
148
|
For your own button, a footer or a "Protected by SENTINEL" line. The eye and the star take the text colour
|
package/dist/core.d.ts
CHANGED
|
@@ -30,6 +30,17 @@ export type Report = {
|
|
|
30
30
|
text: string;
|
|
31
31
|
email?: string;
|
|
32
32
|
};
|
|
33
|
+
/** How a site cleans what is sent (0.3.0). Without `route`, everything goes as in 0.2.0. */
|
|
34
|
+
export type Privacy = {
|
|
35
|
+
/** A real path (`/u/AbC…`) to its template (`/u/[invitation]`). If it throws or returns no string: `"/(otra)"`. */
|
|
36
|
+
route?: (pathname: string) => string;
|
|
37
|
+
/** Every console message, after the default cleaning (with `route`: URLs to their origin, paths to templates). */
|
|
38
|
+
redact?: (message: string) => string;
|
|
39
|
+
/** Send `document.title` (default: yes without `route`, no with it: the title can carry the page's data). */
|
|
40
|
+
title?: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** What goes in place of a path the site's `route` can't turn into a template. */
|
|
43
|
+
export declare const UNKNOWN_ROUTE = "/(otra)";
|
|
33
44
|
export type Reporter = {
|
|
34
45
|
/** Sends a report; rejects with an `Error` whose message is the HTTP status (or the network error). */
|
|
35
46
|
send(report: Report): Promise<void>;
|
|
@@ -41,16 +52,25 @@ type Capture = {
|
|
|
41
52
|
network: FailedRequest[];
|
|
42
53
|
trail: string[];
|
|
43
54
|
fetch: typeof fetch;
|
|
55
|
+
privacy: Privacy;
|
|
44
56
|
};
|
|
45
57
|
declare global {
|
|
46
58
|
interface Window {
|
|
47
59
|
__sentinelCapture?: Capture;
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
|
-
/**
|
|
51
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Starts recording the visit's context (once per page; later calls return the same recording). The privacy options
|
|
64
|
+
* given here are the page's default for every reporter that doesn't bring its own; a later call can still set them.
|
|
65
|
+
*/
|
|
66
|
+
export declare function capture(privacy?: Privacy): Capture;
|
|
67
|
+
/**
|
|
68
|
+
* A console message as it may leave: with `route`, each absolute URL becomes its origin and each path (at the start,
|
|
69
|
+
* or after a space, a bracket or a quote) its template; then the site's own `redact`, if any.
|
|
70
|
+
*/
|
|
71
|
+
export declare function cleanMessage(message: string, privacy: Privacy): string;
|
|
52
72
|
export declare function createReporter(options: {
|
|
53
73
|
site: string;
|
|
54
74
|
endpoint?: string;
|
|
55
|
-
}): Reporter;
|
|
75
|
+
} & Privacy): Reporter;
|
|
56
76
|
export {};
|
package/dist/core.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// The core, with no framework: it records the technical context of the visit (console errors, failed requests,
|
|
2
2
|
// pages visited) and sends a report to SENTINEL. What it never sends: the query string or fragment of any URL (they
|
|
3
3
|
// can carry tokens) or anything typed into the site. Installed once per page, whoever asks first.
|
|
4
|
+
//
|
|
5
|
+
// Privacy (0.3.0): sites whose paths carry secrets (`/u/<invitation token>`) pass `route`, which turns a real path
|
|
6
|
+
// into its template (`/u/[invitation]`). What is recorded stays raw in memory; everything that leaves (`context()`,
|
|
7
|
+
// `send()`) goes through it: the page, every page of the trail, every failed request, and the paths and URLs inside
|
|
8
|
+
// console messages; and the page's title is dropped. `redact` cleans console messages further.
|
|
4
9
|
export const DEFAULT_ENDPOINT = "https://sentinel.cervilla.es";
|
|
10
|
+
/** What goes in place of a path the site's `route` can't turn into a template. */
|
|
11
|
+
export const UNKNOWN_ROUTE = "/(otra)";
|
|
5
12
|
const MAX = 20;
|
|
6
13
|
function clip(text) {
|
|
7
14
|
return String(text).replace(/\s+/g, " ").slice(0, 300);
|
|
@@ -74,27 +81,83 @@ function watchNavigation(capture) {
|
|
|
74
81
|
}
|
|
75
82
|
window.addEventListener("popstate", visit);
|
|
76
83
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Starts recording the visit's context (once per page; later calls return the same recording). The privacy options
|
|
86
|
+
* given here are the page's default for every reporter that doesn't bring its own; a later call can still set them.
|
|
87
|
+
*/
|
|
88
|
+
export function capture(privacy = {}) {
|
|
89
|
+
if (window.__sentinelCapture !== undefined) {
|
|
90
|
+
if (privacy.route !== undefined || privacy.redact !== undefined || privacy.title !== undefined)
|
|
91
|
+
window.__sentinelCapture.privacy = privacy;
|
|
80
92
|
return window.__sentinelCapture;
|
|
81
|
-
|
|
93
|
+
}
|
|
94
|
+
const recording = { logs: [], network: [], trail: [location.pathname], fetch: window.fetch.bind(window), privacy };
|
|
82
95
|
window.__sentinelCapture = recording;
|
|
83
96
|
watchConsole(recording);
|
|
84
97
|
watchFetch(recording);
|
|
85
98
|
watchNavigation(recording);
|
|
86
99
|
return recording;
|
|
87
100
|
}
|
|
101
|
+
/** A path through the site's `route`, never raw once a route is given: what fails goes as `"/(otra)"`. */
|
|
102
|
+
function template(route, pathname) {
|
|
103
|
+
if (route === undefined)
|
|
104
|
+
return pathname;
|
|
105
|
+
try {
|
|
106
|
+
const mapped = route(pathname.split(/[?#]/)[0]);
|
|
107
|
+
return typeof mapped === "string" ? mapped : UNKNOWN_ROUTE;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return UNKNOWN_ROUTE;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A console message as it may leave: with `route`, each absolute URL becomes its origin and each path (at the start,
|
|
115
|
+
* or after a space, a bracket or a quote) its template; then the site's own `redact`, if any.
|
|
116
|
+
*/
|
|
117
|
+
export function cleanMessage(message, privacy) {
|
|
118
|
+
let clean = message;
|
|
119
|
+
if (privacy.route !== undefined) {
|
|
120
|
+
clean = clean.replace(/https?:\/\/[^\s"'()<>]+/g, (url) => {
|
|
121
|
+
try {
|
|
122
|
+
return `${new URL(url).origin}/`;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "[url]";
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
clean = clean.replace(/(^|[\s("'])(\/[^\s"'()<>]*)/g, (_match, before, pathname) => `${before}${template(privacy.route, pathname)}`);
|
|
129
|
+
}
|
|
130
|
+
if (privacy.redact !== undefined) {
|
|
131
|
+
try {
|
|
132
|
+
const redacted = privacy.redact(clean);
|
|
133
|
+
clean = typeof redacted === "string" ? redacted : "";
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
clean = "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return clean;
|
|
140
|
+
}
|
|
141
|
+
/** The trail through `route`; consecutive pages with the same template become one. */
|
|
142
|
+
function trailOf(trail, route) {
|
|
143
|
+
return trail.map((pathname) => template(route, pathname)).filter((pathname, index, all) => index === 0 || all[index - 1] !== pathname);
|
|
144
|
+
}
|
|
88
145
|
export function createReporter(options) {
|
|
89
146
|
const recording = capture();
|
|
90
147
|
const url = `${(options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "")}/reports/${encodeURIComponent(options.site)}`;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
148
|
+
const own = options.route !== undefined || options.redact !== undefined || options.title !== undefined;
|
|
149
|
+
const context = () => {
|
|
150
|
+
// The reporter's own options, else the page's default (from `capture()`), read when sending.
|
|
151
|
+
const privacy = own ? { route: options.route, redact: options.redact, title: options.title } : recording.privacy;
|
|
152
|
+
const withTitle = privacy.title ?? privacy.route === undefined;
|
|
153
|
+
return {
|
|
154
|
+
page: { path: template(privacy.route, location.pathname), title: withTitle ? document.title.slice(0, 200) : "" },
|
|
155
|
+
device: { userAgent: navigator.userAgent.slice(0, 300), language: navigator.language, viewport: `${innerWidth}x${innerHeight}`, screen: `${screen.width}x${screen.height}` },
|
|
156
|
+
console: recording.logs.map((entry) => ({ ...entry, message: cleanMessage(entry.message, privacy) })),
|
|
157
|
+
network: recording.network.map((entry) => ({ ...entry, path: template(privacy.route, entry.path) })),
|
|
158
|
+
trail: privacy.route === undefined ? recording.trail.slice() : trailOf(recording.trail, privacy.route),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
98
161
|
return {
|
|
99
162
|
context,
|
|
100
163
|
async send(report) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { capture, createReporter, DEFAULT_ENDPOINT } from "./core.js";
|
|
1
|
+
export { capture, cleanMessage, createReporter, DEFAULT_ENDPOINT, UNKNOWN_ROUTE } from "./core.js";
|
|
2
2
|
export { MARK_SHAPES, markSvg } from "./logo.js";
|
|
3
|
-
export type { Context, ConsoleEntry, FailedRequest, Report, ReportKind, Reporter } from "./core.js";
|
|
3
|
+
export type { Context, ConsoleEntry, FailedRequest, Privacy, Report, ReportKind, Reporter } from "./core.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// @universelle/sentinel-report: the core, with no framework. For the ready-made button, `…/element` (any site) or
|
|
2
2
|
// `…/react`; to build your own form, `createReporter` here or `useSentinelReport` in React.
|
|
3
|
-
export { capture, createReporter, DEFAULT_ENDPOINT } from "./core.js";
|
|
3
|
+
export { capture, cleanMessage, createReporter, DEFAULT_ENDPOINT, UNKNOWN_ROUTE } from "./core.js";
|
|
4
4
|
export { MARK_SHAPES, markSvg } from "./logo.js";
|
package/dist/react.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import { type Report } from "./core.js";
|
|
2
|
+
import { type Privacy, type Report } from "./core.js";
|
|
3
3
|
import type { Texts } from "./element.js";
|
|
4
4
|
import "./element.js";
|
|
5
5
|
export type ReportButtonProps = {
|
|
@@ -57,11 +57,14 @@ export declare function SentinelMark({ size, title, className, style }: Sentinel
|
|
|
57
57
|
style: CSSProperties | undefined;
|
|
58
58
|
}, Element>;
|
|
59
59
|
export type ReportStatus = "idle" | "sending" | "sent" | "error";
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* For your own form: records the visit from the first render and sends with its context. `route`, `redact` and
|
|
62
|
+
* `title` clean what is sent (see `Privacy`); pass stable functions (defined outside the component).
|
|
63
|
+
*/
|
|
61
64
|
export declare function useSentinelReport(options: {
|
|
62
65
|
site: string;
|
|
63
66
|
endpoint?: string;
|
|
64
|
-
}): {
|
|
67
|
+
} & Privacy): {
|
|
65
68
|
send: (report: Report) => Promise<boolean>;
|
|
66
69
|
status: ReportStatus;
|
|
67
70
|
error: unknown;
|
package/dist/react.js
CHANGED
|
@@ -31,9 +31,12 @@ export function SentinelMark({ size = "1em", title, className, style }) {
|
|
|
31
31
|
const label = title ? { role: "img", "aria-label": title } : { "aria-hidden": true, focusable: "false" };
|
|
32
32
|
return createElement("svg", { viewBox: "0 0 100 100", width: size, height: size, className, style, ...label, dangerouslySetInnerHTML: { __html: `${title ? `<title>${title.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)}</title>` : ""}${MARK_SHAPES}` } });
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* For your own form: records the visit from the first render and sends with its context. `route`, `redact` and
|
|
36
|
+
* `title` clean what is sent (see `Privacy`); pass stable functions (defined outside the component).
|
|
37
|
+
*/
|
|
35
38
|
export function useSentinelReport(options) {
|
|
36
|
-
const reporter = useMemo(() => (typeof window === "undefined" ? undefined : createReporter(options)), [options.site, options.endpoint]);
|
|
39
|
+
const reporter = useMemo(() => (typeof window === "undefined" ? undefined : createReporter(options)), [options.site, options.endpoint, options.route, options.redact, options.title]);
|
|
37
40
|
const [status, setStatus] = useState("idle");
|
|
38
41
|
const [error, setError] = useState(undefined);
|
|
39
42
|
useEffect(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@universelle/sentinel-report",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "SENTINEL's report button: let real users tell you what fails or what they miss, with the technical context of their visit.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"dist",
|
|
28
28
|
"README.md",
|
|
29
|
+
"CHANGELOG.md",
|
|
29
30
|
"LICENSE"
|
|
30
31
|
],
|
|
31
32
|
"scripts": {
|