fix: rotate web-probe jsonl on start (#702)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -78,6 +78,7 @@ interface NodeWebProbeObserveOptions {
|
||||
tailLines: number;
|
||||
maxFiles: number;
|
||||
collectFile: string | null;
|
||||
analyzeArchivePrefix: string | null;
|
||||
full: boolean;
|
||||
stateDir: string | null;
|
||||
jobId: string | null;
|
||||
@@ -6905,6 +6906,7 @@ function parseNodeWebProbeObserveOptions(
|
||||
"--tail-lines",
|
||||
"--max-files",
|
||||
"--file",
|
||||
"--archive-prefix",
|
||||
"--state-dir",
|
||||
"--job-id",
|
||||
"--type",
|
||||
@@ -6922,6 +6924,8 @@ function parseNodeWebProbeObserveOptions(
|
||||
if (jobId !== null && !isSafeWebObserveJobId(jobId)) throw new Error(`unsafe web-probe observe --job-id: ${jobId}`);
|
||||
const collectFile = optionValue(args, "--file") ?? null;
|
||||
if (collectFile !== null && !isSafeWebObserveCollectFile(collectFile)) throw new Error(`unsafe web-probe observe --file: ${collectFile}`);
|
||||
const analyzeArchivePrefix = optionValue(args, "--archive-prefix") ?? null;
|
||||
if (analyzeArchivePrefix !== null && !isSafeWebObserveArchivePrefix(analyzeArchivePrefix)) throw new Error(`unsafe web-probe observe --archive-prefix: ${analyzeArchivePrefix}`);
|
||||
if (observeActionRaw !== "start" && stateDir === null && jobId === null) {
|
||||
throw new Error("web-probe observe status|command|stop|collect|analyze requires --state-dir or --job-id");
|
||||
}
|
||||
@@ -6942,6 +6946,7 @@ function parseNodeWebProbeObserveOptions(
|
||||
tailLines: positiveIntegerOption(args, "--tail-lines", 5, 200),
|
||||
maxFiles: positiveIntegerOption(args, "--max-files", 80, 5000),
|
||||
collectFile,
|
||||
analyzeArchivePrefix,
|
||||
full: args.includes("--full"),
|
||||
stateDir,
|
||||
jobId,
|
||||
@@ -7396,7 +7401,8 @@ function runNodeWebProbeObserveStart(
|
||||
const jobId = `webobs-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
||||
const timestamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z");
|
||||
const day = timestamp.slice(0, 8);
|
||||
const stateDir = `.state/web-observe/${safeWebObserveSegment(options.node)}/${safeWebObserveSegment(options.lane)}/${day.slice(0, 4)}/${day.slice(4, 6)}/${day.slice(6, 8)}/${timestamp}_${safeWebObserveTargetSegment(options.targetPath)}_${jobId}`;
|
||||
const defaultStateDir = `.state/web-observe/${safeWebObserveSegment(options.node)}/${safeWebObserveSegment(options.lane)}/${day.slice(0, 4)}/${day.slice(4, 6)}/${day.slice(6, 8)}/${timestamp}_${safeWebObserveTargetSegment(options.targetPath)}_${jobId}`;
|
||||
const stateDir = options.stateDir ?? defaultStateDir;
|
||||
const runnerB64 = Buffer.from(nodeWebObserveRunnerSource(), "utf8").toString("base64");
|
||||
const webProbeProxy = nodeWebProbeHostProxyEnv(spec);
|
||||
const script = [
|
||||
@@ -7943,7 +7949,8 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
"analysis_stdout=\"$state_dir/analysis/analyzer-stdout.json\"",
|
||||
"analysis_stderr=\"$state_dir/analysis/analyzer-stderr.log\"",
|
||||
"set +e",
|
||||
"UNIDESK_WEB_OBSERVE_STATE_DIR=\"$state_dir\" node \"$analyzer\" >\"$analysis_stdout\" 2>\"$analysis_stderr\"",
|
||||
`UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX=${shellQuote(options.analyzeArchivePrefix ?? "")}`,
|
||||
"UNIDESK_WEB_OBSERVE_STATE_DIR=\"$state_dir\" UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX=\"$UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX\" node \"$analyzer\" >\"$analysis_stdout\" 2>\"$analysis_stderr\"",
|
||||
"analyzer_exit=$?",
|
||||
"set -e",
|
||||
"report_json=\"$state_dir/analysis/report.json\"",
|
||||
@@ -8030,6 +8037,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
"const compact = source ? {",
|
||||
" ok: analyzerExit === 0,",
|
||||
" counts: source.counts ?? null,",
|
||||
" jsonlScope: objectOrNull(source.jsonlScope),",
|
||||
" analysisWindow: objectOrNull(source.analysisWindow),",
|
||||
" archiveSummary: objectOrNull(source.archiveSummary),",
|
||||
" sampleMetrics: metrics,",
|
||||
@@ -8203,6 +8211,7 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
|
||||
const analyzer = record(analysis?.analyzer);
|
||||
const result = record(value.result);
|
||||
const counts = record(analysis?.counts);
|
||||
const jsonlScope = record(analysis?.jsonlScope);
|
||||
const analysisWindow = record(analysis?.analysisWindow);
|
||||
const archiveSummary = record(analysis?.archiveSummary);
|
||||
const sampleMetrics = record(analysis?.sampleMetrics);
|
||||
@@ -8335,10 +8344,11 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
|
||||
]]),
|
||||
"",
|
||||
] : []),
|
||||
webObserveTable(["ID", "NODE", "LANE", "SAMPLES", "CONTROL", "NETWORK", "CONSOLE", "ARTIFACTS"], [[
|
||||
webObserveTable(["ID", "NODE", "LANE", "SCOPE", "SAMPLES", "CONTROL", "NETWORK", "CONSOLE", "ARTIFACTS"], [[
|
||||
webObserveText(value.id),
|
||||
webObserveText(value.node),
|
||||
webObserveText(value.lane),
|
||||
webObserveShort([webObserveText(jsonlScope?.mode ?? "current"), jsonlScope?.archivePrefix ? webObserveText(jsonlScope.archivePrefix) : ""].filter((part) => part && part !== "-").join(":"), 36),
|
||||
webObserveText(counts?.samples),
|
||||
webObserveText(counts?.control),
|
||||
webObserveText(counts?.network),
|
||||
@@ -9356,6 +9366,14 @@ function isSafeWebObserveCollectFile(value: string): boolean {
|
||||
&& /^[A-Za-z0-9_.\/-]+$/u.test(value);
|
||||
}
|
||||
|
||||
function isSafeWebObserveArchivePrefix(value: string): boolean {
|
||||
return value.length > 0
|
||||
&& value.length <= 120
|
||||
&& !value.includes("\0")
|
||||
&& !value.includes("..")
|
||||
&& /^[A-Za-z0-9_.-]+$/u.test(value);
|
||||
}
|
||||
|
||||
function isSafeWebObserveJobId(value: string): boolean {
|
||||
return /^webobs-[A-Za-z0-9_.-]+$/u.test(value);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const dirs = {
|
||||
commandsFailed: path.join(stateDir, "commands", "failed"),
|
||||
screenshots: path.join(stateDir, "screenshots"),
|
||||
analysis: path.join(stateDir, "analysis"),
|
||||
archive: path.join(stateDir, "archive"),
|
||||
};
|
||||
const files = {
|
||||
manifest: path.join(stateDir, "manifest.json"),
|
||||
@@ -60,12 +61,15 @@ let lastScreenshotAtMs = 0;
|
||||
let auth = null;
|
||||
let pageLoadSeq = 0;
|
||||
let currentPageProvenance = null;
|
||||
const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] };
|
||||
|
||||
try {
|
||||
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
||||
await prepareDirs();
|
||||
await rotateExistingJsonlArtifacts();
|
||||
await writeManifest({ status: "starting" });
|
||||
await writeHeartbeat({ status: "starting" });
|
||||
if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true }));
|
||||
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
||||
const { chromium } = await launcher.importPlaywright();
|
||||
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
||||
@@ -114,6 +118,41 @@ async function prepareDirs() {
|
||||
await Promise.all(Object.values(dirs).map((dir) => mkdir(dir, { recursive: true, mode: 0o700 })));
|
||||
}
|
||||
|
||||
async function rotateExistingJsonlArtifacts() {
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
let meta = null;
|
||||
try {
|
||||
meta = await stat(file);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
if (!meta?.isFile()) continue;
|
||||
const archiveFile = await uniqueArchiveFile(jsonlRotation.stamp + "-" + path.basename(file));
|
||||
await rename(file, archiveFile);
|
||||
jsonlRotation.files.push({ key, from: path.relative(stateDir, file), to: path.relative(stateDir, archiveFile), byteCount: meta.size });
|
||||
}
|
||||
}
|
||||
|
||||
async function uniqueArchiveFile(name) {
|
||||
let candidate = path.join(dirs.archive, name);
|
||||
const parsed = path.parse(name);
|
||||
for (let index = 1; ; index += 1) {
|
||||
try {
|
||||
await stat(candidate);
|
||||
candidate = path.join(dirs.archive, parsed.name + "-" + index + parsed.ext);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return candidate;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compactFileTimestamp(value) {
|
||||
return new Date(value).toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z");
|
||||
}
|
||||
|
||||
async function writeManifest(extra = {}) {
|
||||
const manifest = {
|
||||
ok: extra.status !== "failed",
|
||||
@@ -128,6 +167,7 @@ async function writeManifest(extra = {}) {
|
||||
pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true },
|
||||
pageProvenance: compactPageProvenance(currentPageProvenance),
|
||||
sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false },
|
||||
jsonlRotation,
|
||||
commandDirs: dirs,
|
||||
artifacts: files,
|
||||
safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false },
|
||||
@@ -1676,16 +1716,19 @@ import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || process.argv[2] || ".state/web-observe/manual");
|
||||
const archivePrefix = safeArchivePrefix(process.env.UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX || "");
|
||||
const dataDir = archivePrefix ? path.join(stateDir, "archive") : stateDir;
|
||||
const dataFile = (name) => path.join(dataDir, archivePrefix ? archivePrefix + "-" + name : name);
|
||||
const analysisDir = path.join(stateDir, "analysis");
|
||||
const reportJsonPath = path.join(analysisDir, "report.json");
|
||||
const reportMdPath = path.join(analysisDir, "report.md");
|
||||
const jsonlReadIssues = [];
|
||||
const samples = await readJsonl(path.join(stateDir, "samples.jsonl"), { compact: compactSampleForAnalysis });
|
||||
const control = await readJsonl(path.join(stateDir, "control.jsonl"));
|
||||
const network = await readJsonl(path.join(stateDir, "network.jsonl"));
|
||||
const consoleEvents = await readJsonl(path.join(stateDir, "console.jsonl"));
|
||||
const errors = await readJsonl(path.join(stateDir, "errors.jsonl"));
|
||||
const artifacts = await readJsonl(path.join(stateDir, "artifacts.jsonl"));
|
||||
const samples = await readJsonl(dataFile("samples.jsonl"), { compact: compactSampleForAnalysis });
|
||||
const control = await readJsonl(dataFile("control.jsonl"));
|
||||
const network = await readJsonl(dataFile("network.jsonl"));
|
||||
const consoleEvents = await readJsonl(dataFile("console.jsonl"));
|
||||
const errors = await readJsonl(dataFile("errors.jsonl"));
|
||||
const artifacts = await readJsonl(dataFile("artifacts.jsonl"));
|
||||
const manifest = await readJson(path.join(stateDir, "manifest.json"));
|
||||
const heartbeat = await readJson(path.join(stateDir, "heartbeat.json"));
|
||||
|
||||
@@ -1745,6 +1788,7 @@ const report = {
|
||||
command: "web-probe-observe analyze",
|
||||
generatedAt: new Date().toISOString(),
|
||||
stateDir,
|
||||
jsonlScope: { mode: archivePrefix ? "archive" : "current", archivePrefix: archivePrefix || null, dataDir, valuesRedacted: true },
|
||||
manifest: compactManifest(manifest),
|
||||
heartbeat: compactHeartbeat(heartbeat),
|
||||
counts: { samples: samples.length, control: control.length, network: network.length, console: consoleEvents.length, errors: errors.length, artifacts: artifacts.length },
|
||||
@@ -1769,6 +1813,7 @@ console.log(JSON.stringify({
|
||||
ok: true,
|
||||
command: "web-probe-observe analyze",
|
||||
stateDir,
|
||||
jsonlScope: report.jsonlScope,
|
||||
reportJsonPath,
|
||||
reportJsonSha256: jsonMeta.sha256,
|
||||
reportMdPath,
|
||||
@@ -1909,6 +1954,13 @@ async function readJson(file) {
|
||||
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeArchivePrefix(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
if (!/^[A-Za-z0-9_.-]+$/u.test(text) || text.includes("..")) throw new Error("unsafe archive prefix: " + text);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function readJsonl(file, options = {}) {
|
||||
const rows = [];
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user