@raquezha/notrace 0.1.1 → 0.2.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 +18 -0
- package/dist/notrace/index.d.ts +34 -0
- package/dist/notrace/index.js +144 -118
- package/dist/notrace/report-app/__tests__/analytics.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/analytics.test.js +35 -0
- package/dist/notrace/report-app/__tests__/card.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/card.test.js +26 -0
- package/dist/notrace/report-app/__tests__/event.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/event.test.js +20 -0
- package/dist/notrace/report-app/__tests__/format.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/format.test.js +41 -0
- package/dist/notrace/report-app/__tests__/report.test.d.ts +1 -0
- package/dist/notrace/report-app/__tests__/report.test.js +31 -0
- package/dist/notrace/report-app/analytics.d.ts +3 -0
- package/dist/notrace/report-app/analytics.js +78 -0
- package/dist/notrace/report-app/client.d.ts +2 -0
- package/dist/notrace/report-app/client.js +105 -0
- package/dist/notrace/report-app/components/card.d.ts +4 -0
- package/dist/notrace/report-app/components/card.js +36 -0
- package/dist/notrace/report-app/components/dashboard.d.ts +1 -0
- package/dist/notrace/report-app/components/dashboard.js +16 -0
- package/dist/notrace/report-app/components/event.d.ts +5 -0
- package/dist/notrace/report-app/components/event.js +42 -0
- package/dist/notrace/report-app/components/message.d.ts +2 -0
- package/dist/notrace/report-app/components/message.js +43 -0
- package/dist/notrace/report-app/dashboard-report.d.ts +1 -0
- package/dist/notrace/report-app/dashboard-report.js +6 -0
- package/dist/notrace/report-app/escape.d.ts +1 -0
- package/dist/notrace/report-app/escape.js +10 -0
- package/dist/notrace/report-app/format.d.ts +13 -0
- package/dist/notrace/report-app/format.js +102 -0
- package/dist/notrace/report-app/report.d.ts +1 -0
- package/dist/notrace/report-app/report.js +29 -0
- package/dist/notrace/report-app/shell.d.ts +5 -0
- package/dist/notrace/report-app/shell.js +19 -0
- package/dist/notrace/report-app/styles.d.ts +1 -0
- package/dist/notrace/report-app/styles.js +431 -0
- package/dist/notrace/report-app/types.d.ts +28 -0
- package/dist/notrace/report-app/types.js +1 -0
- package/extensions/notrace/__tests__/ghost-session.test.ts +103 -0
- package/extensions/notrace/__tests__/helpers.ts +11 -0
- package/extensions/notrace/__tests__/lock-race.test.ts +176 -0
- package/extensions/notrace/__tests__/usage-normalization.test.ts +80 -0
- package/extensions/notrace/index.ts +160 -124
- package/extensions/notrace/report-app/__tests__/analytics.test.ts +41 -0
- package/extensions/notrace/report-app/__tests__/card.test.ts +29 -0
- package/extensions/notrace/report-app/__tests__/event.test.ts +23 -0
- package/extensions/notrace/report-app/__tests__/format.test.ts +46 -0
- package/extensions/notrace/report-app/__tests__/report.test.ts +33 -0
- package/extensions/notrace/report-app/analytics.ts +79 -0
- package/extensions/notrace/report-app/client.ts +106 -0
- package/extensions/notrace/report-app/components/card.ts +38 -0
- package/extensions/notrace/report-app/components/dashboard.ts +17 -0
- package/extensions/notrace/report-app/components/event.ts +39 -0
- package/extensions/notrace/report-app/components/message.ts +39 -0
- package/extensions/notrace/report-app/dashboard-report.ts +7 -0
- package/extensions/notrace/report-app/escape.ts +10 -0
- package/extensions/notrace/report-app/format.ts +107 -0
- package/extensions/notrace/report-app/report.ts +33 -0
- package/extensions/notrace/report-app/shell.ts +24 -0
- package/extensions/notrace/report-app/styles.ts +431 -0
- package/extensions/notrace/report-app/types.ts +35 -0
- package/package.json +4 -2
- package/templates/dashboard.sample.html +103 -63
- package/templates/dashboard.sample.json +73 -10
- package/templates/render-samples.mjs +119 -1
- package/templates/session.sample.html +125 -168
- package/templates/session.sample.json +66 -7
- package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.html +125 -163
- package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.json +50 -0
- package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.html +125 -162
- package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.json +50 -0
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.html +125 -163
- package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.json +50 -0
- package/templates/sessions/019ed2ee-massive/notrace.html +498 -0
- package/templates/sessions/019ed2ee-massive/notrace.json +14660 -0
- package/tsconfig.json +1 -1
- package/dist/notrace/renderer.d.ts +0 -4
- package/dist/notrace/renderer.js +0 -800
- package/extensions/notrace/renderer.ts +0 -810
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @raquezha/notrace
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 382b624: Add model usage breakdowns, switch insights, lazy timeline rendering, and refreshed report samples.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 04d574e: Refactor notrace report rendering into modular report-app files and add focused regression coverage without changing package dependencies or entrypoints.
|
|
12
|
+
|
|
13
|
+
## 0.1.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- a0f076e: Fix notrace index lock behavior under contention and add regression coverage for lock handling and usage normalization.
|
|
18
|
+
- 894da0a: Add Vitest scaffolding and extract `handleSessionShutdown` for testability with no intended runtime behavior change.
|
|
19
|
+
- 9d6c8b2: Skip writing per-session notrace artifacts for ghost sessions and add regression coverage for ghost vs non-ghost shutdown behavior.
|
|
20
|
+
|
|
3
21
|
## 0.1.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/dist/notrace/index.d.ts
CHANGED
|
@@ -1,2 +1,36 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { NotraceCaptureMode, NotraceEvent, NotraceExtensionTelemetry } from "./types.js";
|
|
3
|
+
import { type WorkflowAdapter } from "./adapters.js";
|
|
4
|
+
type UsageLike = {
|
|
5
|
+
input?: number;
|
|
6
|
+
output?: number;
|
|
7
|
+
inputTokens?: number;
|
|
8
|
+
outputTokens?: number;
|
|
9
|
+
cacheRead?: number;
|
|
10
|
+
cacheWrite?: number;
|
|
11
|
+
cacheReadTokens?: number;
|
|
12
|
+
cacheWriteTokens?: number;
|
|
13
|
+
totalTokens?: number;
|
|
14
|
+
cost?: {
|
|
15
|
+
input?: number;
|
|
16
|
+
output?: number;
|
|
17
|
+
cacheRead?: number;
|
|
18
|
+
cacheWrite?: number;
|
|
19
|
+
total?: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & {
|
|
23
|
+
totalCostUsd: number;
|
|
24
|
+
};
|
|
25
|
+
export type SessionShutdownDeps = {
|
|
26
|
+
events: NotraceEvent[];
|
|
27
|
+
startTime: number;
|
|
28
|
+
traceId: string;
|
|
29
|
+
extensionTelemetry: Map<string, NotraceExtensionTelemetry>;
|
|
30
|
+
captureMode: NotraceCaptureMode;
|
|
31
|
+
notraceDir: string;
|
|
32
|
+
adapter: WorkflowAdapter;
|
|
33
|
+
};
|
|
34
|
+
export declare function handleSessionShutdown(e: any, ctx: any, deps: SessionShutdownDeps): Promise<void>;
|
|
2
35
|
export default function (pi: ExtensionAPI): void;
|
|
36
|
+
export {};
|
package/dist/notrace/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync, rmSync } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
5
5
|
import { getActiveAdapter } from "./adapters.js";
|
|
6
|
-
import { generateHtmlReport
|
|
6
|
+
import { generateHtmlReport } from "./report-app/report.js";
|
|
7
|
+
import { generateDashboardHtml } from "./report-app/dashboard-report.js";
|
|
7
8
|
const REDACTED = "[REDACTED by notrace]";
|
|
8
9
|
const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|AKIA[0-9A-Z]{16})/gi;
|
|
9
10
|
const TELEMETRY_CHANNEL = "notrace.telemetry.extension";
|
|
@@ -40,14 +41,18 @@ function sanitizeTraceValue(value) {
|
|
|
40
41
|
function asNumber(value) {
|
|
41
42
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
42
43
|
}
|
|
43
|
-
function normalizeUsage(raw) {
|
|
44
|
+
export function normalizeUsage(raw) {
|
|
44
45
|
const usage = (raw && typeof raw === "object" ? raw : {});
|
|
46
|
+
const inputTokens = asNumber(usage.inputTokens ?? usage.input);
|
|
47
|
+
const outputTokens = asNumber(usage.outputTokens ?? usage.output);
|
|
48
|
+
const cacheReadTokens = asNumber(usage.cacheReadTokens ?? usage.cacheRead);
|
|
49
|
+
const cacheWriteTokens = asNumber(usage.cacheWriteTokens ?? usage.cacheWrite);
|
|
45
50
|
return {
|
|
46
|
-
inputTokens
|
|
47
|
-
outputTokens
|
|
48
|
-
cacheReadTokens
|
|
49
|
-
cacheWriteTokens
|
|
50
|
-
totalTokens: asNumber(usage.totalTokens),
|
|
51
|
+
inputTokens,
|
|
52
|
+
outputTokens,
|
|
53
|
+
cacheReadTokens,
|
|
54
|
+
cacheWriteTokens,
|
|
55
|
+
totalTokens: usage.totalTokens == null ? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens : asNumber(usage.totalTokens),
|
|
51
56
|
totalCostUsd: asNumber(usage.cost?.total),
|
|
52
57
|
};
|
|
53
58
|
}
|
|
@@ -164,6 +169,129 @@ function createIndexEntry(record, htmlPath, recordPath) {
|
|
|
164
169
|
},
|
|
165
170
|
};
|
|
166
171
|
}
|
|
172
|
+
export async function handleSessionShutdown(e, ctx, deps) {
|
|
173
|
+
const shutdownReason = typeof e?.reason === "string" ? e.reason : null;
|
|
174
|
+
const endedAt = Date.now();
|
|
175
|
+
const context = deps.adapter.getContext(ctx.cwd);
|
|
176
|
+
const finalTraceId = ctx.sessionManager?.getSessionId?.() || deps.traceId;
|
|
177
|
+
const outputDir = path.join(deps.notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
178
|
+
const repositoryName = path.basename(ctx.cwd);
|
|
179
|
+
let branchName = null;
|
|
180
|
+
try {
|
|
181
|
+
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// not a git repo or no commits yet
|
|
185
|
+
}
|
|
186
|
+
const recordPath = path.join(outputDir, "notrace.json");
|
|
187
|
+
let mergedEvents = deps.events;
|
|
188
|
+
let originalStartedAt = deps.startTime;
|
|
189
|
+
let originalTask = null;
|
|
190
|
+
if (existsSync(recordPath)) {
|
|
191
|
+
try {
|
|
192
|
+
const oldRecord = readJsonFile(recordPath, null);
|
|
193
|
+
if (Array.isArray(oldRecord.events)) {
|
|
194
|
+
mergedEvents = [...oldRecord.events, ...deps.events];
|
|
195
|
+
}
|
|
196
|
+
if (oldRecord.session?.startedAt) {
|
|
197
|
+
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
198
|
+
}
|
|
199
|
+
if (oldRecord.task) {
|
|
200
|
+
originalTask = oldRecord.task;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
// ignore parse errors
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
208
|
+
// Do not index purely empty ghost sessions
|
|
209
|
+
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
210
|
+
const telemetry = Object.fromEntries([...deps.extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
211
|
+
const record = {
|
|
212
|
+
kind: "notrace-run",
|
|
213
|
+
schemaVersion: SCHEMA_VERSION,
|
|
214
|
+
traceId: finalTraceId,
|
|
215
|
+
repository: {
|
|
216
|
+
name: repositoryName,
|
|
217
|
+
cwd: ctx.cwd,
|
|
218
|
+
branch: branchName,
|
|
219
|
+
},
|
|
220
|
+
session: {
|
|
221
|
+
id: finalTraceId,
|
|
222
|
+
startedAt: new Date(originalStartedAt).toISOString(),
|
|
223
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
224
|
+
durationMs: activity.durationMs,
|
|
225
|
+
shutdownReason,
|
|
226
|
+
},
|
|
227
|
+
task: toTaskInfo(context) || originalTask,
|
|
228
|
+
captureMode: deps.captureMode,
|
|
229
|
+
conditions: buildConditions(mergedEvents, telemetry),
|
|
230
|
+
activity,
|
|
231
|
+
telemetry: { extensions: telemetry },
|
|
232
|
+
events: mergedEvents,
|
|
233
|
+
};
|
|
234
|
+
validateRunRecord(record);
|
|
235
|
+
const htmlPath = path.join(outputDir, "notrace.html");
|
|
236
|
+
if (!isGhostSession) {
|
|
237
|
+
const html = generateHtmlReport(record);
|
|
238
|
+
mkdirSync(outputDir, { recursive: true });
|
|
239
|
+
writePrivateFileAtomic(htmlPath, html);
|
|
240
|
+
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
241
|
+
}
|
|
242
|
+
const indexPath = path.join(deps.notraceDir, "index.json");
|
|
243
|
+
const lockPath = `${indexPath}.lock`;
|
|
244
|
+
let lockAcquired = false;
|
|
245
|
+
for (let i = 0; i < 20; i++) {
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
248
|
+
lockAcquired = true;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
const t = Date.now();
|
|
253
|
+
while (Date.now() - t < 50) { } // busy wait 50ms
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (!lockAcquired) {
|
|
257
|
+
// Could not get exclusive access to the index after retrying. Skip the
|
|
258
|
+
// index/dashboard update rather than racing another process's
|
|
259
|
+
// read-modify-write on index.json. The per-session record and HTML
|
|
260
|
+
// report were already written above and are not affected.
|
|
261
|
+
console.warn(`[notrace] Could not acquire index lock, skipping index update for ${finalTraceId}`);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
try {
|
|
265
|
+
const existing = readJsonFile(indexPath, { sessions: [] });
|
|
266
|
+
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
|
|
267
|
+
if (!isGhostSession) {
|
|
268
|
+
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
269
|
+
}
|
|
270
|
+
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
271
|
+
writePrivateFileAtomic(path.join(deps.notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
if (existsSync(lockPath)) {
|
|
275
|
+
try {
|
|
276
|
+
rmSync(lockPath);
|
|
277
|
+
}
|
|
278
|
+
catch { }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (context && !isGhostSession) {
|
|
283
|
+
const displayPath = htmlPath.startsWith(os.homedir())
|
|
284
|
+
? `~${htmlPath.slice(os.homedir().length)}`
|
|
285
|
+
: htmlPath;
|
|
286
|
+
deps.adapter.attach(context, {
|
|
287
|
+
html: displayPath,
|
|
288
|
+
record: recordPath
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (!isGhostSession) {
|
|
292
|
+
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
167
295
|
function normalizeTelemetryPayload(raw) {
|
|
168
296
|
if (!raw || typeof raw !== "object")
|
|
169
297
|
return null;
|
|
@@ -194,7 +322,6 @@ export default function (pi) {
|
|
|
194
322
|
const startTime = Date.now();
|
|
195
323
|
let traceId = "";
|
|
196
324
|
let activeLlmPayload = null;
|
|
197
|
-
let shutdownReason = null;
|
|
198
325
|
const extensionTelemetry = new Map();
|
|
199
326
|
currentMode = getInitialMode();
|
|
200
327
|
if (typeof pi.events?.on === "function") {
|
|
@@ -249,115 +376,14 @@ export default function (pi) {
|
|
|
249
376
|
}
|
|
250
377
|
});
|
|
251
378
|
pi.on("session_shutdown", async (e, ctx) => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
|
|
258
|
-
const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
|
|
259
|
-
const repositoryName = path.basename(ctx.cwd);
|
|
260
|
-
let branchName = null;
|
|
261
|
-
try {
|
|
262
|
-
branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
// not a git repo or no commits yet
|
|
266
|
-
}
|
|
267
|
-
const recordPath = path.join(outputDir, "notrace.json");
|
|
268
|
-
let mergedEvents = events;
|
|
269
|
-
let originalStartedAt = startTime;
|
|
270
|
-
let originalTask = null;
|
|
271
|
-
if (existsSync(recordPath)) {
|
|
272
|
-
try {
|
|
273
|
-
const oldRecord = readJsonFile(recordPath, null);
|
|
274
|
-
if (Array.isArray(oldRecord.events)) {
|
|
275
|
-
mergedEvents = [...oldRecord.events, ...events];
|
|
276
|
-
}
|
|
277
|
-
if (oldRecord.session?.startedAt) {
|
|
278
|
-
originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
|
|
279
|
-
}
|
|
280
|
-
if (oldRecord.task) {
|
|
281
|
-
originalTask = oldRecord.task;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
catch (err) {
|
|
285
|
-
// ignore parse errors
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
|
|
289
|
-
// Do not index purely empty ghost sessions
|
|
290
|
-
const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
|
|
291
|
-
const telemetry = Object.fromEntries([...extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
292
|
-
const record = {
|
|
293
|
-
kind: "notrace-run",
|
|
294
|
-
schemaVersion: SCHEMA_VERSION,
|
|
295
|
-
traceId: finalTraceId,
|
|
296
|
-
repository: {
|
|
297
|
-
name: repositoryName,
|
|
298
|
-
cwd: ctx.cwd,
|
|
299
|
-
branch: branchName,
|
|
300
|
-
},
|
|
301
|
-
session: {
|
|
302
|
-
id: finalTraceId,
|
|
303
|
-
startedAt: new Date(originalStartedAt).toISOString(),
|
|
304
|
-
endedAt: new Date(endedAt).toISOString(),
|
|
305
|
-
durationMs: activity.durationMs,
|
|
306
|
-
shutdownReason,
|
|
307
|
-
},
|
|
308
|
-
task: toTaskInfo(context) || originalTask,
|
|
379
|
+
await handleSessionShutdown(e, ctx, {
|
|
380
|
+
events,
|
|
381
|
+
startTime,
|
|
382
|
+
traceId,
|
|
383
|
+
extensionTelemetry,
|
|
309
384
|
captureMode: currentMode,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
events: mergedEvents,
|
|
314
|
-
};
|
|
315
|
-
validateRunRecord(record);
|
|
316
|
-
const html = generateHtmlReport(record);
|
|
317
|
-
mkdirSync(outputDir, { recursive: true });
|
|
318
|
-
const htmlPath = path.join(outputDir, "notrace.html");
|
|
319
|
-
writePrivateFileAtomic(htmlPath, html);
|
|
320
|
-
writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
321
|
-
const indexPath = path.join(notraceDir, "index.json");
|
|
322
|
-
const lockPath = `${indexPath}.lock`;
|
|
323
|
-
let lockAcquired = false;
|
|
324
|
-
for (let i = 0; i < 20; i++) {
|
|
325
|
-
try {
|
|
326
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
327
|
-
lockAcquired = true;
|
|
328
|
-
break;
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
const t = Date.now();
|
|
332
|
-
while (Date.now() - t < 50) { } // busy wait 50ms
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
try {
|
|
336
|
-
const existing = readJsonFile(indexPath, { sessions: [] });
|
|
337
|
-
let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
|
|
338
|
-
if (!isGhostSession) {
|
|
339
|
-
sessions.push(createIndexEntry(record, htmlPath, recordPath));
|
|
340
|
-
}
|
|
341
|
-
writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
|
|
342
|
-
writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
|
|
343
|
-
}
|
|
344
|
-
finally {
|
|
345
|
-
if (lockAcquired && existsSync(lockPath)) {
|
|
346
|
-
try {
|
|
347
|
-
import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath));
|
|
348
|
-
}
|
|
349
|
-
catch { }
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
if (context) {
|
|
353
|
-
const displayPath = htmlPath.startsWith(os.homedir())
|
|
354
|
-
? `~${htmlPath.slice(os.homedir().length)}`
|
|
355
|
-
: htmlPath;
|
|
356
|
-
adapter.attach(context, {
|
|
357
|
-
html: displayPath,
|
|
358
|
-
record: recordPath
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
|
|
385
|
+
notraceDir: process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace"),
|
|
386
|
+
adapter: getActiveAdapter(ctx.cwd),
|
|
387
|
+
});
|
|
362
388
|
});
|
|
363
389
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildModelSummary, buildModelSwitches, groupByModel } from "../analytics.js";
|
|
3
|
+
const events = [
|
|
4
|
+
{ type: "llm_completion", model: "a", provider: "x", timestamp: 1000, usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 1 } } },
|
|
5
|
+
{ type: "llm_completion", model: "a", provider: "x", timestamp: "2026-06-17T17:00:02.000Z", usage: { input: 20, output: 10, totalTokens: 30, cost: { total: 2 } }, errorMessage: "oops" },
|
|
6
|
+
{ type: "llm_completion", model: "b", provider: "y", timestamp: "2026-06-17T17:00:03.000Z", usage: { input: 40, output: 20, cost: { total: 3 } } }, // missing totalTokens
|
|
7
|
+
];
|
|
8
|
+
describe("report-app analytics", () => {
|
|
9
|
+
it("groups model usage and falls back when totalTokens is missing", () => {
|
|
10
|
+
const grouped = groupByModel(events);
|
|
11
|
+
expect(grouped.a.count).toBe(2);
|
|
12
|
+
expect(grouped.a.inputTokens).toBe(30);
|
|
13
|
+
expect(grouped.a.errors).toBe(1);
|
|
14
|
+
// b is missing totalTokens, should fallback to input + output (40 + 20)
|
|
15
|
+
expect(grouped.b.totalTokens).toBe(60);
|
|
16
|
+
});
|
|
17
|
+
it("tracks model switches and normalizes string timestamps", () => {
|
|
18
|
+
const switches = buildModelSwitches(events);
|
|
19
|
+
expect(switches).toHaveLength(1);
|
|
20
|
+
expect(switches[0].from).toBe("a");
|
|
21
|
+
expect(switches[0].to).toBe("b");
|
|
22
|
+
expect(switches[0].providerChanged).toBe(true);
|
|
23
|
+
// timestamp Delta between "2026-06-17T17:00:02.000Z" and "2026-06-17T17:00:03.000Z" is 1000ms
|
|
24
|
+
expect(switches[0].timeDelta).toBe(1000);
|
|
25
|
+
// falls back to input + output when totalTokens is missing
|
|
26
|
+
expect(switches[0].tokens).toBe(60);
|
|
27
|
+
});
|
|
28
|
+
it("builds summary", () => {
|
|
29
|
+
const summary = buildModelSummary(events);
|
|
30
|
+
expect(summary.firstModel).toBe("a");
|
|
31
|
+
expect(summary.finalModel).toBe("b");
|
|
32
|
+
expect(summary.switchCount).toBe(1);
|
|
33
|
+
expect(summary.uniqueModels).toBe(2);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderCollapsibleSection, renderKeyValueList, renderToolResultHtml, renderToolUseHtml } from "../components/card.js";
|
|
3
|
+
describe("report-app card", () => {
|
|
4
|
+
it("renders tool use and result cards", () => {
|
|
5
|
+
const useHtml = renderToolUseHtml("bash", { command: "echo hi" });
|
|
6
|
+
const resultHtml = renderToolResultHtml("tool-1", { stdout: "ok" }, true);
|
|
7
|
+
expect(useHtml).toContain("chat-tool-use");
|
|
8
|
+
expect(useHtml).toContain("bash");
|
|
9
|
+
expect(resultHtml).toContain("Tool Result: tool-1");
|
|
10
|
+
expect(resultHtml).toContain("color: var(--err);");
|
|
11
|
+
});
|
|
12
|
+
it("escapes injected content", () => {
|
|
13
|
+
const html = renderToolUseHtml("<script>alert(1)</script>", "<script>alert(1)</script>");
|
|
14
|
+
expect(html).toContain("<script>alert(1)</script>");
|
|
15
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
16
|
+
});
|
|
17
|
+
it("renders collapsible sections and key-value lists", () => {
|
|
18
|
+
const section = renderCollapsibleSection("Models", "<div>body</div>", true);
|
|
19
|
+
const kv = renderKeyValueList([["A", "B"], ["Empty", ""]]);
|
|
20
|
+
expect(section).toContain("panel collapsible");
|
|
21
|
+
expect(section).toContain(" open");
|
|
22
|
+
expect(kv).toContain("kv-list");
|
|
23
|
+
expect(kv).toContain("Empty");
|
|
24
|
+
expect(kv).toContain(">-<");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderEventCard } from "../components/event.js";
|
|
3
|
+
describe("report-app event", () => {
|
|
4
|
+
it("escapes hostile content in lazy event body attributes", () => {
|
|
5
|
+
const hostileEvent = {
|
|
6
|
+
type: "llm_completion",
|
|
7
|
+
outputContent: '<img src=x onerror=alert(1)><script>alert(1)</script>" autofocus onfocus=alert(1)',
|
|
8
|
+
timestamp: 1000
|
|
9
|
+
};
|
|
10
|
+
const html = renderEventCard(hostileEvent);
|
|
11
|
+
// The data-lazy-event-body attribute should contain the URI-encoded then HTML-escaped string.
|
|
12
|
+
// It must NOT contain the raw dangerous strings.
|
|
13
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
14
|
+
expect(html).not.toContain('onerror=alert(1)');
|
|
15
|
+
expect(html).toContain("data-lazy-event-body=");
|
|
16
|
+
// Double check that the encoded payload still holds the data safely.
|
|
17
|
+
expect(html).toContain("alert(1)"); // uri encoded form might just be alert(1) but the < > and " will be %3C %3E %22
|
|
18
|
+
expect(html).toContain("%26lt%3Bscript%26gt%3Balert(1)%26lt%3B%2Fscript%26gt%3B");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatDateDay, formatMs, formatTelemetryStatus, formatTimeMinutes, formatTimeSeconds, formatTokens, formatUsd, parseDate, taskDisplay, workflowClassName, workflowDisplayName } from "../format.js";
|
|
3
|
+
describe("report-app format", () => {
|
|
4
|
+
it("parses valid dates and rejects invalid ones", () => {
|
|
5
|
+
expect(parseDate("2026-06-17T17:00:00Z")?.toISOString()).toBe("2026-06-17T17:00:00.000Z");
|
|
6
|
+
expect(parseDate("nope")).toBeNull();
|
|
7
|
+
});
|
|
8
|
+
it("formats date and time pieces", () => {
|
|
9
|
+
expect(formatDateDay("2026-06-17T17:00:00Z")).toBe("2026-06-17");
|
|
10
|
+
expect(formatTimeMinutes("2026-06-17T17:00:00Z")).toMatch(/^17:00|\d{2}:\d{2}$/);
|
|
11
|
+
expect(formatTimeSeconds("2026-06-17T17:00:00Z")).toMatch(/^17:00:00|\d{2}:\d{2}:\d{2}$/);
|
|
12
|
+
});
|
|
13
|
+
it("formats workflow labels and classes", () => {
|
|
14
|
+
expect(workflowDisplayName("norpiv")).toBe("RPIV");
|
|
15
|
+
expect(workflowDisplayName("research")).toBe("Research");
|
|
16
|
+
expect(workflowDisplayName(undefined)).toBe("Generic");
|
|
17
|
+
expect(workflowClassName("norpiv")).toBe("workflow-rpiv");
|
|
18
|
+
expect(workflowClassName("research")).toBe("workflow-research");
|
|
19
|
+
expect(workflowClassName(undefined)).toBe("workflow-generic");
|
|
20
|
+
});
|
|
21
|
+
it("preserves old taskDisplay semantics for direct and nested shapes", () => {
|
|
22
|
+
expect(taskDisplay({ workflow: "research", id: "branch:main" })).toBe("Branch main");
|
|
23
|
+
expect(taskDisplay({ task: { workflow: "norpiv", id: "NR-101" } })).toBe("NR-101");
|
|
24
|
+
expect(taskDisplay({ workflow: "generic" })).toBe("General session");
|
|
25
|
+
expect(taskDisplay({ workflow: "weird" })).toBe("No active task");
|
|
26
|
+
});
|
|
27
|
+
it("formats usd, tokens, durations, and telemetry status", () => {
|
|
28
|
+
expect(formatUsd(0)).toBe("$0");
|
|
29
|
+
expect(formatUsd(1.234567)).toBe("$1.23457");
|
|
30
|
+
expect(formatTokens(999)).toBe("999");
|
|
31
|
+
expect(formatTokens(1500)).toBe("1.5k");
|
|
32
|
+
expect(formatTokens(2_500_000)).toBe("2.50M");
|
|
33
|
+
expect(formatMs(0)).toBe("-");
|
|
34
|
+
expect(formatMs(15_000)).toBe("15s");
|
|
35
|
+
expect(formatMs(125_000)).toBe("2m 5s");
|
|
36
|
+
expect(formatMs(3_780_000)).toBe("1h 3m");
|
|
37
|
+
expect(formatTelemetryStatus("active")).toBe("Active");
|
|
38
|
+
expect(formatTelemetryStatus("loaded-disabled")).toBe("Loaded disabled");
|
|
39
|
+
expect(formatTelemetryStatus(undefined)).toBe("Unknown");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateHtmlReport } from "../report.js";
|
|
3
|
+
const record = {
|
|
4
|
+
traceId: "session-1",
|
|
5
|
+
repository: { name: "nothing", branch: "main" },
|
|
6
|
+
session: { id: "session-1", startedAt: "2026-06-17T17:00:00Z", durationMs: 15000 },
|
|
7
|
+
task: { workflow: "research", id: "branch:main" },
|
|
8
|
+
captureMode: "full",
|
|
9
|
+
conditions: { providers: ["anthropic"] },
|
|
10
|
+
activity: {
|
|
11
|
+
llmCallCount: 1,
|
|
12
|
+
toolCallCount: 1,
|
|
13
|
+
toolErrorCount: 0,
|
|
14
|
+
durationMs: 15000,
|
|
15
|
+
totals: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 15, totalCostUsd: 0.01 },
|
|
16
|
+
},
|
|
17
|
+
telemetry: { extensions: {} },
|
|
18
|
+
events: [
|
|
19
|
+
{ type: "llm_completion", model: "claude", provider: "anthropic", timestamp: 1710000000000, inputPayload: { messages: [{ role: "user", content: "hi" }] }, outputContent: "hello", usage: { totalTokens: 15, cost: { total: 0.01 } } },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
describe("report-app report", () => {
|
|
23
|
+
it("renders structural markers", () => {
|
|
24
|
+
const html = generateHtmlReport(record);
|
|
25
|
+
expect(html).toContain("Session retrospective");
|
|
26
|
+
expect(html).toContain("Run Summary");
|
|
27
|
+
expect(html).toContain("Timeline");
|
|
28
|
+
expect(html).toContain("Branch main");
|
|
29
|
+
expect(html).toContain("claude");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function groupByModel(events) {
|
|
2
|
+
const models = {};
|
|
3
|
+
for (const ev of events) {
|
|
4
|
+
if (ev.type !== "llm_completion")
|
|
5
|
+
continue;
|
|
6
|
+
const name = ev.model || "unknown";
|
|
7
|
+
if (!models[name]) {
|
|
8
|
+
models[name] = { count: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0, cacheRead: 0, cacheWrite: 0, errors: 0 };
|
|
9
|
+
}
|
|
10
|
+
const m = models[name];
|
|
11
|
+
m.count++;
|
|
12
|
+
if (ev.usage) {
|
|
13
|
+
const input = Number(ev.usage.inputTokens || ev.usage.input || 0);
|
|
14
|
+
const output = Number(ev.usage.outputTokens || ev.usage.output || 0);
|
|
15
|
+
const cacheR = Number(ev.usage.cacheReadTokens || ev.usage.cacheRead || 0);
|
|
16
|
+
const cacheW = Number(ev.usage.cacheWriteTokens || ev.usage.cacheWrite || 0);
|
|
17
|
+
m.inputTokens += input;
|
|
18
|
+
m.outputTokens += output;
|
|
19
|
+
m.cacheRead += cacheR;
|
|
20
|
+
m.cacheWrite += cacheW;
|
|
21
|
+
m.totalTokens += Number(ev.usage.totalTokens || (input + output + cacheR + cacheW));
|
|
22
|
+
m.cost += Number(ev.usage.cost?.total || 0);
|
|
23
|
+
}
|
|
24
|
+
if (ev.errorMessage)
|
|
25
|
+
m.errors++;
|
|
26
|
+
}
|
|
27
|
+
return models;
|
|
28
|
+
}
|
|
29
|
+
export function buildModelSwitches(events) {
|
|
30
|
+
const switches = [];
|
|
31
|
+
let lastModel = null;
|
|
32
|
+
let lastProvider = null;
|
|
33
|
+
let lastTime = null;
|
|
34
|
+
let completionIndex = 0;
|
|
35
|
+
for (const ev of events) {
|
|
36
|
+
if (ev.type !== "llm_completion")
|
|
37
|
+
continue;
|
|
38
|
+
completionIndex++;
|
|
39
|
+
const currentModel = ev.model || "unknown";
|
|
40
|
+
const currentProvider = ev.provider || "unknown";
|
|
41
|
+
const currentTimestamp = ev.timestamp ? new Date(ev.timestamp).getTime() : 0;
|
|
42
|
+
const validTimestamp = Number.isNaN(currentTimestamp) ? 0 : currentTimestamp;
|
|
43
|
+
if (lastModel && lastModel !== currentModel) {
|
|
44
|
+
const input = Number(ev.usage?.inputTokens || ev.usage?.input || 0);
|
|
45
|
+
const output = Number(ev.usage?.outputTokens || ev.usage?.output || 0);
|
|
46
|
+
const cacheR = Number(ev.usage?.cacheReadTokens || ev.usage?.cacheRead || 0);
|
|
47
|
+
const cacheW = Number(ev.usage?.cacheWriteTokens || ev.usage?.cacheWrite || 0);
|
|
48
|
+
switches.push({
|
|
49
|
+
index: completionIndex,
|
|
50
|
+
from: lastModel,
|
|
51
|
+
to: currentModel,
|
|
52
|
+
fromProvider: lastProvider || "unknown",
|
|
53
|
+
toProvider: currentProvider,
|
|
54
|
+
providerChanged: (lastProvider || "unknown") !== currentProvider,
|
|
55
|
+
timestamp: validTimestamp,
|
|
56
|
+
timeDelta: lastTime ? validTimestamp - lastTime : 0,
|
|
57
|
+
cost: Number(ev.usage?.cost?.total || 0),
|
|
58
|
+
tokens: Number(ev.usage?.totalTokens || (input + output + cacheR + cacheW))
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
lastModel = currentModel;
|
|
62
|
+
lastProvider = currentProvider;
|
|
63
|
+
lastTime = validTimestamp;
|
|
64
|
+
}
|
|
65
|
+
return switches;
|
|
66
|
+
}
|
|
67
|
+
export function buildModelSummary(events) {
|
|
68
|
+
const completions = events.filter((ev) => ev.type === "llm_completion");
|
|
69
|
+
if (!completions.length)
|
|
70
|
+
return null;
|
|
71
|
+
const uniqueModels = new Set(completions.map((ev) => ev.model || "unknown"));
|
|
72
|
+
return {
|
|
73
|
+
firstModel: completions[0]?.model || "unknown",
|
|
74
|
+
finalModel: completions[completions.length - 1]?.model || "unknown",
|
|
75
|
+
switchCount: buildModelSwitches(events).length,
|
|
76
|
+
uniqueModels: uniqueModels.size,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const COPY_SCRIPT = "(() => {\n document.querySelectorAll('[data-copy-value]').forEach((button) => {\n button.addEventListener('click', async () => {\n const value = button.getAttribute('data-copy-value') || '';\n try {\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(value);\n } else {\n const textarea = document.createElement('textarea');\n textarea.value = value;\n textarea.style.position = 'fixed';\n textarea.style.opacity = '0';\n document.body.appendChild(textarea);\n textarea.focus();\n textarea.select();\n document.execCommand('copy');\n textarea.remove();\n }\n const previous = button.innerHTML;\n button.classList.add('copied');\n button.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6 9 17l-5-5\"></path></svg>';\n setTimeout(() => {\n button.classList.remove('copied');\n button.innerHTML = previous;\n }, 1400);\n } catch {\n button.textContent = 'ERR';\n }\n });\n });\n\n document.querySelectorAll('details[data-lazy-event-body]').forEach((details) => {\n details.addEventListener('toggle', () => {\n if (!details.open) return;\n if (details.querySelector('.event-body')) return;\n const html = decodeURIComponent(details.getAttribute('data-lazy-event-body') || '');\n details.insertAdjacentHTML('beforeend', html);\n }, { once: false });\n });\n\n const topBtn = document.querySelector('.back-to-top');\n if (topBtn) {\n const syncTopButton = () => {\n const scrollable = document.documentElement.scrollHeight > window.innerHeight + 24;\n const show = scrollable && window.scrollY > 200;\n topBtn.classList.toggle('visible', show);\n };\n window.addEventListener('scroll', syncTopButton, { passive: true });\n window.addEventListener('resize', syncTopButton);\n syncTopButton();\n }\n })();";
|
|
2
|
+
export declare const DASHBOARD_SORT_SCRIPT = "(() => {\n const table = document.querySelector('[data-dashboard-table]');\n if (!table) return;\n const tbody = table.querySelector('tbody');\n if (!tbody) return;\n const buttons = Array.from(document.querySelectorAll('[data-sort-key]'));\n let currentKey = 'index';\n let currentDir = 'desc';\n\n function icon(dir) {\n return dir === 'asc' ? '\u2191' : '\u2193';\n }\n\n function updateState() {\n buttons.forEach(btn => {\n const key = btn.getAttribute('data-sort-key');\n const state = btn.querySelector('.sort-state');\n if (!state) return;\n state.textContent = key === currentKey ? icon(currentDir) : '';\n });\n }\n\n function compare(a, b, key) {\n if (key === 'index' || key === 'started' || key === 'tokens' || key === 'cost') {\n return Number(a.dataset[key] || 0) - Number(b.dataset[key] || 0);\n }\n return String(a.dataset[key] || '').localeCompare(String(b.dataset[key] || ''));\n }\n\n function sortBy(key) {\n const rows = Array.from(tbody.querySelectorAll('tr'));\n rows.sort((a, b) => {\n const result = compare(a, b, key);\n return currentDir === 'asc' ? result : -result;\n });\n rows.forEach(row => tbody.appendChild(row));\n updateState();\n }\n\n buttons.forEach(btn => {\n btn.addEventListener('click', () => {\n const key = btn.getAttribute('data-sort-key') || 'index';\n if (currentKey === key) currentDir = currentDir === 'asc' ? 'desc' : 'asc';\n else {\n currentKey = key;\n currentDir = key === 'workflow' ? 'asc' : 'desc';\n }\n sortBy(currentKey);\n });\n });\n\n sortBy(currentKey);\n })();";
|