Skip to content

Commit 9346174

Browse files
lollipop-onlclaude
andauthored
feat: add HTTP request logger using undici Dispatcher (#79)
* chore: add undici as dev dependency to backlog-utils Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add HTTP logging interceptor module for undici Create a logging interceptor using undici's Dispatcher.compose that logs HTTP request start/end via consola.debug, with timing information. Includes undici dependency and comprehensive tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: install HTTP logger at CLI startup for debug logging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: require Node.js >=20.18 for undici Dispatcher.compose support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use undici v7 handler API to avoid private field errors with Proxy Replace Proxy-based handler wrapping with plain delegate object using the v7 dispatch handler API (onResponseStart/onResponseError instead of onHeaders/onError). This avoids TypeError when undici's WrapHandler tries to access private #handler field through a Proxy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: mask apiKey query parameter in HTTP debug logs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: improve HTTP logger output format - Show API host once on first request instead of repeating in every line - Remove [backlog] prefix for cleaner output - Decode percent-encoded query parameters for readability - Show path only (without origin) in per-request logs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: fix pnpm-lock.yaml after undici moved to dependencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update minimum Node.js version to 20.18 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: enable debug log level for dev script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add cross-env for Windows-compatible dev script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace cross-env with Node.js built-in --env-file flag Use `tsx --env-file=.env.development` instead of `cross-env` for cross-platform dev script. Also remove duplicate undici entry from backlog-utils devDependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 45e886b commit 9346174

11 files changed

Lines changed: 245 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ web_modules/
6969
.env
7070
.env.*
7171
!.env.example
72+
!.env.development
7273

7374
# parcel-bundler cache (https://parceljs.org/)
7475
.cache

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![CodeQL](https://img.shields.io/github/actions/workflow/status/nulab/bee/codeql.yml?logo=github&label=CodeQL)](https://github.com/nulab/bee/actions/workflows/codeql.yml)
55
[![npm version](https://img.shields.io/npm/v/@nulab/bee?logo=npm)](https://www.npmjs.com/package/@nulab/bee)
66
[![npm last publish](https://img.shields.io/npm/last-update/@nulab/bee?logo=npm&label=last%20publish)](https://www.npmjs.com/package/@nulab/bee)
7-
[![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen?logo=nodedotjs)](https://nodejs.org/)
7+
[![node](https://img.shields.io/badge/node-%3E%3D20.18-brightgreen?logo=nodedotjs)](https://nodejs.org/)
88
[![license](https://img.shields.io/github/license/nulab/bee?logo=opensourceinitiative)](LICENSE)
99
[![docs](https://img.shields.io/badge/docs-Starlight-purple?logo=astro&logoColor=white)](https://nulab.github.io/bee)
1010

apps/cli/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CONSOLA_LEVEL=4

apps/cli/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
},
3333
"scripts": {
3434
"build": "unbuild",
35-
"dev": "tsx src/index.ts",
35+
"dev": "tsx --env-file=.env.development src/index.ts",
3636
"test": "vitest run",
3737
"typecheck": "tsc --noEmit"
3838
},
@@ -55,6 +55,6 @@
5555
"vitest": "^4.0.18"
5656
},
5757
"engines": {
58-
"node": ">=20"
58+
"node": ">=20.18"
5959
}
6060
}

apps/cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import consola from "consola";
2+
import { installHttpLogger } from "@repo/backlog-utils";
23
import { BeeCommand } from "./lib/bee-command";
34
import { handleError } from "./lib/error";
45
import pkg from "../package.json" with { type: "json" };
56

67
consola.options.formatOptions.date = false;
8+
installHttpLogger();
79

810
const program = new BeeCommand("bee").version(pkg.version).description(pkg.description ?? "");
911

apps/docs/src/content/docs/getting-started.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: はじめに
33
description: |-
4-
bee CLI のインストールから Backlog への初回ログイン、課題の一覧・作成まで。Node.js 20 以上が必要です。
4+
bee CLI のインストールから Backlog への初回ログイン、課題の一覧・作成まで。Node.js 20.18 以上が必要です。
55
---
66

77
import { Tabs, TabItem, Steps, LinkCard } from "@astrojs/starlight/components";
@@ -10,7 +10,7 @@ bee は、[Backlog](https://backlog.com/) をターミナルから操作する
1010

1111
## 前提条件
1212

13-
- **Node.js 20** 以上
13+
- **Node.js 20.18** 以上
1414

1515
Node.js のバージョンを確認するには、以下のコマンドを実行します。
1616

packages/backlog-utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"backlog-js": "^0.16.0",
1515
"consola": "^3.4.2",
1616
"open": "^11.0.0",
17+
"undici": "^7.22.0",
1718
"valibot": "^1.2.0"
1819
},
1920
"devDependencies": {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import consola from "consola";
3+
4+
vi.mock("consola", () => import("@repo/test-utils/mock-consola"));
5+
6+
vi.mock("undici", () => {
7+
const MockAgent = vi.fn();
8+
MockAgent.prototype.compose = vi.fn(function (this: unknown) {
9+
return this;
10+
});
11+
return {
12+
Agent: MockAgent,
13+
setGlobalDispatcher: vi.fn(),
14+
};
15+
});
16+
17+
const newHandler = () => ({
18+
onRequestStart: vi.fn(),
19+
onResponseStart: vi.fn(),
20+
onResponseData: vi.fn(),
21+
onResponseEnd: vi.fn(),
22+
onResponseError: vi.fn(),
23+
onRequestUpgrade: vi.fn(),
24+
});
25+
26+
describe("createLoggingInterceptor", () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
vi.restoreAllMocks();
30+
});
31+
32+
it("returns a function", async () => {
33+
const { createLoggingInterceptor } = await import("./http-logger");
34+
const interceptor = createLoggingInterceptor();
35+
expect(typeof interceptor).toBe("function");
36+
});
37+
38+
it("logs API host on the first request only", async () => {
39+
const { createLoggingInterceptor } = await import("./http-logger");
40+
const interceptor = createLoggingInterceptor();
41+
42+
const mockDispatch = vi.fn().mockReturnValue(true);
43+
const wrappedDispatch = interceptor(mockDispatch);
44+
45+
const opts1 = { method: "GET", origin: "https://example.backlog.com", path: "/api/v2/space" };
46+
const opts2 = { method: "GET", origin: "https://example.backlog.com", path: "/api/v2/users" };
47+
48+
wrappedDispatch(opts1 as never, newHandler() as never);
49+
wrappedDispatch(opts2 as never, newHandler() as never);
50+
51+
const hostCalls = vi
52+
.mocked(consola.debug)
53+
.mock.calls.filter((call) => typeof call[0] === "string" && call[0].startsWith("API host:"));
54+
expect(hostCalls).toHaveLength(1);
55+
expect(hostCalls[0]![0]).toBe("API host: https://example.backlog.com");
56+
});
57+
58+
it("logs request start with method and path", async () => {
59+
const { createLoggingInterceptor } = await import("./http-logger");
60+
const interceptor = createLoggingInterceptor();
61+
62+
const mockDispatch = vi.fn().mockReturnValue(true);
63+
const wrappedDispatch = interceptor(mockDispatch);
64+
65+
const opts = { method: "GET", origin: "https://example.backlog.com", path: "/api/v2/issues" };
66+
wrappedDispatch(opts as never, newHandler() as never);
67+
68+
expect(consola.debug).toHaveBeenCalledWith("→ GET /api/v2/issues");
69+
});
70+
71+
it("logs response with status code and duration", async () => {
72+
const { createLoggingInterceptor } = await import("./http-logger");
73+
const interceptor = createLoggingInterceptor();
74+
75+
const mockDispatch = vi.fn().mockImplementation((_opts, wrappedHandler) => {
76+
wrappedHandler.onResponseStart({}, 200, {}, "OK");
77+
return true;
78+
});
79+
80+
const wrappedDispatch = interceptor(mockDispatch);
81+
const opts = { method: "POST", origin: "https://example.backlog.com", path: "/api/v2/issues" };
82+
const handler = newHandler();
83+
84+
wrappedDispatch(opts as never, handler as never);
85+
86+
expect(consola.debug).toHaveBeenCalledWith(
87+
expect.stringMatching(/ 200 POST \/api\/v2\/issues \(\d+ms\)/),
88+
);
89+
expect(handler.onResponseStart).toHaveBeenCalledWith({}, 200, {}, "OK");
90+
});
91+
92+
it("logs error with duration", async () => {
93+
const { createLoggingInterceptor } = await import("./http-logger");
94+
const interceptor = createLoggingInterceptor();
95+
96+
const testError = new Error("connection refused");
97+
const mockDispatch = vi.fn().mockImplementation((_opts, wrappedHandler) => {
98+
wrappedHandler.onResponseError({}, testError);
99+
return true;
100+
});
101+
102+
const wrappedDispatch = interceptor(mockDispatch);
103+
const opts = {
104+
method: "DELETE",
105+
origin: "https://example.backlog.com",
106+
path: "/api/v2/issues/1",
107+
};
108+
const handler = newHandler();
109+
110+
wrappedDispatch(opts as never, handler as never);
111+
112+
expect(consola.debug).toHaveBeenCalledWith(
113+
expect.stringMatching(/ DELETE \/api\/v2\/issues\/1 \(\d+ms\)/),
114+
);
115+
expect(handler.onResponseError).toHaveBeenCalledWith({}, testError);
116+
});
117+
118+
it("masks apiKey and decodes percent-encoded query parameters", async () => {
119+
const { createLoggingInterceptor } = await import("./http-logger");
120+
const interceptor = createLoggingInterceptor();
121+
122+
const mockDispatch = vi.fn().mockReturnValue(true);
123+
const wrappedDispatch = interceptor(mockDispatch);
124+
125+
const opts = {
126+
method: "GET",
127+
origin: "https://example.backlog.com",
128+
path: "/api/v2/issues?apiKey=secret123&projectId%5B%5D=1",
129+
};
130+
wrappedDispatch(opts as never, newHandler() as never);
131+
132+
expect(consola.debug).toHaveBeenCalledWith("→ GET /api/v2/issues?apiKey=***&projectId[]=1");
133+
});
134+
});
135+
136+
describe("installHttpLogger", () => {
137+
beforeEach(() => {
138+
vi.clearAllMocks();
139+
});
140+
141+
it("calls setGlobalDispatcher with a composed agent", async () => {
142+
const { Agent, setGlobalDispatcher } = await import("undici");
143+
const { installHttpLogger } = await import("./http-logger");
144+
145+
installHttpLogger();
146+
147+
expect(Agent).toHaveBeenCalled();
148+
expect(vi.mocked(Agent).prototype.compose).toHaveBeenCalled();
149+
expect(setGlobalDispatcher).toHaveBeenCalled();
150+
});
151+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import consola from "consola";
2+
import { Agent, type Dispatcher, setGlobalDispatcher } from "undici";
3+
4+
type Interceptor = (dispatch: Dispatcher["dispatch"]) => Dispatcher["dispatch"];
5+
6+
/** Masks sensitive query parameters (e.g. apiKey) from a URL path. */
7+
const maskSensitiveParams = (url: string): string =>
8+
url.replaceAll(/([?&])(apiKey)=[^&]*/gi, "$1$2=***");
9+
10+
/** Formats a URL path for logging: masks secrets and decodes percent-encoded characters. */
11+
const formatPath = (path: string): string => {
12+
try {
13+
return decodeURIComponent(maskSensitiveParams(path));
14+
} catch {
15+
return maskSensitiveParams(path);
16+
}
17+
};
18+
19+
/**
20+
* Creates an undici interceptor that logs HTTP request start/end via consola.debug.
21+
*
22+
* Uses the undici v7 "new" handler API (onRequestStart, onResponseStart, etc.)
23+
* with a plain delegate object to avoid Proxy issues with private class fields.
24+
*/
25+
const createLoggingInterceptor = (): Interceptor => {
26+
let originLogged = false;
27+
28+
return (dispatch) => (opts, handler) => {
29+
const origin = opts.origin?.toString() ?? "";
30+
if (!originLogged && origin) {
31+
consola.debug(`API host: ${origin}`);
32+
originLogged = true;
33+
}
34+
35+
const method = opts.method ?? "UNKNOWN";
36+
const path = formatPath(opts.path ?? "");
37+
const start = performance.now();
38+
39+
consola.debug(`→ ${method} ${path}`);
40+
41+
return dispatch(opts, {
42+
onRequestStart(controller, context) {
43+
handler.onRequestStart?.(controller, context);
44+
},
45+
onRequestUpgrade(controller, statusCode, headers, socket) {
46+
handler.onRequestUpgrade?.(controller, statusCode, headers, socket);
47+
},
48+
onResponseStart(controller, statusCode, headers, statusMessage) {
49+
const elapsed = Math.round(performance.now() - start);
50+
consola.debug(`← ${statusCode} ${method} ${path} (${elapsed}ms)`);
51+
handler.onResponseStart?.(controller, statusCode, headers, statusMessage);
52+
},
53+
onResponseData(controller, data) {
54+
handler.onResponseData?.(controller, data);
55+
},
56+
onResponseEnd(controller, trailers) {
57+
handler.onResponseEnd?.(controller, trailers);
58+
},
59+
onResponseError(controller, err) {
60+
const elapsed = Math.round(performance.now() - start);
61+
consola.debug(`✗ ${method} ${path} (${elapsed}ms)`);
62+
handler.onResponseError?.(controller, err);
63+
},
64+
});
65+
};
66+
};
67+
68+
/** Installs the logging interceptor as the global fetch dispatcher. */
69+
const installHttpLogger = (): void => {
70+
const agent = new Agent().compose(createLoggingInterceptor());
71+
setGlobalDispatcher(agent);
72+
};
73+
74+
export { createLoggingInterceptor, installHttpLogger };

packages/backlog-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@ export {
4646
getRepoRelativePath,
4747
parseBacklogRemoteUrl,
4848
} from "./git-context";
49+
export { installHttpLogger } from "./http-logger";

0 commit comments

Comments
 (0)