feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a new Settings tab, after a consent box that says what is recorded, that the server keeps it and for how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent. - users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force. - The server logs an elevated user's requests at that level and stores every line of them (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation is active). Teljes adds the redacted query string to the request line. - POST /api/diagnostics/events stores the browser's log as source=web rows: account from the session, body capped while streaming, events/attrs capped, re-redacted, events under the consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed. - lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its own timer), on withdrawal and on 409. - Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user text, input values or a link's query), visibility/bfcache/resize, filter changes (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are warnings at every level). Offline/online at the base level. - "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy the filtered events as JSON lines, clear. - Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to the same sections); the policy now describes the server logs and diagnostic logging, self-service deletion and password sign-in. Fixed on the way: - A sync auth dependency ran logs.set_user on a copied context, so the account never reached a handler's own log lines (measured); a per-request mutable scope carries it now. - The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is a scrolling row above the content. - PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E22 S5: the consented level applied to the tab, and the stream it opens. The module keeps its state
|
||||
// at module level (what is applied, the queue), so every test imports a fresh copy — and the log
|
||||
// from that same copy, or it would be reading a different ring.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sent: unknown[][] = [];
|
||||
const unloaded: unknown[][] = [];
|
||||
let answer: () => Promise<unknown> = () => Promise.resolve({ ok: true, stored: 0 });
|
||||
|
||||
vi.mock("./api", async () => {
|
||||
const core = await vi.importActual<typeof import("./api/core")>("./api/core");
|
||||
return {
|
||||
HttpError: core.HttpError,
|
||||
DIAGNOSTICS_EVENTS_PATH: "/api/diagnostics/events",
|
||||
api: {
|
||||
sendLogEvents: (events: unknown[]) => {
|
||||
sent.push(events);
|
||||
return answer();
|
||||
},
|
||||
sendLogEventsOnUnload: (events: unknown[]) => void unloaded.push(events),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
sent.length = 0;
|
||||
unloaded.length = 0;
|
||||
answer = () => Promise.resolve({ ok: true, stored: 0 });
|
||||
});
|
||||
let current: typeof import("./diagnostics") | null = null;
|
||||
afterEach(() => {
|
||||
// A module copy left streaming would still hear `pagehide` in the next test.
|
||||
current?.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
current = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function fresh() {
|
||||
const diagnostics = await import("./diagnostics");
|
||||
current = diagnostics;
|
||||
const logMod = await import("./log");
|
||||
const apiMod = await import("./api");
|
||||
return { ...diagnostics, ...logMod, HttpError: apiMod.HttpError };
|
||||
}
|
||||
|
||||
const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
|
||||
level,
|
||||
until: new Date(Date.now() + hours * 3_600_000).toISOString(),
|
||||
retention_days: 7,
|
||||
});
|
||||
const codes = (batch: unknown[] | undefined) =>
|
||||
(batch ?? []).map((e) => (e as { event: string }).event);
|
||||
|
||||
describe("diagnostics level", () => {
|
||||
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
d.log.info("nav.view", "x");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(sent).toEqual([]);
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
expect(codes(d.events())).toEqual(["nav.view"]);
|
||||
});
|
||||
|
||||
it("a raised level streams only what happens after it, without its own request lines", async () => {
|
||||
const d = await fresh();
|
||||
d.log.info("before.consent", "not sent");
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
expect(d.enabled("DEBUG")).toBe(true);
|
||||
d.log.debug("ui.click", "click");
|
||||
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123");
|
||||
d.log.request("INFO", "GET", { path: "/api/feed", status: 200 }, "abcdef0124");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(codes(sent[0])).toEqual(["diagnostics.level", "ui.click", "http.request"]);
|
||||
expect(JSON.stringify(sent[0])).not.toContain("/api/diagnostics/events");
|
||||
});
|
||||
|
||||
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sent.map((b) => b.length)).toEqual([100]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(sent.every((b) => b.length <= 100)).toBe(true);
|
||||
expect(sent.flat()).toHaveLength(251); // the 250 clicks + the line that opened the stream
|
||||
});
|
||||
|
||||
it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("TRACE"), () => {});
|
||||
d.log.trace("search.typed", "lofi");
|
||||
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
|
||||
d.log.info("nav.view", "after");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(sent).toEqual([]);
|
||||
expect(d.enabled("TRACE")).toBe(false);
|
||||
});
|
||||
|
||||
it("lapses on its own timer and tells the app to refetch", async () => {
|
||||
const d = await fresh();
|
||||
const lapsed = vi.fn();
|
||||
d.applyDiagnostics(raised("DEBUG", 1), lapsed);
|
||||
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
|
||||
expect(lapsed).toHaveBeenCalledOnce();
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
sent.length = 0;
|
||||
d.log.info("nav.view", "after the lapse");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("an already-expired level is the base level", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG", -1), () => {});
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
});
|
||||
|
||||
it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
|
||||
const d = await fresh();
|
||||
const lapsed = vi.fn();
|
||||
d.applyDiagnostics(raised("DEBUG"), lapsed);
|
||||
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
|
||||
d.log.debug("ui.click", "kept for later");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
answer = () => Promise.reject(new d.HttpError(409, "off"));
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(codes(sent[1])).toEqual(codes(sent[0])); // the same batch, retried after the 429
|
||||
expect(lapsed).toHaveBeenCalledOnce();
|
||||
expect(d.enabled("DEBUG")).toBe(false);
|
||||
});
|
||||
|
||||
it("a closing tab sends what is still waiting with a keepalive request", async () => {
|
||||
const d = await fresh();
|
||||
d.applyDiagnostics(raised("DEBUG"), () => {});
|
||||
d.log.debug("ui.click", "last click");
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user