diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index aafa426f66..71ec77ceaa 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -4,7 +4,7 @@ import { TypedEventEmitter } from "@posthog/shared"; import type { WorkspaceClient } from "@posthog/workspace-client/client"; import { createWorkspaceClient } from "@posthog/workspace-client/client"; import type { FileWatcherEvent } from "@posthog/workspace-client/types"; -import { app, BrowserWindow, dialog } from "electron"; +import { app, BrowserWindow, dialog, session } from "electron"; import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; @@ -55,6 +55,7 @@ import { AUTH_SERVICE, CANVAS_LINK_SERVICE, DATABASE_SERVICE, + DEV_NETWORK_SERVICE, DISCORD_PRESENCE_SERVICE, EXTERNAL_APPS_SERVICE, FILE_WATCHER_SERVICE, @@ -72,6 +73,7 @@ import { import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox"; import type { AppLifecycleService } from "./services/app-lifecycle/service"; +import type { DevNetworkService } from "./services/dev-network/service"; import { initDevToolbar } from "./services/dev-toolbar"; import type { DiscordPresenceService } from "./services/discord-presence/service"; import { @@ -87,9 +89,12 @@ import { ensureClaudeConfigDir } from "./utils/env"; import { getChromiumLogFilePath, getLogFilePath, + getNetworkLogFilePath, readChromiumLogTail, } from "./utils/logger"; import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard"; +import { installMainFetchLogging } from "./utils/network-fetch-logger"; +import { installRendererNetworkLogging } from "./utils/network-webrequest-logger"; import { createWindow } from "./window"; type FileWatcherEventsByKind = { @@ -286,6 +291,11 @@ registerDeepLinkHandlers(); // Initialize PostHog analytics posthogNodeAnalytics.initialize(); +// Must wrap fetch before DevNetworkService.install() (post-ready, dev toolbar) +// so it stays the innermost layer; otherwise toggling dev mode off restores +// native fetch and silently drops network.log capture. +installMainFetchLogging(); + app.whenReady().then(async () => { if ( process.platform === "darwin" && @@ -327,10 +337,14 @@ app.whenReady().then(async () => { ].join(" | "), ); log.info( - `Logs: main=${getLogFilePath()} chromium=${getChromiumLogFilePath() ?? "(disabled)"}`, + `Logs: main=${getLogFilePath()} chromium=${getChromiumLogFilePath() ?? "(disabled)"} network=${getNetworkLogFilePath()}`, ); ensureClaudeConfigDir(); registerMcpSandboxProtocol(); + installRendererNetworkLogging( + session.fromPartition("persist:main").webRequest, + container.get(DEV_NETWORK_SERVICE), + ); createWindow(); const wsServer = container.get( diff --git a/apps/code/src/main/services/dev-network/service.ts b/apps/code/src/main/services/dev-network/service.ts index 563d54a7e6..98a4a20fc2 100644 --- a/apps/code/src/main/services/dev-network/service.ts +++ b/apps/code/src/main/services/dev-network/service.ts @@ -81,6 +81,11 @@ export class DevNetworkService extends TypedEventEmitter { this.emit(DevNetworkEvent.Request, req); } + recordExternal(req: Omit): void { + if (!this.capturing()) return; + this.record({ ...req, id: this.nextId++, host: safeHost(req.url) }); + } + private wrapFetch(): void { const original = globalThis.fetch; if (!original) return; diff --git a/apps/code/src/main/utils/logger.ts b/apps/code/src/main/utils/logger.ts index eb5d2435de..c9812b12c7 100644 --- a/apps/code/src/main/utils/logger.ts +++ b/apps/code/src/main/utils/logger.ts @@ -11,6 +11,7 @@ import { import os from "node:os"; import { join } from "node:path"; import { initOtelTransport } from "@main/utils/otel-log-transport"; +import type ElectronLog from "electron-log"; import log from "electron-log/main"; import { isDevBuild } from "./env"; @@ -21,35 +22,43 @@ const LOG_DIR = join( isDev ? "logs-dev" : "logs", ); const LOG_FILE = "main.log"; +const NETWORK_LOG_FILE = "network.log"; const MAX_ARCHIVES = 3; +const MAX_LOG_SIZE = 10 * 1024 * 1024; mkdirSync(LOG_DIR, { recursive: true }); -log.initialize(); - -log.transports.file.resolvePathFn = () => join(LOG_DIR, LOG_FILE); -log.transports.file.maxSize = 10 * 1024 * 1024; // 10 MB -log.transports.file.archiveLogFn = (oldLogFile) => { - const archivePath = (n: number) => join(LOG_DIR, `main.${n}.log`); +function createArchiveLogFn( + prefix: string, +): (oldLogFile: ElectronLog.LogFile) => void { + return (oldLogFile) => { + const archivePath = (n: number) => join(LOG_DIR, `${prefix}.${n}.log`); - try { - const lastArchive = archivePath(MAX_ARCHIVES); - if (existsSync(lastArchive)) { - unlinkSync(lastArchive); - } + try { + const lastArchive = archivePath(MAX_ARCHIVES); + if (existsSync(lastArchive)) { + unlinkSync(lastArchive); + } - for (let i = MAX_ARCHIVES - 1; i >= 1; i--) { - const from = archivePath(i); - if (existsSync(from)) { - renameSync(from, archivePath(i + 1)); + for (let i = MAX_ARCHIVES - 1; i >= 1; i--) { + const from = archivePath(i); + if (existsSync(from)) { + renameSync(from, archivePath(i + 1)); + } } + + renameSync(oldLogFile.path, archivePath(1)); + } catch { + // Best-effort rotation } + }; +} - renameSync(oldLogFile.path, archivePath(1)); - } catch { - // Best-effort rotation - } -}; +log.initialize(); + +log.transports.file.resolvePathFn = () => join(LOG_DIR, LOG_FILE); +log.transports.file.maxSize = MAX_LOG_SIZE; +log.transports.file.archiveLogFn = createArchiveLogFn("main"); const level = isDev ? "debug" : "info"; log.transports.file.level = level; @@ -57,6 +66,18 @@ log.transports.console.level = level; log.transports.ipc.level = level; log.transports.otel = initOtelTransport(level); +// File-only instance: console off, ipc off (defaults to active in dev and +// would spam renderer devtools), no otel so network lines stay out of OTLP +// ingestion, no initialize() since nothing routes to it over renderer IPC. +export const networkLog = log.create({ logId: "network" }); +networkLog.transports.file.resolvePathFn = () => + join(LOG_DIR, NETWORK_LOG_FILE); +networkLog.transports.file.maxSize = MAX_LOG_SIZE; +networkLog.transports.file.archiveLogFn = createArchiveLogFn("network"); +networkLog.transports.file.level = "info"; +networkLog.transports.console.level = false; +networkLog.transports.ipc.level = false; + export const logger = log; export type Logger = typeof logger; export type ScopedLogger = ReturnType; @@ -65,6 +86,10 @@ export function getLogFilePath(): string { return join(LOG_DIR, LOG_FILE); } +export function getNetworkLogFilePath(): string { + return join(LOG_DIR, NETWORK_LOG_FILE); +} + export function getChromiumLogFilePath(): string | undefined { return process.env.POSTHOG_CODE_CHROMIUM_LOG_PATH; } diff --git a/apps/code/src/main/utils/network-fetch-logger.test.ts b/apps/code/src/main/utils/network-fetch-logger.test.ts new file mode 100644 index 0000000000..e20a2d3c24 --- /dev/null +++ b/apps/code/src/main/utils/network-fetch-logger.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./logger", () => ({ + networkLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("./network-log", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordNetworkRequest: vi.fn(), + }; +}); + +import { createNetworkLoggingFetch } from "./network-fetch-logger"; +import { recordNetworkRequest } from "./network-log"; + +const mockedRecord = vi.mocked(recordNetworkRequest); + +function fakeResponse( + status = 200, + headers: Record = { "content-length": "1834" }, +): Response { + return new Response(null, { status, headers }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createNetworkLoggingFetch", () => { + it("records a successful request with status, duration and bytes", async () => { + const original = vi.fn(async () => fakeResponse()); + const wrapped = createNetworkLoggingFetch( + original as unknown as typeof fetch, + ); + + const response = await wrapped("https://us.posthog.com/api/", { + method: "post", + }); + + expect(response.status).toBe(200); + expect(original).toHaveBeenCalledWith("https://us.posthog.com/api/", { + method: "post", + }); + expect(mockedRecord).toHaveBeenCalledWith({ + origin: "main", + method: "POST", + url: "https://us.posthog.com/api/", + status: 200, + durationMs: expect.any(Number), + bytes: 1834, + }); + }); + + it.each([ + ["string", "https://example.com/a", "https://example.com/a", "GET"], + ["URL", new URL("https://example.com/b"), "https://example.com/b", "GET"], + [ + "Request", + new Request("https://example.com/c", { method: "PUT" }), + "https://example.com/c", + "PUT", + ], + ])( + "extracts url and method from %s input", + async (_kind, input, url, method) => { + const original = vi.fn(async () => fakeResponse()); + const wrapped = createNetworkLoggingFetch( + original as unknown as typeof fetch, + ); + + await wrapped(input); + + expect(mockedRecord).toHaveBeenCalledWith( + expect.objectContaining({ url, method }), + ); + }, + ); + + it("records null bytes when content-length is missing", async () => { + const original = vi.fn(async () => fakeResponse(204, {})); + const wrapped = createNetworkLoggingFetch( + original as unknown as typeof fetch, + ); + + await wrapped("https://example.com/"); + + expect(mockedRecord).toHaveBeenCalledWith( + expect.objectContaining({ status: 204, bytes: null }), + ); + }); + + it("records and rethrows async rejections", async () => { + const original = vi.fn(async () => { + throw new TypeError("fetch failed"); + }); + const wrapped = createNetworkLoggingFetch( + original as unknown as typeof fetch, + ); + + await expect(wrapped("https://example.com/")).rejects.toThrow( + "fetch failed", + ); + expect(mockedRecord).toHaveBeenCalledWith( + expect.objectContaining({ + status: null, + bytes: null, + error: "TypeError: fetch failed", + }), + ); + }); + + it("records and rethrows synchronous throws", async () => { + const original = vi.fn(() => { + throw new Error("boom"); + }); + const wrapped = createNetworkLoggingFetch( + original as unknown as typeof fetch, + ); + + await expect(wrapped("https://example.com/")).rejects.toThrow("boom"); + expect(mockedRecord).toHaveBeenCalledWith( + expect.objectContaining({ error: "Error: boom" }), + ); + }); + + it("forwards preconnect from the original fetch", () => { + const preconnect = vi.fn(); + const original = Object.assign( + vi.fn(async () => fakeResponse()), + { + preconnect, + }, + ); + const wrapped = createNetworkLoggingFetch( + original as unknown as typeof fetch, + ); + + (wrapped as unknown as { preconnect: (origin: string) => void }).preconnect( + "https://example.com", + ); + + expect(preconnect).toHaveBeenCalledWith("https://example.com"); + }); +}); + +describe("installMainFetchLogging", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("wraps globalThis.fetch once and stays idempotent", async () => { + const original = vi.fn(async () => fakeResponse()); + vi.stubGlobal("fetch", original); + + vi.resetModules(); + const { installMainFetchLogging } = await import("./network-fetch-logger"); + + installMainFetchLogging(); + const wrappedOnce = globalThis.fetch; + expect(wrappedOnce).not.toBe(original); + + installMainFetchLogging(); + expect(globalThis.fetch).toBe(wrappedOnce); + + await globalThis.fetch("https://example.com/"); + expect(original).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/code/src/main/utils/network-fetch-logger.ts b/apps/code/src/main/utils/network-fetch-logger.ts new file mode 100644 index 0000000000..8eab170bf0 --- /dev/null +++ b/apps/code/src/main/utils/network-fetch-logger.ts @@ -0,0 +1,69 @@ +import { parseContentLength, recordNetworkRequest } from "./network-log"; + +export function createNetworkLoggingFetch( + original: typeof fetch, +): typeof fetch { + const wrapped = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const start = performance.now(); + + try { + const response = await original(input, init); + recordNetworkRequest({ + origin: "main", + method, + url, + status: response.status, + durationMs: performance.now() - start, + bytes: parseContentLength(response.headers.get("content-length")), + }); + return response; + } catch (error) { + recordNetworkRequest({ + origin: "main", + method, + url, + status: null, + durationMs: performance.now() - start, + bytes: null, + error: + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), + }); + throw error; + } + }; + + const preconnect = ( + original as unknown as { + preconnect?: (...args: unknown[]) => unknown; + } + ).preconnect; + Object.defineProperty(wrapped, "preconnect", { + value: preconnect?.bind(original) ?? (() => undefined), + }); + + return wrapped as typeof fetch; +} + +let installed = false; + +export function installMainFetchLogging(): void { + if (installed) return; + installed = true; + const original = globalThis.fetch; + if (!original) return; + globalThis.fetch = createNetworkLoggingFetch(original); +} diff --git a/apps/code/src/main/utils/network-log.test.ts b/apps/code/src/main/utils/network-log.test.ts new file mode 100644 index 0000000000..35ced6510a --- /dev/null +++ b/apps/code/src/main/utils/network-log.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./logger", () => ({ + networkLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { networkLog } from "./logger"; +import { + formatBytes, + formatNetworkLine, + isLoopbackHost, + levelForEntry, + type NetworkLogEntry, + parseContentLength, + recordNetworkRequest, + redactUrl, + shouldLogUrl, +} from "./network-log"; + +const mockedNetworkLog = vi.mocked(networkLog); + +function entry(overrides: Partial = {}): NetworkLogEntry { + return { + origin: "main", + method: "GET", + url: "https://us.posthog.com/api/projects/", + status: 200, + durationMs: 214, + bytes: 1834, + ...overrides, + }; +} + +describe("isLoopbackHost", () => { + it.each([ + ["localhost", true], + ["LOCALHOST", true], + ["127.0.0.1", true], + ["127.1.2.3", true], + ["::1", true], + ["[::1]", true], + ["0.0.0.0", true], + ["us.posthog.com", false], + ["127posthog.com", false], + ["mylocalhost.dev", false], + ])("%s -> %s", (hostname, expected) => { + expect(isLoopbackHost(hostname)).toBe(expected); + }); +}); + +describe("shouldLogUrl", () => { + it.each([ + ["https://us.posthog.com/api/", true], + ["http://127.0.0.1:54321/trpc", false], + ["http://localhost:5173/src/main.tsx", false], + ["http://[::1]:8080/", false], + ["not a url", true], + ])("%s -> %s", (url, expected) => { + expect(shouldLogUrl(url)).toBe(expected); + }); +}); + +describe("redactUrl", () => { + it.each([ + "secret", + "token", + "access_token", + "refresh_token", + "id_token", + "code", + "signature", + "api_key", + "apikey", + "client_secret", + "password", + "session", + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + ])("redacts %s query param", (param) => { + const redacted = redactUrl(`https://example.com/path?${param}=hunter2`); + expect(redacted).not.toContain("hunter2"); + expect(redacted).toContain(`${param}=***`); + }); + + it("redacts case-insensitively", () => { + expect(redactUrl("https://s3.aws.com/log?X-Amz-Signature=abc123")).toBe( + "https://s3.aws.com/log?X-Amz-Signature=***", + ); + }); + + it("collapses repeated sensitive params into one redacted value", () => { + const redacted = redactUrl("https://example.com/?token=a&token=b"); + expect(redacted).not.toContain("=a"); + expect(redacted).not.toContain("=b"); + expect(redacted).toContain("token=***"); + }); + + it("leaves non-sensitive params untouched", () => { + expect(redactUrl("https://example.com/api?limit=50&offset=10")).toBe( + "https://example.com/api?limit=50&offset=10", + ); + }); + + it("strips the whole query when the url does not parse", () => { + expect(redactUrl("/relative/path?token=abc")).toBe( + "/relative/path?", + ); + expect(redactUrl("/relative/path")).toBe("/relative/path"); + }); +}); + +describe("parseContentLength", () => { + it.each([ + ["1834", 1834], + ["0", 0], + ["abc", null], + ["", null], + [null, null], + [undefined, null], + ])("%s -> %s", (value, expected) => { + expect(parseContentLength(value)).toBe(expected); + }); +}); + +describe("formatBytes", () => { + it.each([ + [1834, "1834B"], + [0, "0B"], + [null, "-"], + ])("%s -> %s", (bytes, expected) => { + expect(formatBytes(bytes)).toBe(expected); + }); +}); + +describe("levelForEntry", () => { + it.each([ + [200, "info"], + [204, "info"], + [301, "info"], + [399, "info"], + [400, "warn"], + [404, "warn"], + [499, "warn"], + [500, "error"], + [503, "error"], + [null, "error"], + ])("status %s -> %s", (status, expected) => { + expect(levelForEntry(entry({ status }))).toBe(expected); + }); +}); + +describe("formatNetworkLine", () => { + it("formats a successful request", () => { + expect(formatNetworkLine(entry())).toBe( + "[main] GET https://us.posthog.com/api/projects/ -> 200 214ms 1834B", + ); + }); + + it("formats a failed request with the error and no bytes", () => { + expect( + formatNetworkLine( + entry({ + origin: "renderer", + method: "post", + status: null, + error: "TypeError: fetch failed", + durationMs: 30011.4, + bytes: null, + }), + ), + ).toBe( + '[renderer] POST https://us.posthog.com/api/projects/ -> ERR "TypeError: fetch failed" 30011ms -', + ); + }); + + it("redacts sensitive query params in the line", () => { + const line = formatNetworkLine( + entry({ url: "https://s3.aws.com/log?X-Amz-Signature=abc123" }), + ); + expect(line).toContain("X-Amz-Signature=***"); + expect(line).not.toContain("abc123"); + }); +}); + +describe("recordNetworkRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("writes at the level matching the outcome", () => { + recordNetworkRequest(entry({ status: 200 })); + recordNetworkRequest(entry({ status: 404 })); + recordNetworkRequest(entry({ status: null, error: "boom" })); + + expect(mockedNetworkLog.info).toHaveBeenCalledOnce(); + expect(mockedNetworkLog.warn).toHaveBeenCalledOnce(); + expect(mockedNetworkLog.error).toHaveBeenCalledOnce(); + }); + + it("skips loopback urls", () => { + recordNetworkRequest(entry({ url: "http://127.0.0.1:54321/trpc" })); + recordNetworkRequest(entry({ url: "http://localhost:5173/main.tsx" })); + + expect(mockedNetworkLog.info).not.toHaveBeenCalled(); + }); + + it("never throws even when the logger does", () => { + mockedNetworkLog.info.mockImplementationOnce(() => { + throw new Error("disk full"); + }); + + expect(() => recordNetworkRequest(entry())).not.toThrow(); + }); +}); diff --git a/apps/code/src/main/utils/network-log.ts b/apps/code/src/main/utils/network-log.ts new file mode 100644 index 0000000000..816100cf20 --- /dev/null +++ b/apps/code/src/main/utils/network-log.ts @@ -0,0 +1,98 @@ +import { networkLog } from "./logger"; + +export interface NetworkLogEntry { + origin: "main" | "renderer"; + method: string; + url: string; + status: number | null; + durationMs: number; + bytes: number | null; + error?: string; +} + +const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1", "[::1]", "0.0.0.0"]); + +export function isLoopbackHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return LOOPBACK_HOSTNAMES.has(host) || host.startsWith("127."); +} + +export function shouldLogUrl(url: string): boolean { + try { + return !isLoopbackHost(new URL(url).hostname); + } catch { + return true; + } +} + +const SENSITIVE_QUERY_PARAMS = new Set([ + "secret", + "token", + "access_token", + "refresh_token", + "id_token", + "code", + "signature", + "api_key", + "apikey", + "client_secret", + "password", + "session", + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", +]); + +export function redactUrl(rawUrl: string): string { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + const [base] = rawUrl.split("?"); + return rawUrl.includes("?") ? `${base}?` : rawUrl; + } + + for (const key of new Set(parsed.searchParams.keys())) { + if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) { + parsed.searchParams.set(key, "***"); + } + } + return parsed.toString(); +} + +export function parseContentLength( + value: string | null | undefined, +): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +export function formatBytes(bytes: number | null): string { + return bytes === null ? "-" : `${bytes}B`; +} + +export function levelForEntry( + entry: NetworkLogEntry, +): "info" | "warn" | "error" { + if (entry.status === null || entry.status >= 500) return "error"; + if (entry.status >= 400) return "warn"; + return "info"; +} + +export function formatNetworkLine(entry: NetworkLogEntry): string { + const outcome = + entry.status !== null + ? String(entry.status) + : `ERR "${entry.error ?? "unknown error"}"`; + return `[${entry.origin}] ${entry.method.toUpperCase()} ${redactUrl(entry.url)} -> ${outcome} ${Math.round(entry.durationMs)}ms ${formatBytes(entry.bytes)}`; +} + +export function recordNetworkRequest(entry: NetworkLogEntry): void { + try { + if (!shouldLogUrl(entry.url)) return; + networkLog[levelForEntry(entry)](formatNetworkLine(entry)); + } catch { + // Logging must never break the request it observed + } +} diff --git a/apps/code/src/main/utils/network-webrequest-logger.test.ts b/apps/code/src/main/utils/network-webrequest-logger.test.ts new file mode 100644 index 0000000000..5b6bd4cf79 --- /dev/null +++ b/apps/code/src/main/utils/network-webrequest-logger.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./logger", () => ({ + networkLog: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("./network-log", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordNetworkRequest: vi.fn(), + }; +}); + +import type { DevNetworkService } from "../services/dev-network/service"; +import type { ObservableWebRequest } from "./network-webrequest-logger"; +import { contentLengthFromHeaders } from "./network-webrequest-logger"; + +type Listeners = { + onBeforeRequest: Parameters[1]; + onCompleted: Parameters[1]; + onErrorOccurred: Parameters[1]; +}; + +function fakeWebRequest(): { webRequest: ObservableWebRequest } & { + listeners: Listeners; +} { + const listeners = {} as Listeners; + return { + listeners, + webRequest: { + onBeforeRequest: (_filter, listener) => { + listeners.onBeforeRequest = listener; + }, + onCompleted: (_filter, listener) => { + listeners.onCompleted = listener; + }, + onErrorOccurred: (_filter, listener) => { + listeners.onErrorOccurred = listener; + }, + }, + }; +} + +function fakeDevNetwork() { + return { recordExternal: vi.fn() } as unknown as DevNetworkService & { + recordExternal: ReturnType; + }; +} + +async function installFresh() { + vi.resetModules(); + const { installRendererNetworkLogging } = await import( + "./network-webrequest-logger" + ); + const { recordNetworkRequest } = await import("./network-log"); + const { listeners, webRequest } = fakeWebRequest(); + const devNetwork = fakeDevNetwork(); + installRendererNetworkLogging(webRequest, devNetwork); + return { listeners, devNetwork, record: vi.mocked(recordNetworkRequest) }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("contentLengthFromHeaders", () => { + it.each([ + [{ "Content-Length": ["1834"] }, 1834], + [{ "content-length": ["42"] }, 42], + [{ "CONTENT-LENGTH": ["7"] }, 7], + [{ "Content-Type": ["application/json"] }, null], + [{ "Content-Length": [] as string[] }, null], + [{ "Content-Length": ["abc"] }, null], + [{}, null], + [undefined, null], + ])("%o -> %s", (headers, expected) => { + expect(contentLengthFromHeaders(headers)).toBe(expected); + }); +}); + +describe("installRendererNetworkLogging", () => { + it("always invokes the onBeforeRequest callback", async () => { + const { listeners } = await installFresh(); + const callback = vi.fn(); + + listeners.onBeforeRequest( + { id: 1, method: "GET", url: "https://example.com/" }, + callback, + ); + + expect(callback).toHaveBeenCalledWith({}); + }); + + it("records a completed request and mirrors it to the dev toolbar", async () => { + const { listeners, devNetwork, record } = await installFresh(); + + listeners.onBeforeRequest( + { id: 7, method: "GET", url: "https://us.posthog.com/api/" }, + vi.fn(), + ); + listeners.onCompleted({ + id: 7, + method: "GET", + url: "https://us.posthog.com/api/", + statusCode: 200, + responseHeaders: { "Content-Length": ["1834"] }, + }); + + expect(record).toHaveBeenCalledWith({ + origin: "renderer", + method: "GET", + url: "https://us.posthog.com/api/", + status: 200, + durationMs: expect.any(Number), + bytes: 1834, + }); + expect(devNetwork.recordExternal).toHaveBeenCalledWith( + expect.objectContaining({ + origin: "renderer", + status: 200, + ok: true, + bytes: 1834, + }), + ); + }); + + it("records a failed request with the chromium error string", async () => { + const { listeners, devNetwork, record } = await installFresh(); + + listeners.onBeforeRequest( + { id: 9, method: "POST", url: "https://example.com/x" }, + vi.fn(), + ); + listeners.onErrorOccurred({ + id: 9, + method: "POST", + url: "https://example.com/x", + error: "net::ERR_CONNECTION_RESET", + }); + + expect(record).toHaveBeenCalledWith({ + origin: "renderer", + method: "POST", + url: "https://example.com/x", + status: null, + durationMs: expect.any(Number), + bytes: null, + error: "net::ERR_CONNECTION_RESET", + }); + expect(devNetwork.recordExternal).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: "net::ERR_CONNECTION_RESET", + }), + ); + }); + + it("marks non-2xx completions as not ok", async () => { + const { listeners, devNetwork } = await installFresh(); + + listeners.onCompleted({ + id: 3, + method: "GET", + url: "https://example.com/missing", + statusCode: 404, + }); + + expect(devNetwork.recordExternal).toHaveBeenCalledWith( + expect.objectContaining({ status: 404, ok: false, bytes: null }), + ); + }); + + it("still records completions whose start entry was evicted", async () => { + const { listeners, record } = await installFresh(); + + listeners.onCompleted({ + id: 999, + method: "GET", + url: "https://example.com/", + statusCode: 200, + }); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ status: 200, durationMs: 0 }), + ); + }); +}); diff --git a/apps/code/src/main/utils/network-webrequest-logger.ts b/apps/code/src/main/utils/network-webrequest-logger.ts new file mode 100644 index 0000000000..78ba2d1172 --- /dev/null +++ b/apps/code/src/main/utils/network-webrequest-logger.ts @@ -0,0 +1,138 @@ +import type { DevNetworkService } from "../services/dev-network/service"; +import { parseContentLength, recordNetworkRequest } from "./network-log"; + +interface WebRequestFilter { + urls: string[]; +} + +interface RequestDetails { + id: number; + method: string; + url: string; +} + +interface CompletedDetails extends RequestDetails { + statusCode: number; + responseHeaders?: Record; +} + +interface FailedDetails extends RequestDetails { + error: string; +} + +export interface ObservableWebRequest { + onBeforeRequest( + filter: WebRequestFilter, + listener: ( + details: RequestDetails, + callback: (response: { cancel?: boolean }) => void, + ) => void, + ): void; + onCompleted( + filter: WebRequestFilter, + listener: (details: CompletedDetails) => void, + ): void; + onErrorOccurred( + filter: WebRequestFilter, + listener: (details: FailedDetails) => void, + ): void; +} + +interface PendingRequest { + startedAt: number; +} + +const MAX_PENDING_REQUESTS = 2000; +const pending = new Map(); + +function trackPending(id: number): void { + if (pending.size >= MAX_PENDING_REQUESTS) { + const oldest = pending.keys().next().value; + if (oldest !== undefined) pending.delete(oldest); + } + pending.set(id, { startedAt: Date.now() }); +} + +function takeDuration(id: number): number { + const start = pending.get(id); + pending.delete(id); + return start ? Date.now() - start.startedAt : 0; +} + +export function contentLengthFromHeaders( + headers?: Record, +): number | null { + if (!headers) return null; + const header = Object.entries(headers).find( + ([name]) => name.toLowerCase() === "content-length", + ); + return parseContentLength(header?.[1]?.[0]); +} + +let installed = false; + +export function installRendererNetworkLogging( + webRequest: ObservableWebRequest, + devNetwork: DevNetworkService, +): void { + if (installed) return; + installed = true; + + const filter: WebRequestFilter = { urls: ["http://*/*", "https://*/*"] }; + + webRequest.onBeforeRequest(filter, (details, callback) => { + // Chromium stalls the request forever if the callback never runs. + try { + trackPending(details.id); + } finally { + callback({}); + } + }); + + webRequest.onCompleted(filter, (details) => { + const durationMs = takeDuration(details.id); + const bytes = contentLengthFromHeaders(details.responseHeaders); + recordNetworkRequest({ + origin: "renderer", + method: details.method, + url: details.url, + status: details.statusCode, + durationMs, + bytes, + }); + devNetwork.recordExternal({ + origin: "renderer", + method: details.method, + url: details.url, + status: details.statusCode, + ok: details.statusCode >= 200 && details.statusCode < 300, + durationMs, + startedAt: Date.now() - durationMs, + bytes, + }); + }); + + webRequest.onErrorOccurred(filter, (details) => { + const durationMs = takeDuration(details.id); + recordNetworkRequest({ + origin: "renderer", + method: details.method, + url: details.url, + status: null, + durationMs, + bytes: null, + error: details.error, + }); + devNetwork.recordExternal({ + origin: "renderer", + method: details.method, + url: details.url, + status: null, + ok: false, + durationMs, + startedAt: Date.now() - durationMs, + bytes: null, + error: details.error, + }); + }); +}