Skip to content

Commit 34e88be

Browse files
committed
add network.log for main and renderer requests
1 parent 68f1ef6 commit 34e88be

9 files changed

Lines changed: 957 additions & 22 deletions

File tree

apps/code/src/main/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TypedEventEmitter } from "@posthog/shared";
44
import type { WorkspaceClient } from "@posthog/workspace-client/client";
55
import { createWorkspaceClient } from "@posthog/workspace-client/client";
66
import type { FileWatcherEvent } from "@posthog/workspace-client/types";
7-
import { app, BrowserWindow, dialog } from "electron";
7+
import { app, BrowserWindow, dialog, session } from "electron";
88
import log from "electron-log/main";
99
import "./utils/logger";
1010
import "./services/index.js";
@@ -55,6 +55,7 @@ import {
5555
AUTH_SERVICE,
5656
CANVAS_LINK_SERVICE,
5757
DATABASE_SERVICE,
58+
DEV_NETWORK_SERVICE,
5859
DISCORD_PRESENCE_SERVICE,
5960
EXTERNAL_APPS_SERVICE,
6061
FILE_WATCHER_SERVICE,
@@ -72,6 +73,7 @@ import {
7273
import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics";
7374
import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox";
7475
import type { AppLifecycleService } from "./services/app-lifecycle/service";
76+
import type { DevNetworkService } from "./services/dev-network/service";
7577
import { initDevToolbar } from "./services/dev-toolbar";
7678
import type { DiscordPresenceService } from "./services/discord-presence/service";
7779
import {
@@ -87,9 +89,12 @@ import { ensureClaudeConfigDir } from "./utils/env";
8789
import {
8890
getChromiumLogFilePath,
8991
getLogFilePath,
92+
getNetworkLogFilePath,
9093
readChromiumLogTail,
9194
} from "./utils/logger";
9295
import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard";
96+
import { installMainFetchLogging } from "./utils/network-fetch-logger";
97+
import { installRendererNetworkLogging } from "./utils/network-webrequest-logger";
9398
import { createWindow } from "./window";
9499

95100
type FileWatcherEventsByKind = {
@@ -286,6 +291,11 @@ registerDeepLinkHandlers();
286291
// Initialize PostHog analytics
287292
posthogNodeAnalytics.initialize();
288293

294+
// Must wrap fetch before DevNetworkService.install() (post-ready, dev toolbar)
295+
// so it stays the innermost layer; otherwise toggling dev mode off restores
296+
// native fetch and silently drops network.log capture.
297+
installMainFetchLogging();
298+
289299
app.whenReady().then(async () => {
290300
if (
291301
process.platform === "darwin" &&
@@ -327,10 +337,14 @@ app.whenReady().then(async () => {
327337
].join(" | "),
328338
);
329339
log.info(
330-
`Logs: main=${getLogFilePath()} chromium=${getChromiumLogFilePath() ?? "(disabled)"}`,
340+
`Logs: main=${getLogFilePath()} chromium=${getChromiumLogFilePath() ?? "(disabled)"} network=${getNetworkLogFilePath()}`,
331341
);
332342
ensureClaudeConfigDir();
333343
registerMcpSandboxProtocol();
344+
installRendererNetworkLogging(
345+
session.fromPartition("persist:main").webRequest,
346+
container.get<DevNetworkService>(DEV_NETWORK_SERVICE),
347+
);
334348
createWindow();
335349

336350
const wsServer = container.get<WorkspaceServerService>(

apps/code/src/main/services/dev-network/service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export class DevNetworkService extends TypedEventEmitter<DevNetworkEvents> {
8181
this.emit(DevNetworkEvent.Request, req);
8282
}
8383

84+
recordExternal(req: Omit<NetworkRequest, "id" | "host">): void {
85+
if (!this.capturing()) return;
86+
this.record({ ...req, id: this.nextId++, host: safeHost(req.url) });
87+
}
88+
8489
private wrapFetch(): void {
8590
const original = globalThis.fetch;
8691
if (!original) return;

apps/code/src/main/utils/logger.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import os from "node:os";
1212
import { join } from "node:path";
1313
import { initOtelTransport } from "@main/utils/otel-log-transport";
14+
import type ElectronLog from "electron-log";
1415
import log from "electron-log/main";
1516
import { isDevBuild } from "./env";
1617

@@ -21,42 +22,62 @@ const LOG_DIR = join(
2122
isDev ? "logs-dev" : "logs",
2223
);
2324
const LOG_FILE = "main.log";
25+
const NETWORK_LOG_FILE = "network.log";
2426
const MAX_ARCHIVES = 3;
27+
const MAX_LOG_SIZE = 10 * 1024 * 1024;
2528

2629
mkdirSync(LOG_DIR, { recursive: true });
2730

28-
log.initialize();
29-
30-
log.transports.file.resolvePathFn = () => join(LOG_DIR, LOG_FILE);
31-
log.transports.file.maxSize = 10 * 1024 * 1024; // 10 MB
32-
log.transports.file.archiveLogFn = (oldLogFile) => {
33-
const archivePath = (n: number) => join(LOG_DIR, `main.${n}.log`);
31+
function createArchiveLogFn(
32+
prefix: string,
33+
): (oldLogFile: ElectronLog.LogFile) => void {
34+
return (oldLogFile) => {
35+
const archivePath = (n: number) => join(LOG_DIR, `${prefix}.${n}.log`);
3436

35-
try {
36-
const lastArchive = archivePath(MAX_ARCHIVES);
37-
if (existsSync(lastArchive)) {
38-
unlinkSync(lastArchive);
39-
}
37+
try {
38+
const lastArchive = archivePath(MAX_ARCHIVES);
39+
if (existsSync(lastArchive)) {
40+
unlinkSync(lastArchive);
41+
}
4042

41-
for (let i = MAX_ARCHIVES - 1; i >= 1; i--) {
42-
const from = archivePath(i);
43-
if (existsSync(from)) {
44-
renameSync(from, archivePath(i + 1));
43+
for (let i = MAX_ARCHIVES - 1; i >= 1; i--) {
44+
const from = archivePath(i);
45+
if (existsSync(from)) {
46+
renameSync(from, archivePath(i + 1));
47+
}
4548
}
49+
50+
renameSync(oldLogFile.path, archivePath(1));
51+
} catch {
52+
// Best-effort rotation
4653
}
54+
};
55+
}
4756

48-
renameSync(oldLogFile.path, archivePath(1));
49-
} catch {
50-
// Best-effort rotation
51-
}
52-
};
57+
log.initialize();
58+
59+
log.transports.file.resolvePathFn = () => join(LOG_DIR, LOG_FILE);
60+
log.transports.file.maxSize = MAX_LOG_SIZE;
61+
log.transports.file.archiveLogFn = createArchiveLogFn("main");
5362

5463
const level = isDev ? "debug" : "info";
5564
log.transports.file.level = level;
5665
log.transports.console.level = level;
5766
log.transports.ipc.level = level;
5867
log.transports.otel = initOtelTransport(level);
5968

69+
// File-only instance: console off, ipc off (defaults to active in dev and
70+
// would spam renderer devtools), no otel so network lines stay out of OTLP
71+
// ingestion, no initialize() since nothing routes to it over renderer IPC.
72+
export const networkLog = log.create({ logId: "network" });
73+
networkLog.transports.file.resolvePathFn = () =>
74+
join(LOG_DIR, NETWORK_LOG_FILE);
75+
networkLog.transports.file.maxSize = MAX_LOG_SIZE;
76+
networkLog.transports.file.archiveLogFn = createArchiveLogFn("network");
77+
networkLog.transports.file.level = "info";
78+
networkLog.transports.console.level = false;
79+
networkLog.transports.ipc.level = false;
80+
6081
export const logger = log;
6182
export type Logger = typeof logger;
6283
export type ScopedLogger = ReturnType<typeof logger.scope>;
@@ -65,6 +86,10 @@ export function getLogFilePath(): string {
6586
return join(LOG_DIR, LOG_FILE);
6687
}
6788

89+
export function getNetworkLogFilePath(): string {
90+
return join(LOG_DIR, NETWORK_LOG_FILE);
91+
}
92+
6893
export function getChromiumLogFilePath(): string | undefined {
6994
return process.env.POSTHOG_CODE_CHROMIUM_LOG_PATH;
7095
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("./logger", () => ({
4+
networkLog: {
5+
info: vi.fn(),
6+
warn: vi.fn(),
7+
error: vi.fn(),
8+
},
9+
}));
10+
11+
vi.mock("./network-log", async (importOriginal) => {
12+
const actual = await importOriginal<typeof import("./network-log")>();
13+
return {
14+
...actual,
15+
recordNetworkRequest: vi.fn(),
16+
};
17+
});
18+
19+
import { createNetworkLoggingFetch } from "./network-fetch-logger";
20+
import { recordNetworkRequest } from "./network-log";
21+
22+
const mockedRecord = vi.mocked(recordNetworkRequest);
23+
24+
function fakeResponse(
25+
status = 200,
26+
headers: Record<string, string> = { "content-length": "1834" },
27+
): Response {
28+
return new Response(null, { status, headers });
29+
}
30+
31+
beforeEach(() => {
32+
vi.clearAllMocks();
33+
});
34+
35+
describe("createNetworkLoggingFetch", () => {
36+
it("records a successful request with status, duration and bytes", async () => {
37+
const original = vi.fn(async () => fakeResponse());
38+
const wrapped = createNetworkLoggingFetch(
39+
original as unknown as typeof fetch,
40+
);
41+
42+
const response = await wrapped("https://us.posthog.com/api/", {
43+
method: "post",
44+
});
45+
46+
expect(response.status).toBe(200);
47+
expect(original).toHaveBeenCalledWith("https://us.posthog.com/api/", {
48+
method: "post",
49+
});
50+
expect(mockedRecord).toHaveBeenCalledWith({
51+
origin: "main",
52+
method: "POST",
53+
url: "https://us.posthog.com/api/",
54+
status: 200,
55+
durationMs: expect.any(Number),
56+
bytes: 1834,
57+
});
58+
});
59+
60+
it.each([
61+
["string", "https://example.com/a", "https://example.com/a", "GET"],
62+
["URL", new URL("https://example.com/b"), "https://example.com/b", "GET"],
63+
[
64+
"Request",
65+
new Request("https://example.com/c", { method: "PUT" }),
66+
"https://example.com/c",
67+
"PUT",
68+
],
69+
])(
70+
"extracts url and method from %s input",
71+
async (_kind, input, url, method) => {
72+
const original = vi.fn(async () => fakeResponse());
73+
const wrapped = createNetworkLoggingFetch(
74+
original as unknown as typeof fetch,
75+
);
76+
77+
await wrapped(input);
78+
79+
expect(mockedRecord).toHaveBeenCalledWith(
80+
expect.objectContaining({ url, method }),
81+
);
82+
},
83+
);
84+
85+
it("records null bytes when content-length is missing", async () => {
86+
const original = vi.fn(async () => fakeResponse(204, {}));
87+
const wrapped = createNetworkLoggingFetch(
88+
original as unknown as typeof fetch,
89+
);
90+
91+
await wrapped("https://example.com/");
92+
93+
expect(mockedRecord).toHaveBeenCalledWith(
94+
expect.objectContaining({ status: 204, bytes: null }),
95+
);
96+
});
97+
98+
it("records and rethrows async rejections", async () => {
99+
const original = vi.fn(async () => {
100+
throw new TypeError("fetch failed");
101+
});
102+
const wrapped = createNetworkLoggingFetch(
103+
original as unknown as typeof fetch,
104+
);
105+
106+
await expect(wrapped("https://example.com/")).rejects.toThrow(
107+
"fetch failed",
108+
);
109+
expect(mockedRecord).toHaveBeenCalledWith(
110+
expect.objectContaining({
111+
status: null,
112+
bytes: null,
113+
error: "TypeError: fetch failed",
114+
}),
115+
);
116+
});
117+
118+
it("records and rethrows synchronous throws", async () => {
119+
const original = vi.fn(() => {
120+
throw new Error("boom");
121+
});
122+
const wrapped = createNetworkLoggingFetch(
123+
original as unknown as typeof fetch,
124+
);
125+
126+
await expect(wrapped("https://example.com/")).rejects.toThrow("boom");
127+
expect(mockedRecord).toHaveBeenCalledWith(
128+
expect.objectContaining({ error: "Error: boom" }),
129+
);
130+
});
131+
132+
it("forwards preconnect from the original fetch", () => {
133+
const preconnect = vi.fn();
134+
const original = Object.assign(
135+
vi.fn(async () => fakeResponse()),
136+
{
137+
preconnect,
138+
},
139+
);
140+
const wrapped = createNetworkLoggingFetch(
141+
original as unknown as typeof fetch,
142+
);
143+
144+
(wrapped as unknown as { preconnect: (origin: string) => void }).preconnect(
145+
"https://example.com",
146+
);
147+
148+
expect(preconnect).toHaveBeenCalledWith("https://example.com");
149+
});
150+
});
151+
152+
describe("installMainFetchLogging", () => {
153+
afterEach(() => {
154+
vi.unstubAllGlobals();
155+
vi.resetModules();
156+
});
157+
158+
it("wraps globalThis.fetch once and stays idempotent", async () => {
159+
const original = vi.fn(async () => fakeResponse());
160+
vi.stubGlobal("fetch", original);
161+
162+
vi.resetModules();
163+
const { installMainFetchLogging } = await import("./network-fetch-logger");
164+
165+
installMainFetchLogging();
166+
const wrappedOnce = globalThis.fetch;
167+
expect(wrappedOnce).not.toBe(original);
168+
169+
installMainFetchLogging();
170+
expect(globalThis.fetch).toBe(wrappedOnce);
171+
172+
await globalThis.fetch("https://example.com/");
173+
expect(original).toHaveBeenCalledOnce();
174+
});
175+
});

0 commit comments

Comments
 (0)