@remit/web-client 0.0.60 → 0.0.61
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/components/ui/AppVersion.tsx +16 -9
- package/src/lib/app-info.ts +13 -1
- package/src/lib/bug-report.test.ts +144 -1
- package/src/lib/bug-report.ts +80 -4
- package/src/lib/network-error.ts +17 -1
- package/src/lib/request-breadcrumbs.test.ts +162 -0
- package/src/lib/request-breadcrumbs.ts +138 -0
- package/src/lib/route-breadcrumbs.test.ts +41 -0
- package/src/lib/route-breadcrumbs.ts +45 -0
- package/src/router.tsx +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.61",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
export interface AppVersionProps {
|
|
8
8
|
/** Override SHA for testing/Storybook. Defaults to the build-time constant. */
|
|
9
9
|
sha?: string;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Override commit URL. Defaults to the GitHub commit link, which is undefined
|
|
12
|
+
* for a local "dev" build with no real SHA — the version renders unlinked then.
|
|
13
|
+
*/
|
|
11
14
|
commitUrl?: string;
|
|
12
15
|
/** Override build time ISO string. Defaults to the build-time constant. */
|
|
13
16
|
buildTime?: string;
|
|
@@ -35,14 +38,18 @@ export function AppVersion({
|
|
|
35
38
|
return (
|
|
36
39
|
<p className="text-xs text-fg-subtle">
|
|
37
40
|
Version{" "}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
{commitUrl ? (
|
|
42
|
+
<a
|
|
43
|
+
href={commitUrl}
|
|
44
|
+
target="_blank"
|
|
45
|
+
rel="noopener noreferrer"
|
|
46
|
+
className="font-mono hover:text-fg-muted hover:underline"
|
|
47
|
+
>
|
|
48
|
+
{sha}
|
|
49
|
+
</a>
|
|
50
|
+
) : (
|
|
51
|
+
<span className="font-mono">{sha}</span>
|
|
52
|
+
)}
|
|
46
53
|
{" · "}
|
|
47
54
|
<span>Built {formatBuildTime(buildTime)}</span>
|
|
48
55
|
</p>
|
package/src/lib/app-info.ts
CHANGED
|
@@ -10,6 +10,18 @@ export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
|
|
|
10
10
|
/** First 7 characters of the SHA, matching git's default short form. */
|
|
11
11
|
export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* True when `APP_SHA` is a real git commit — a build that was given the commit
|
|
15
|
+
* (via `GITHUB_SHA` at build time). A local build with no git resolves to the
|
|
16
|
+
* literal "dev", which is not a commit and must not be linked (a
|
|
17
|
+
* `/commit/dev` URL is a dead link).
|
|
18
|
+
*/
|
|
19
|
+
export const APP_SHA_IS_COMMIT = /^[0-9a-f]{40}$/.test(APP_SHA);
|
|
20
|
+
|
|
21
|
+
/** Commit URL for the build, or undefined when the build has no real SHA. */
|
|
22
|
+
export const GITHUB_COMMIT_URL: string | undefined = APP_SHA_IS_COMMIT
|
|
23
|
+
? `https://github.com/remit-mail/reader/commit/${APP_SHA}`
|
|
24
|
+
: undefined;
|
|
25
|
+
|
|
14
26
|
export const GITHUB_NEW_ISSUE_URL =
|
|
15
27
|
"https://github.com/remit-mail/reader/issues/new";
|
|
@@ -9,10 +9,13 @@ import {
|
|
|
9
9
|
} from "@remit/ui";
|
|
10
10
|
import type { BugReportContext } from "./bug-report";
|
|
11
11
|
import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
|
|
12
|
+
import type { RequestBreadcrumb } from "./request-breadcrumbs";
|
|
13
|
+
import type { RouteBreadcrumb } from "./route-breadcrumbs";
|
|
12
14
|
|
|
13
15
|
const baseCtx: BugReportContext = {
|
|
14
|
-
appSha: "abcdef1234567890abcdef1234567890abcdef12",
|
|
15
16
|
appShortSha: "abcdef1",
|
|
17
|
+
appCommitUrl:
|
|
18
|
+
"https://github.com/remit-mail/reader/commit/abcdef1234567890abcdef1234567890abcdef12",
|
|
16
19
|
appBuildTime: "2024-01-15T10:30:00.000Z",
|
|
17
20
|
userAgent: "Mozilla/5.0 (Test Browser)",
|
|
18
21
|
viewport: "1440×900",
|
|
@@ -20,6 +23,8 @@ const baseCtx: BugReportContext = {
|
|
|
20
23
|
timezone: "Europe/Amsterdam",
|
|
21
24
|
href: "https://app.example.com/mail/inbox?q=test",
|
|
22
25
|
recentErrors: [],
|
|
26
|
+
requests: [],
|
|
27
|
+
routes: [],
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
/**
|
|
@@ -145,6 +150,144 @@ describe("buildGitHubIssueUrl", () => {
|
|
|
145
150
|
const url = buildGitHubIssueUrl(ctx);
|
|
146
151
|
assert.doesNotThrow(() => new URL(url), "URL must be valid");
|
|
147
152
|
});
|
|
153
|
+
|
|
154
|
+
it("links the short SHA to the build commit", () => {
|
|
155
|
+
const decoded = decode(buildGitHubIssueUrl(baseCtx));
|
|
156
|
+
assert.ok(
|
|
157
|
+
decoded.includes(
|
|
158
|
+
"[`abcdef1`](https://github.com/remit-mail/reader/commit/abcdef1234567890abcdef1234567890abcdef12)",
|
|
159
|
+
),
|
|
160
|
+
"Expected the version to link the commit",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("renders the version unlinked when the build has no real SHA", () => {
|
|
165
|
+
const decoded = decode(
|
|
166
|
+
buildGitHubIssueUrl({
|
|
167
|
+
...baseCtx,
|
|
168
|
+
appShortSha: "dev",
|
|
169
|
+
appCommitUrl: undefined,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
assert.ok(
|
|
173
|
+
decoded.includes("**Version**: `dev`"),
|
|
174
|
+
"Expected an unlinked dev version",
|
|
175
|
+
);
|
|
176
|
+
assert.ok(
|
|
177
|
+
!decoded.includes("/commit/dev"),
|
|
178
|
+
"A dev build must not produce a dead commit link",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("buildGitHubIssueUrl — request breadcrumbs", () => {
|
|
184
|
+
const requests: RequestBreadcrumb[] = [
|
|
185
|
+
{
|
|
186
|
+
method: "GET",
|
|
187
|
+
path: "/api/messages/abc-123",
|
|
188
|
+
status: 200,
|
|
189
|
+
durationMs: 42,
|
|
190
|
+
correlationId: "corr-ok",
|
|
191
|
+
timestamp: "2024-06-12T08:00:01.000Z",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
method: "POST",
|
|
195
|
+
path: "/api/messages/abc-123/move",
|
|
196
|
+
status: 500,
|
|
197
|
+
durationMs: 130,
|
|
198
|
+
correlationId: "corr-fail",
|
|
199
|
+
timestamp: "2024-06-12T08:00:02.000Z",
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
it("renders a table of recent API requests", () => {
|
|
204
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, requests }));
|
|
205
|
+
assert.ok(decoded.includes("## Recent API requests"));
|
|
206
|
+
assert.ok(decoded.includes("/api/messages/abc-123/move"));
|
|
207
|
+
assert.ok(
|
|
208
|
+
decoded.includes("corr-fail"),
|
|
209
|
+
"Expected the correlation id in the table",
|
|
210
|
+
);
|
|
211
|
+
assert.ok(decoded.includes("| GET |"));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("shows (none) when no requests were captured", () => {
|
|
215
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, requests: [] }));
|
|
216
|
+
assert.ok(decoded.includes("## Recent API requests"));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("calls out the failing request in its own section", () => {
|
|
220
|
+
const failingRequest = requests[1];
|
|
221
|
+
const decoded = decode(
|
|
222
|
+
buildGitHubIssueUrl({ ...baseCtx, requests, failingRequest }),
|
|
223
|
+
);
|
|
224
|
+
assert.ok(decoded.includes("## Failing request"));
|
|
225
|
+
assert.ok(decoded.includes("**Endpoint**: `/api/messages/abc-123/move`"));
|
|
226
|
+
assert.ok(decoded.includes("**Method**: POST"));
|
|
227
|
+
assert.ok(decoded.includes("**Status**: 500"));
|
|
228
|
+
assert.ok(decoded.includes("**Correlation id**: corr-fail"));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("describes a transport failure as no response", () => {
|
|
232
|
+
const failingRequest: RequestBreadcrumb = {
|
|
233
|
+
method: "GET",
|
|
234
|
+
path: "/api/messages",
|
|
235
|
+
status: 0,
|
|
236
|
+
durationMs: 5000,
|
|
237
|
+
timestamp: "2024-06-12T08:00:03.000Z",
|
|
238
|
+
};
|
|
239
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, failingRequest }));
|
|
240
|
+
assert.ok(decoded.includes("no response (transport failure)"));
|
|
241
|
+
assert.ok(decoded.includes("**Correlation id**: (none)"));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("no breadcrumb ever carries a query string or a body", () => {
|
|
245
|
+
// A breadcrumb path is redacted at capture (request-breadcrumbs.ts), so
|
|
246
|
+
// even if the fetch layer saw a `?q=` search or a request body, the
|
|
247
|
+
// assembled requests table carries neither. Scope the check to that
|
|
248
|
+
// section — the `## URL` section legitimately keeps the full href.
|
|
249
|
+
const requestsWithQueryShaped: RequestBreadcrumb[] = [
|
|
250
|
+
{
|
|
251
|
+
method: "POST",
|
|
252
|
+
path: "/api/search",
|
|
253
|
+
status: 200,
|
|
254
|
+
durationMs: 5,
|
|
255
|
+
correlationId: "c1",
|
|
256
|
+
timestamp: "2024-06-12T08:00:01.000Z",
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
const decoded = decode(
|
|
260
|
+
buildGitHubIssueUrl({ ...baseCtx, requests: requestsWithQueryShaped }),
|
|
261
|
+
);
|
|
262
|
+
const section = decoded.slice(decoded.indexOf("## Recent API requests"));
|
|
263
|
+
assert.ok(
|
|
264
|
+
!section.includes("?"),
|
|
265
|
+
"A breadcrumb path must never carry a query string",
|
|
266
|
+
);
|
|
267
|
+
assert.ok(
|
|
268
|
+
!/"password"|"subject"|"body":/.test(section),
|
|
269
|
+
"A breadcrumb must never carry a request/response body",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("buildGitHubIssueUrl — navigation trail", () => {
|
|
275
|
+
const routes: RouteBreadcrumb[] = [
|
|
276
|
+
{ path: "/mail/inbox", timestamp: "2024-06-12T07:59:58.000Z" },
|
|
277
|
+
{ path: "/mail/message/abc-123", timestamp: "2024-06-12T08:00:00.000Z" },
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
it("renders the navigation trail so Steps to reproduce has context", () => {
|
|
281
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, routes }));
|
|
282
|
+
assert.ok(decoded.includes("## Navigation trail"));
|
|
283
|
+
assert.ok(decoded.includes("/mail/inbox"));
|
|
284
|
+
assert.ok(decoded.includes("/mail/message/abc-123"));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("still emits the Steps to reproduce template", () => {
|
|
288
|
+
const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, routes }));
|
|
289
|
+
assert.ok(decoded.includes("## Steps to reproduce"));
|
|
290
|
+
});
|
|
148
291
|
});
|
|
149
292
|
|
|
150
293
|
describe("buildGitHubIssueUrl — seeded from a caught error", () => {
|
package/src/lib/bug-report.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { QuarantineReportSections } from "@remit/ui";
|
|
2
2
|
import {
|
|
3
3
|
APP_BUILD_TIME,
|
|
4
|
-
APP_SHA,
|
|
5
4
|
APP_SHORT_SHA,
|
|
5
|
+
GITHUB_COMMIT_URL,
|
|
6
6
|
GITHUB_NEW_ISSUE_URL,
|
|
7
7
|
} from "./app-info";
|
|
8
8
|
import { getRecentErrors } from "./console-errors";
|
|
9
|
+
import {
|
|
10
|
+
getFailingRequest,
|
|
11
|
+
getRecentRequests,
|
|
12
|
+
type RequestBreadcrumb,
|
|
13
|
+
} from "./request-breadcrumbs";
|
|
14
|
+
import { getRecentRoutes, type RouteBreadcrumb } from "./route-breadcrumbs";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* A GitHub new-issue URL is capped (~8 KB). We budget below that so the stack
|
|
@@ -19,8 +25,9 @@ const TRUNCATION_MARKER =
|
|
|
19
25
|
"\n… (truncated — the copy action on this screen has the full report)";
|
|
20
26
|
|
|
21
27
|
export interface BugReportContext {
|
|
22
|
-
appSha: string;
|
|
23
28
|
appShortSha: string;
|
|
29
|
+
/** Commit URL for the build, or undefined for a local "dev" build (no link). */
|
|
30
|
+
appCommitUrl?: string;
|
|
24
31
|
appBuildTime: string;
|
|
25
32
|
userAgent: string;
|
|
26
33
|
viewport: string;
|
|
@@ -28,6 +35,12 @@ export interface BugReportContext {
|
|
|
28
35
|
timezone: string;
|
|
29
36
|
href: string;
|
|
30
37
|
recentErrors: readonly string[];
|
|
38
|
+
/** Recent API calls, metadata only — method/path/status/duration/correlation. */
|
|
39
|
+
requests: readonly RequestBreadcrumb[];
|
|
40
|
+
/** Recent in-app navigations, oldest first. */
|
|
41
|
+
routes: readonly RouteBreadcrumb[];
|
|
42
|
+
/** The request that most likely triggered the report (transport or >= 400). */
|
|
43
|
+
failingRequest?: RequestBreadcrumb;
|
|
31
44
|
/** The message of the error that triggered the report, when seeded from one. */
|
|
32
45
|
errorMessage?: string;
|
|
33
46
|
/** The error's stacktrace, when available. */
|
|
@@ -56,8 +69,8 @@ export interface BugReportSeed {
|
|
|
56
69
|
|
|
57
70
|
export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
58
71
|
return {
|
|
59
|
-
appSha: APP_SHA,
|
|
60
72
|
appShortSha: APP_SHORT_SHA,
|
|
73
|
+
appCommitUrl: GITHUB_COMMIT_URL,
|
|
61
74
|
appBuildTime: APP_BUILD_TIME,
|
|
62
75
|
userAgent: navigator.userAgent,
|
|
63
76
|
viewport: `${window.innerWidth}×${window.innerHeight}`,
|
|
@@ -65,6 +78,9 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
|
|
|
65
78
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
66
79
|
href: window.location.href,
|
|
67
80
|
recentErrors: getRecentErrors(),
|
|
81
|
+
requests: getRecentRequests(),
|
|
82
|
+
routes: getRecentRoutes(),
|
|
83
|
+
failingRequest: getFailingRequest(),
|
|
68
84
|
errorMessage: seed?.errorMessage,
|
|
69
85
|
stack: seed?.stack,
|
|
70
86
|
componentStack: seed?.componentStack,
|
|
@@ -77,6 +93,59 @@ function fencedBlock(content: string): string {
|
|
|
77
93
|
return ["```", content, "```"].join("\n");
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
/** `2024-06-12T08:00:01.234Z` → `08:00:01` — the wall-clock time, compact. */
|
|
97
|
+
function clockTime(iso: string): string {
|
|
98
|
+
const match = /T(\d{2}:\d{2}:\d{2})/.exec(iso);
|
|
99
|
+
return match ? match[1] : iso;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function versionLine(ctx: BugReportContext): string {
|
|
103
|
+
const version = ctx.appCommitUrl
|
|
104
|
+
? `[\`${ctx.appShortSha}\`](${ctx.appCommitUrl})`
|
|
105
|
+
: `\`${ctx.appShortSha}\``;
|
|
106
|
+
return `- **Version**: ${version} built ${ctx.appBuildTime}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function failingRequestSection(request: RequestBreadcrumb): string[] {
|
|
110
|
+
const status =
|
|
111
|
+
request.status === 0
|
|
112
|
+
? "no response (transport failure)"
|
|
113
|
+
: `${request.status}`;
|
|
114
|
+
return [
|
|
115
|
+
"## Failing request",
|
|
116
|
+
`- **Endpoint**: \`${request.path}\``,
|
|
117
|
+
`- **Method**: ${request.method}`,
|
|
118
|
+
`- **Status**: ${status}`,
|
|
119
|
+
`- **Correlation id**: ${request.correlationId ?? "(none)"}`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function requestsSection(requests: readonly RequestBreadcrumb[]): string[] {
|
|
125
|
+
if (requests.length === 0) {
|
|
126
|
+
return ["## Recent API requests", "(none)", ""];
|
|
127
|
+
}
|
|
128
|
+
const rows = requests.map((r) => {
|
|
129
|
+
const status = r.status === 0 ? "ERR" : `${r.status}`;
|
|
130
|
+
return `| ${clockTime(r.timestamp)} | ${r.method} | \`${r.path}\` | ${status} | ${r.durationMs}ms | ${r.correlationId ?? ""} |`;
|
|
131
|
+
});
|
|
132
|
+
return [
|
|
133
|
+
"## Recent API requests",
|
|
134
|
+
"| Time | Method | Path | Status | Duration | Correlation |",
|
|
135
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
136
|
+
...rows,
|
|
137
|
+
"",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function navigationSection(routes: readonly RouteBreadcrumb[]): string[] {
|
|
142
|
+
if (routes.length === 0) {
|
|
143
|
+
return ["## Navigation trail", "(none)", ""];
|
|
144
|
+
}
|
|
145
|
+
const rows = routes.map((r) => `- ${clockTime(r.timestamp)} \`${r.path}\``);
|
|
146
|
+
return ["## Navigation trail", ...rows, ""];
|
|
147
|
+
}
|
|
148
|
+
|
|
80
149
|
interface BodyOverrides {
|
|
81
150
|
stack?: string;
|
|
82
151
|
/** Replaces the MIME-tree section only; it is fenced after this is applied. */
|
|
@@ -94,7 +163,7 @@ function buildIssueBody(
|
|
|
94
163
|
|
|
95
164
|
const lines: string[] = [
|
|
96
165
|
"## Environment",
|
|
97
|
-
|
|
166
|
+
versionLine(ctx),
|
|
98
167
|
`- **Browser**: ${ctx.userAgent}`,
|
|
99
168
|
`- **Viewport**: ${ctx.viewport}`,
|
|
100
169
|
`- **Time**: ${ctx.timestamp} (${ctx.timezone})`,
|
|
@@ -108,6 +177,10 @@ function buildIssueBody(
|
|
|
108
177
|
lines.push("## Error", ctx.errorMessage, "");
|
|
109
178
|
}
|
|
110
179
|
|
|
180
|
+
if (ctx.failingRequest) {
|
|
181
|
+
lines.push(...failingRequestSection(ctx.failingRequest));
|
|
182
|
+
}
|
|
183
|
+
|
|
111
184
|
if (ctx.quarantine) {
|
|
112
185
|
const structure =
|
|
113
186
|
overrides?.quarantineStructure ?? ctx.quarantine.structure;
|
|
@@ -130,6 +203,9 @@ function buildIssueBody(
|
|
|
130
203
|
lines.push("## Component stack", fencedBlock(ctx.componentStack), "");
|
|
131
204
|
}
|
|
132
205
|
|
|
206
|
+
lines.push(...requestsSection(ctx.requests));
|
|
207
|
+
lines.push(...navigationSection(ctx.routes));
|
|
208
|
+
|
|
133
209
|
lines.push(
|
|
134
210
|
"## Recent console errors",
|
|
135
211
|
errorSection,
|
package/src/lib/network-error.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* boundary makes the question decidable and the browser's wording irrelevant.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { recordRequest } from "./request-breadcrumbs";
|
|
19
|
+
|
|
18
20
|
/** A request that never reached a server. Always soft — never escalated. */
|
|
19
21
|
export class NetworkError extends Error {
|
|
20
22
|
constructor(cause: unknown) {
|
|
@@ -47,12 +49,26 @@ const isDeliberateAbort = (error: unknown): boolean =>
|
|
|
47
49
|
* `fetch`, with transport failures tagged. Every app-owned request goes through
|
|
48
50
|
* this — the generated client is configured with it, and the hand-written
|
|
49
51
|
* wrapper calls it — so a `NetworkError` downstream is a fact, not an inference.
|
|
52
|
+
*
|
|
53
|
+
* It is also the one place every request is seen, so it records a metadata-only
|
|
54
|
+
* breadcrumb (method, path, status, duration, correlation id) for bug reports.
|
|
55
|
+
* A deliberate abort is not a request outcome worth reporting, so it is
|
|
56
|
+
* re-thrown before any breadcrumb is written.
|
|
50
57
|
*/
|
|
51
58
|
export const taggedFetch: typeof fetch = async (input, init) => {
|
|
59
|
+
const startedAt = performance.now();
|
|
52
60
|
try {
|
|
53
|
-
|
|
61
|
+
const response = await fetch(input, init);
|
|
62
|
+
recordRequest({
|
|
63
|
+
input,
|
|
64
|
+
init,
|
|
65
|
+
response,
|
|
66
|
+
durationMs: performance.now() - startedAt,
|
|
67
|
+
});
|
|
68
|
+
return response;
|
|
54
69
|
} catch (error) {
|
|
55
70
|
if (isDeliberateAbort(error)) throw error;
|
|
71
|
+
recordRequest({ input, init, durationMs: performance.now() - startedAt });
|
|
56
72
|
throw new NetworkError(error);
|
|
57
73
|
}
|
|
58
74
|
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
__resetRequestBreadcrumbs,
|
|
5
|
+
getFailingRequest,
|
|
6
|
+
getRecentRequests,
|
|
7
|
+
recordRequest,
|
|
8
|
+
requestPath,
|
|
9
|
+
} from "./request-breadcrumbs";
|
|
10
|
+
|
|
11
|
+
const response = (
|
|
12
|
+
status: number,
|
|
13
|
+
headers: Record<string, string> = {},
|
|
14
|
+
): Response => new Response(null, { status, headers });
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
__resetRequestBreadcrumbs();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("requestPath — redaction boundary", () => {
|
|
21
|
+
it("keeps the pathname and drops the query string", () => {
|
|
22
|
+
assert.equal(requestPath("/api/search?q=secret+query+text"), "/api/search");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("drops the query on an absolute URL too", () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
requestPath("https://app.example.com/api/messages?q=private"),
|
|
28
|
+
"/api/messages",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops a hash fragment", () => {
|
|
33
|
+
assert.equal(requestPath("/api/thing#section"), "/api/thing");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps opaque path ids (already public in the URL section)", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
requestPath("/api/messages/abc-123-def"),
|
|
39
|
+
"/api/messages/abc-123-def",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("accepts a URL instance", () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
requestPath(new URL("https://app.example.com/api/x?q=1")),
|
|
46
|
+
"/api/x",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts a Request instance", () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
requestPath(new Request("https://app.example.com/api/y?token=abc")),
|
|
53
|
+
"/api/y",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("recordRequest", () => {
|
|
59
|
+
it("captures metadata only — never the query or the body", () => {
|
|
60
|
+
recordRequest({
|
|
61
|
+
input: "/api/search?q=confidential",
|
|
62
|
+
init: { method: "POST", body: JSON.stringify({ subject: "secret" }) },
|
|
63
|
+
response: response(200),
|
|
64
|
+
durationMs: 12.6,
|
|
65
|
+
});
|
|
66
|
+
const [entry] = getRecentRequests();
|
|
67
|
+
assert.equal(entry.method, "POST");
|
|
68
|
+
assert.equal(entry.path, "/api/search");
|
|
69
|
+
assert.equal(entry.status, 200);
|
|
70
|
+
assert.equal(entry.durationMs, 13);
|
|
71
|
+
// The breadcrumb shape has no body/query field; assert nothing leaked in.
|
|
72
|
+
const serialized = JSON.stringify(entry);
|
|
73
|
+
assert.ok(
|
|
74
|
+
!serialized.includes("confidential"),
|
|
75
|
+
"query text must not be captured",
|
|
76
|
+
);
|
|
77
|
+
assert.ok(
|
|
78
|
+
!serialized.includes("secret"),
|
|
79
|
+
"request body must not be captured",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("defaults the method to GET", () => {
|
|
84
|
+
recordRequest({
|
|
85
|
+
input: "/api/thing",
|
|
86
|
+
response: response(204),
|
|
87
|
+
durationMs: 1,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(getRecentRequests()[0].method, "GET");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reads the method from a Request instance", () => {
|
|
93
|
+
recordRequest({
|
|
94
|
+
input: new Request("https://app.example.com/api/z", { method: "delete" }),
|
|
95
|
+
response: response(200),
|
|
96
|
+
durationMs: 1,
|
|
97
|
+
});
|
|
98
|
+
assert.equal(getRecentRequests()[0].method, "DELETE");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("records status 0 and no correlation id for a transport failure", () => {
|
|
102
|
+
recordRequest({ input: "/api/thing", durationMs: 5000 });
|
|
103
|
+
const [entry] = getRecentRequests();
|
|
104
|
+
assert.equal(entry.status, 0);
|
|
105
|
+
assert.equal(entry.correlationId, undefined);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reads the correlation id from the first present header", () => {
|
|
109
|
+
recordRequest({
|
|
110
|
+
input: "/api/a",
|
|
111
|
+
response: response(200, { "x-request-id": "req-42" }),
|
|
112
|
+
durationMs: 1,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(getRecentRequests()[0].correlationId, "req-42");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps only the last ten requests", () => {
|
|
118
|
+
for (let i = 0; i < 14; i++) {
|
|
119
|
+
recordRequest({
|
|
120
|
+
input: `/api/n/${i}`,
|
|
121
|
+
response: response(200),
|
|
122
|
+
durationMs: 1,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const entries = getRecentRequests();
|
|
126
|
+
assert.equal(entries.length, 10);
|
|
127
|
+
assert.equal(entries[0].path, "/api/n/4");
|
|
128
|
+
assert.equal(entries[9].path, "/api/n/13");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("getFailingRequest", () => {
|
|
133
|
+
it("returns the most recent HTTP error", () => {
|
|
134
|
+
recordRequest({ input: "/api/ok", response: response(200), durationMs: 1 });
|
|
135
|
+
recordRequest({
|
|
136
|
+
input: "/api/bad",
|
|
137
|
+
response: response(500),
|
|
138
|
+
durationMs: 1,
|
|
139
|
+
});
|
|
140
|
+
recordRequest({
|
|
141
|
+
input: "/api/ok2",
|
|
142
|
+
response: response(200),
|
|
143
|
+
durationMs: 1,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(getFailingRequest()?.path, "/api/bad");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("treats a transport failure as failing", () => {
|
|
149
|
+
recordRequest({ input: "/api/gone", durationMs: 100 });
|
|
150
|
+
assert.equal(getFailingRequest()?.status, 0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns undefined when every request succeeded", () => {
|
|
154
|
+
recordRequest({ input: "/api/ok", response: response(200), durationMs: 1 });
|
|
155
|
+
recordRequest({
|
|
156
|
+
input: "/api/ok2",
|
|
157
|
+
response: response(304),
|
|
158
|
+
durationMs: 1,
|
|
159
|
+
});
|
|
160
|
+
assert.equal(getFailingRequest(), undefined);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A constant-size ring of the last few API calls, captured at the one fetch
|
|
3
|
+
* choke point (`taggedFetch` in network-error.ts). It gives a bug report the
|
|
4
|
+
* request context that a minified stack cannot: which endpoints were hit, in
|
|
5
|
+
* what order, and what they returned — plus the correlation id that ties a
|
|
6
|
+
* failing request to its server-side log line.
|
|
7
|
+
*
|
|
8
|
+
* PRIVACY — the issue tracker is public, so a breadcrumb is METADATA ONLY.
|
|
9
|
+
* Capture records the request method, the URL PATHNAME, the HTTP status, the
|
|
10
|
+
* duration, and a correlation id. It never records a request or response body,
|
|
11
|
+
* and never the query string — a search's `?q=` carries the user's query text,
|
|
12
|
+
* a message fetch's params can carry a subject. `requestPath` strips the query
|
|
13
|
+
* and hash precisely so none of that can reach the report even when the fetch
|
|
14
|
+
* layer has the full URL in hand. Opaque path ids (a message id in the path)
|
|
15
|
+
* are acceptable: they already appear in the report's URL section.
|
|
16
|
+
*
|
|
17
|
+
* Capture is cheap by construction — a fixed-size array of small records, no
|
|
18
|
+
* serialization until a report is assembled.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface RequestBreadcrumb {
|
|
22
|
+
method: string;
|
|
23
|
+
/** URL pathname only — never the query string or hash (see file header). */
|
|
24
|
+
path: string;
|
|
25
|
+
/** HTTP status, or 0 when the request never reached a server (transport failure). */
|
|
26
|
+
status: number;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
/** Server correlation id from a response header, when the response carried one. */
|
|
29
|
+
correlationId?: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_ENTRIES = 10;
|
|
34
|
+
|
|
35
|
+
const ring: RequestBreadcrumb[] = [];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Response headers that carry a request/trace correlation id, in preference
|
|
39
|
+
* order. `Headers.get` is case-insensitive, so the lowercase form matches
|
|
40
|
+
* whatever casing the server sent.
|
|
41
|
+
*/
|
|
42
|
+
const CORRELATION_HEADERS = [
|
|
43
|
+
"x-correlation-id",
|
|
44
|
+
"x-request-id",
|
|
45
|
+
"x-amzn-requestid",
|
|
46
|
+
"x-amzn-trace-id",
|
|
47
|
+
"apigw-requestid",
|
|
48
|
+
"x-trace-id",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The pathname of a request, with the query string and hash discarded. This is
|
|
53
|
+
* the redaction boundary: it is the only thing that reads the request URL, and
|
|
54
|
+
* it deliberately keeps nothing but the path so no query text can leak into a
|
|
55
|
+
* breadcrumb.
|
|
56
|
+
*/
|
|
57
|
+
export function requestPath(input: RequestInfo | URL): string {
|
|
58
|
+
const raw =
|
|
59
|
+
typeof input === "string"
|
|
60
|
+
? input
|
|
61
|
+
: input instanceof URL
|
|
62
|
+
? input.href
|
|
63
|
+
: input.url;
|
|
64
|
+
try {
|
|
65
|
+
return new URL(raw, window.location.origin).pathname;
|
|
66
|
+
} catch {
|
|
67
|
+
// A non-URL input (rare) still must not leak a query — cut at the first
|
|
68
|
+
// `?` or `#` and return what precedes it.
|
|
69
|
+
return raw.split(/[?#]/)[0];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
|
74
|
+
if (init?.method) return init.method.toUpperCase();
|
|
75
|
+
if (typeof input !== "string" && !(input instanceof URL) && input.method) {
|
|
76
|
+
return input.method.toUpperCase();
|
|
77
|
+
}
|
|
78
|
+
return "GET";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function correlationIdFrom(response: Response): string | undefined {
|
|
82
|
+
for (const header of CORRELATION_HEADERS) {
|
|
83
|
+
const value = response.headers.get(header);
|
|
84
|
+
if (value) return value;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function push(entry: RequestBreadcrumb): void {
|
|
90
|
+
ring.push(entry);
|
|
91
|
+
if (ring.length > MAX_ENTRIES) ring.shift();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Record the outcome of one fetch. Called from `taggedFetch` for both a
|
|
96
|
+
* completed response and a transport failure (no response, status 0). Only the
|
|
97
|
+
* metadata listed in `RequestBreadcrumb` is read — never `init.body` or the
|
|
98
|
+
* response body.
|
|
99
|
+
*/
|
|
100
|
+
export function recordRequest(args: {
|
|
101
|
+
input: RequestInfo | URL;
|
|
102
|
+
init?: RequestInit;
|
|
103
|
+
response?: Response;
|
|
104
|
+
durationMs: number;
|
|
105
|
+
}): void {
|
|
106
|
+
const { input, init, response, durationMs } = args;
|
|
107
|
+
push({
|
|
108
|
+
method: requestMethod(input, init),
|
|
109
|
+
path: requestPath(input),
|
|
110
|
+
status: response?.status ?? 0,
|
|
111
|
+
durationMs: Math.round(durationMs),
|
|
112
|
+
correlationId: response ? correlationIdFrom(response) : undefined,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The captured breadcrumbs, oldest first. A copy — callers cannot mutate the ring. */
|
|
118
|
+
export function getRecentRequests(): readonly RequestBreadcrumb[] {
|
|
119
|
+
return ring.slice();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The most recent request that failed — a transport failure (status 0) or an
|
|
124
|
+
* HTTP error (>= 400). This is the request a report should call out explicitly,
|
|
125
|
+
* so nobody has to decode a minified stack to learn what broke.
|
|
126
|
+
*/
|
|
127
|
+
export function getFailingRequest(): RequestBreadcrumb | undefined {
|
|
128
|
+
for (let i = ring.length - 1; i >= 0; i--) {
|
|
129
|
+
const entry = ring[i];
|
|
130
|
+
if (entry.status === 0 || entry.status >= 400) return entry;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Test-only: clear the ring. */
|
|
136
|
+
export function __resetRequestBreadcrumbs(): void {
|
|
137
|
+
ring.length = 0;
|
|
138
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
__resetRouteBreadcrumbs,
|
|
5
|
+
getRecentRoutes,
|
|
6
|
+
recordRoute,
|
|
7
|
+
} from "./route-breadcrumbs";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
__resetRouteBreadcrumbs();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("recordRoute", () => {
|
|
14
|
+
it("captures the pathname with a timestamp", () => {
|
|
15
|
+
recordRoute("/mail/inbox");
|
|
16
|
+
const [entry] = getRecentRoutes();
|
|
17
|
+
assert.equal(entry.path, "/mail/inbox");
|
|
18
|
+
assert.ok(entry.timestamp);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("strips a query string — never the user's search text", () => {
|
|
22
|
+
recordRoute("/mail/search?q=confidential");
|
|
23
|
+
const [entry] = getRecentRoutes();
|
|
24
|
+
assert.equal(entry.path, "/mail/search");
|
|
25
|
+
assert.ok(!JSON.stringify(entry).includes("confidential"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("collapses a repeated navigation to the same path", () => {
|
|
29
|
+
recordRoute("/mail/inbox");
|
|
30
|
+
recordRoute("/mail/inbox");
|
|
31
|
+
assert.equal(getRecentRoutes().length, 1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps only the last eight navigations", () => {
|
|
35
|
+
for (let i = 0; i < 12; i++) recordRoute(`/mail/${i}`);
|
|
36
|
+
const entries = getRecentRoutes();
|
|
37
|
+
assert.equal(entries.length, 8);
|
|
38
|
+
assert.equal(entries[0].path, "/mail/4");
|
|
39
|
+
assert.equal(entries[7].path, "/mail/11");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A constant-size ring of the last few in-app navigations, captured from the
|
|
3
|
+
* router's `onResolved` event (see router.tsx). It gives a bug report a
|
|
4
|
+
* navigation trail, so "Steps to reproduce" has something concrete even when
|
|
5
|
+
* the reporter leaves the template blank.
|
|
6
|
+
*
|
|
7
|
+
* PRIVACY — like request breadcrumbs, this is METADATA ONLY and the tracker is
|
|
8
|
+
* public. A route entry is the resolved PATHNAME and a timestamp. The query
|
|
9
|
+
* string is dropped: a search route's `?q=` carries the user's query text.
|
|
10
|
+
* Opaque path ids (a message id in the path) are acceptable — they already
|
|
11
|
+
* appear in the report's URL section.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RouteBreadcrumb {
|
|
15
|
+
/** Resolved pathname only — never the query string (see file header). */
|
|
16
|
+
path: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 8;
|
|
21
|
+
|
|
22
|
+
const ring: RouteBreadcrumb[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Record a navigation. `path` is expected to be a pathname; any query or hash
|
|
26
|
+
* is stripped defensively so a caller passing a full location cannot leak query
|
|
27
|
+
* text into a breadcrumb.
|
|
28
|
+
*/
|
|
29
|
+
export function recordRoute(path: string): void {
|
|
30
|
+
const clean = path.split(/[?#]/)[0];
|
|
31
|
+
const last = ring[ring.length - 1];
|
|
32
|
+
if (last && last.path === clean) return;
|
|
33
|
+
ring.push({ path: clean, timestamp: new Date().toISOString() });
|
|
34
|
+
if (ring.length > MAX_ENTRIES) ring.shift();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The captured navigations, oldest first. A copy — callers cannot mutate the ring. */
|
|
38
|
+
export function getRecentRoutes(): readonly RouteBreadcrumb[] {
|
|
39
|
+
return ring.slice();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test-only: clear the ring. */
|
|
43
|
+
export function __resetRouteBreadcrumbs(): void {
|
|
44
|
+
ring.length = 0;
|
|
45
|
+
}
|
package/src/router.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { QueryClient } from "@tanstack/react-query";
|
|
2
2
|
import { createRouter } from "@tanstack/react-router";
|
|
3
|
+
import { recordRoute } from "./lib/route-breadcrumbs";
|
|
3
4
|
import type { Telemetry } from "./lib/telemetry";
|
|
4
5
|
import { routeTree } from "./routeTree.gen";
|
|
5
6
|
|
|
@@ -19,6 +20,9 @@ export const createAppRouter = (
|
|
|
19
20
|
|
|
20
21
|
router.subscribe("onResolved", (event) => {
|
|
21
22
|
telemetry.recordPageView(event.toLocation.pathname);
|
|
23
|
+
// A metadata-only navigation trail for bug reports; pathname only, never
|
|
24
|
+
// the query string (route-breadcrumbs.ts enforces the redaction).
|
|
25
|
+
recordRoute(event.toLocation.pathname);
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
return router;
|