newpr 1.0.2 → 1.0.3
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/package.json +1 -1
- package/src/telemetry/index.ts +115 -0
- package/src/web/client/components/AnalyticsConsent.tsx +10 -0
- package/src/web/client/components/SettingsPanel.tsx +5 -0
- package/src/web/server/routes.ts +9 -0
- package/src/web/server/session-manager.ts +7 -0
- package/src/web/server.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const GA_ID = "G-L3SL6T6JQ1";
|
|
7
|
+
const GA_SECRET = "Sier1nbXS2-eX2TR3j1kZQ";
|
|
8
|
+
const MP_ENDPOINT = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`;
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = join(homedir(), ".newpr");
|
|
11
|
+
const TELEMETRY_FILE = join(CONFIG_DIR, "telemetry.json");
|
|
12
|
+
|
|
13
|
+
interface TelemetryConfig {
|
|
14
|
+
client_id: string;
|
|
15
|
+
consent: "granted" | "denied" | "pending";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let config: TelemetryConfig | null = null;
|
|
19
|
+
|
|
20
|
+
function ensureDir(): void {
|
|
21
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadConfig(): Promise<TelemetryConfig> {
|
|
25
|
+
if (config) return config;
|
|
26
|
+
try {
|
|
27
|
+
const file = Bun.file(TELEMETRY_FILE);
|
|
28
|
+
if (await file.exists()) {
|
|
29
|
+
const data = JSON.parse(await file.text()) as Partial<TelemetryConfig>;
|
|
30
|
+
config = {
|
|
31
|
+
client_id: data.client_id || randomUUID(),
|
|
32
|
+
consent: data.consent === "granted" || data.consent === "denied" ? data.consent : "pending",
|
|
33
|
+
};
|
|
34
|
+
} else {
|
|
35
|
+
config = { client_id: randomUUID(), consent: "pending" };
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
config = { client_id: randomUUID(), consent: "pending" };
|
|
39
|
+
}
|
|
40
|
+
await saveConfig();
|
|
41
|
+
return config;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function saveConfig(): Promise<void> {
|
|
45
|
+
if (!config) return;
|
|
46
|
+
ensureDir();
|
|
47
|
+
await Bun.write(TELEMETRY_FILE, `${JSON.stringify(config, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getTelemetryConsent(): Promise<"granted" | "denied" | "pending"> {
|
|
51
|
+
const cfg = await loadConfig();
|
|
52
|
+
return cfg.consent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function setTelemetryConsent(consent: "granted" | "denied"): Promise<void> {
|
|
56
|
+
const cfg = await loadConfig();
|
|
57
|
+
cfg.consent = consent;
|
|
58
|
+
await saveConfig();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sendEvents(events: Array<{ name: string; params?: Record<string, string | number | boolean> }>): Promise<void> {
|
|
62
|
+
const cfg = await loadConfig();
|
|
63
|
+
if (cfg.consent !== "granted") return;
|
|
64
|
+
|
|
65
|
+
const body = {
|
|
66
|
+
client_id: cfg.client_id,
|
|
67
|
+
events,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await fetch(MP_ENDPOINT, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
// Fire-and-forget — network failures are silent
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function track(name: string, params?: Record<string, string | number | boolean>): void {
|
|
82
|
+
sendEvents([{ name, params }]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const telemetry = {
|
|
86
|
+
serverStarted: (version: string) =>
|
|
87
|
+
track("server_started", { version, platform: process.platform, arch: process.arch }),
|
|
88
|
+
|
|
89
|
+
analysisStarted: (fileCount: number) =>
|
|
90
|
+
track("analysis_started", { file_count: fileCount }),
|
|
91
|
+
|
|
92
|
+
analysisCompleted: (fileCount: number, durationSec: number) =>
|
|
93
|
+
track("analysis_completed", { file_count: fileCount, duration_sec: durationSec }),
|
|
94
|
+
|
|
95
|
+
analysisError: (errorType: string) =>
|
|
96
|
+
track("analysis_error", { error_type: errorType.slice(0, 100) }),
|
|
97
|
+
|
|
98
|
+
chatSent: () =>
|
|
99
|
+
track("chat_sent"),
|
|
100
|
+
|
|
101
|
+
chatCompleted: (durationSec: number, hasTools: boolean) =>
|
|
102
|
+
track("chat_completed", { duration_sec: durationSec, has_tools: hasTools }),
|
|
103
|
+
|
|
104
|
+
reviewSubmitted: (event: string) =>
|
|
105
|
+
track("review_submitted", { review_event: event }),
|
|
106
|
+
|
|
107
|
+
stackStarted: () =>
|
|
108
|
+
track("stack_started"),
|
|
109
|
+
|
|
110
|
+
stackCompleted: (groupCount: number) =>
|
|
111
|
+
track("stack_completed", { group_count: groupCount }),
|
|
112
|
+
|
|
113
|
+
stackPublished: (prCount: number) =>
|
|
114
|
+
track("stack_published", { pr_count: prCount }),
|
|
115
|
+
};
|
|
@@ -7,13 +7,23 @@ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
|
|
|
7
7
|
|
|
8
8
|
if (state !== "pending") return null;
|
|
9
9
|
|
|
10
|
+
const syncServer = (consent: "granted" | "denied") => {
|
|
11
|
+
fetch("/api/config", {
|
|
12
|
+
method: "PUT",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ telemetry_consent: consent }),
|
|
15
|
+
}).catch(() => {});
|
|
16
|
+
};
|
|
17
|
+
|
|
10
18
|
const handleAccept = () => {
|
|
11
19
|
setConsent("granted");
|
|
20
|
+
syncServer("granted");
|
|
12
21
|
onDone();
|
|
13
22
|
};
|
|
14
23
|
|
|
15
24
|
const handleDecline = () => {
|
|
16
25
|
setConsent("denied");
|
|
26
|
+
syncServer("denied");
|
|
17
27
|
onDone();
|
|
18
28
|
};
|
|
19
29
|
|
|
@@ -285,6 +285,11 @@ function AnalyticsToggle() {
|
|
|
285
285
|
setConsent(next);
|
|
286
286
|
setLocal(next);
|
|
287
287
|
analytics.settingsChanged("analytics");
|
|
288
|
+
fetch("/api/config", {
|
|
289
|
+
method: "PUT",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body: JSON.stringify({ telemetry_consent: next }),
|
|
292
|
+
}).catch(() => {});
|
|
288
293
|
};
|
|
289
294
|
|
|
290
295
|
return (
|
package/src/web/server/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
|
17
17
|
import { randomBytes } from "node:crypto";
|
|
18
18
|
import { publishStack } from "../../stack/publish.ts";
|
|
19
19
|
import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks } from "./stack-manager.ts";
|
|
20
|
+
import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
|
|
20
21
|
|
|
21
22
|
function json(data: unknown, status = 200): Response {
|
|
22
23
|
return new Response(JSON.stringify(data), {
|
|
@@ -610,6 +611,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
610
611
|
const pluginList = getAllPlugins().map((p) => ({ id: p.id, name: p.name }));
|
|
611
612
|
const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
|
|
612
613
|
const agents = await detectAgents();
|
|
614
|
+
const telemetryConsent = await getTelemetryConsent();
|
|
613
615
|
return json({
|
|
614
616
|
model: config.model,
|
|
615
617
|
agent: config.agent ?? null,
|
|
@@ -622,6 +624,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
622
624
|
has_github_token: !!token,
|
|
623
625
|
enabled_plugins: enabledPlugins,
|
|
624
626
|
available_plugins: pluginList,
|
|
627
|
+
telemetry_consent: telemetryConsent,
|
|
625
628
|
defaults: {
|
|
626
629
|
model: DEFAULT_CONFIG.model,
|
|
627
630
|
language: DEFAULT_CONFIG.language,
|
|
@@ -673,6 +676,11 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
673
676
|
update.enabled_plugins = (body as Record<string, unknown>).enabled_plugins as string[];
|
|
674
677
|
}
|
|
675
678
|
|
|
679
|
+
const telemetryConsentVal = (body as Record<string, unknown>).telemetry_consent as string | undefined;
|
|
680
|
+
if (telemetryConsentVal === "granted" || telemetryConsentVal === "denied") {
|
|
681
|
+
await setTelemetryConsent(telemetryConsentVal);
|
|
682
|
+
}
|
|
683
|
+
|
|
676
684
|
await writeStoredConfig(update);
|
|
677
685
|
return json({ ok: true });
|
|
678
686
|
},
|
|
@@ -748,6 +756,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
748
756
|
return json({ error: errBody.message ?? `GitHub API error: ${res.status}` }, res.status);
|
|
749
757
|
}
|
|
750
758
|
const data = await res.json() as { id: number; state: string; html_url: string };
|
|
759
|
+
telemetry.reviewSubmitted(body.event);
|
|
751
760
|
return json({ ok: true, id: data.id, state: data.state, html_url: data.html_url });
|
|
752
761
|
} catch (err) {
|
|
753
762
|
return json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
@@ -4,6 +4,7 @@ import type { ProgressEvent } from "../../analyzer/progress.ts";
|
|
|
4
4
|
import { analyzePr } from "../../analyzer/pipeline.ts";
|
|
5
5
|
import { parsePrInput } from "../../github/parse-pr.ts";
|
|
6
6
|
import { saveSession, savePatchesSidecar } from "../../history/store.ts";
|
|
7
|
+
import { telemetry } from "../../telemetry/index.ts";
|
|
7
8
|
|
|
8
9
|
type SessionStatus = "running" | "done" | "error" | "canceled";
|
|
9
10
|
|
|
@@ -65,6 +66,7 @@ export function startAnalysis(
|
|
|
65
66
|
};
|
|
66
67
|
sessions.set(id, session);
|
|
67
68
|
|
|
69
|
+
telemetry.analysisStarted(0);
|
|
68
70
|
runPipeline(session, prInput, token, config);
|
|
69
71
|
|
|
70
72
|
return { sessionId: id };
|
|
@@ -100,6 +102,9 @@ async function runPipeline(
|
|
|
100
102
|
session.result = result;
|
|
101
103
|
session.finishedAt = Date.now();
|
|
102
104
|
|
|
105
|
+
const durationSec = Math.round((session.finishedAt - session.startedAt) / 1000);
|
|
106
|
+
telemetry.analysisCompleted(result.files?.length ?? 0, durationSec);
|
|
107
|
+
|
|
103
108
|
for (const sub of session.subscribers) {
|
|
104
109
|
sub({ type: "done" });
|
|
105
110
|
}
|
|
@@ -118,6 +123,8 @@ async function runPipeline(
|
|
|
118
123
|
session.error = msg;
|
|
119
124
|
session.finishedAt = Date.now();
|
|
120
125
|
|
|
126
|
+
telemetry.analysisError(msg);
|
|
127
|
+
|
|
121
128
|
for (const sub of session.subscribers) {
|
|
122
129
|
sub({ type: "error", data: msg });
|
|
123
130
|
}
|
package/src/web/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import index from "./index.html";
|
|
|
5
5
|
|
|
6
6
|
import type { PreflightResult } from "../cli/preflight.ts";
|
|
7
7
|
import { getVersion } from "../version.ts";
|
|
8
|
+
import { telemetry } from "../telemetry/index.ts";
|
|
8
9
|
|
|
9
10
|
interface WebServerOptions {
|
|
10
11
|
port: number;
|
|
@@ -211,6 +212,8 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
|
|
|
211
212
|
|
|
212
213
|
const url = `http://localhost:${server.port}`;
|
|
213
214
|
|
|
215
|
+
telemetry.serverStarted(getVersion());
|
|
216
|
+
|
|
214
217
|
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
215
218
|
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
216
219
|
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|