feat: client log with request ids, joined to the server's
The browser now keeps its half of the one event shape logs.py defines: lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring with the server's level names and severity numbers. Nothing leaves the browser yet; sending is the consented diagnostics level (S5). - lib/http.ts `send` is the one fetch: every request carries an X-Request-Id and becomes one http.request event. The server echoes the matched route template in X-Route (only for a matched route, so a raw path with a value in it is never sent back); without one the client templates the path itself. req, beacon, the upload XHR, the public watch page and the legal pages all go through it. - Hooks: app start snapshot, uncaught errors and rejections, History push/replace/popstate (a scroll-only replace is not an event; parameter values only at DEBUG, typed search only at TRACE), notifications, the error dialog, the crash boundary, failed queries and mutations (key head only), and the messages socket (lifecycle, frame types at DEBUG). - Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json runs against both redact() implementations. - Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging module, console to none; a contract test proves the rules fire. Fixes a socket takeover: the close of a socket released by the last subscriber cleared the newer socket a quick re-subscribe had opened and scheduled a third, leaving two live sockets that delivered every message twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
|
||||
// safety net if the socket is down.
|
||||
import { getActiveAccount, type Message, type MessageUser } from "./api";
|
||||
import { log } from "./log";
|
||||
|
||||
// What the client log records of the socket: its lifecycle, and at DEBUG each frame's TYPE. Never a
|
||||
// frame's content — chat plaintext stays out of every log structurally, not by redaction. The path
|
||||
// is fixed, without the `?account=` query.
|
||||
const WS_PATH = "/api/messages/ws";
|
||||
|
||||
// A pushed message plus both parties, so the dock can open/flash the right window.
|
||||
interface IncomingMessage {
|
||||
@@ -28,22 +34,33 @@ function url(): string {
|
||||
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
|
||||
const account = getActiveAccount();
|
||||
const qs = account != null ? `?account=${account}` : "";
|
||||
return `${proto}://${location.host}/api/messages/ws${qs}`;
|
||||
return `${proto}://${location.host}${WS_PATH}${qs}`;
|
||||
}
|
||||
|
||||
function open() {
|
||||
let sock: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(url());
|
||||
} catch {
|
||||
sock = new WebSocket(url());
|
||||
} catch (e) {
|
||||
log.warn("ws.failed", "could not open the socket", { path: WS_PATH, message: String(e) });
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
ws = sock;
|
||||
// Every handler checks it still belongs to the CURRENT socket. A socket closed by the last
|
||||
// subscriber leaving closes asynchronously: when a subscriber came back in between (a remount,
|
||||
// StrictMode's double effect) a newer socket is already open, and the old one's close used to
|
||||
// clear that newer reference and schedule a THIRD — two live sockets, every message delivered twice.
|
||||
sock.onopen = () => {
|
||||
if (ws !== sock) return;
|
||||
log.info("ws.open", "socket open", { path: WS_PATH });
|
||||
backoff = 1000;
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
sock.onmessage = (ev) => {
|
||||
if (ws !== sock) return;
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
log.debug("ws.frame", "socket frame", { path: WS_PATH, type: String(data?.type) });
|
||||
if (data?.type === "message" && data.message) {
|
||||
const e: IncomingMessage = {
|
||||
message: data.message as Message,
|
||||
@@ -59,12 +76,22 @@ function open() {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
sock.onclose = (ev) => {
|
||||
const current = ws === sock;
|
||||
// A socket we let go of (the last subscriber left) closing is routine; the current one closing
|
||||
// is the server or the network going away, and the reconnect that follows is worth seeing.
|
||||
(current ? log.warn : log.info)("ws.close", "socket closed", {
|
||||
path: WS_PATH,
|
||||
code: ev.code,
|
||||
clean: ev.wasClean,
|
||||
reconnect_ms: current && started ? backoff : null,
|
||||
});
|
||||
if (!current) return;
|
||||
ws = null;
|
||||
if (started) schedule();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
sock.onerror = () => {
|
||||
sock.close();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user