Skip to content
155 linesCodeBlameRaw
1import { AsyncLocalStorage } from "node:async_hooks";
2
3import type { ServiceBinding } from "@g1t/contracts";
4
5import {
6 type Bookmarks,
7 type ServiceTiming,
8 PRIMARY_WINDOW_SECONDS,
9 bookmarkCookie,
10 coveredMs,
11 mayWrite,
12 setsBookmark,
13 readBookmarks,
14 rpcMethodOf,
15 serverTiming,
16 serviceDuration,
17 databaseTime,
18 sessionFor,
19 SESSION_SERVICES,
20} from "./perf";
21
22/**
23 * What one request to the site did: when it started, each service call
24 * and how long it took, each loader, and the D1 bookmarks it read with and
25 * got back. Kept per request with AsyncLocalStorage, so the service
26 * clients, which are shared by every request in the isolate, can record
27 * into the right one.
28 */
29type RequestPerf = {
30 started: number;
31 /** Not GET or HEAD: an action, a form post. */
32 writing: boolean;
33 /** A service call that may have written (lib/perf.ts `mayWrite`). */
34 wrote: boolean;
35 bookmarks: Bookmarks;
36 returned: Record<string, string>;
37 intervals: [number, number][];
38 services: Record<string, ServiceTiming>;
39 loaders: { id: string; ms: number; kind: "loader" | "action" }[];
40 /** How each session-capable service was asked to read, for the header. */
41 sessions: Map<string, string>;
42};
43
44const scope = new AsyncLocalStorage<RequestPerf>();
45
46/** Runs `handle` with a fresh record for `request`. */
47export function withRequestPerf<T>(request: Request, handle: () => Promise<T>): Promise<T> {
48 const writing = request.method !== "GET" && request.method !== "HEAD";
49 return scope.run(
50 {
51 started: Date.now(),
52 writing,
53 wrote: false,
54 bookmarks: readBookmarks(request.headers.get("cookie")),
55 returned: {},
56 intervals: [],
57 services: {},
58 loaders: [],
59 sessions: new Map(),
60 },
61 handle,
62 );
63}
64
65/**
66 * `binding` with its calls timed, and, for a service that reads D1 with
67 * sessions, the `x-d1-bookmark` each call should carry (lib/perf.ts
68 * `sessionFor`). Only `fetch` is wrapped: the clients use nothing else.
69 */
70export function instrumented(name: string, binding: ServiceBinding): ServiceBinding {
71 return {
72 async fetch(input: string, init?: RequestInit) {
73 const perf = scope.getStore();
74 if (!perf) return binding.fetch(input, init);
75 const session = sessionFor(name, perf.bookmarks, perf.writing, Math.floor(Date.now() / 1000));
76 let sent = init;
77 if (session) {
78 const headers = new Headers(init?.headers);
79 headers.set("x-d1-bookmark", session);
80 sent = { ...init, headers };
81 perf.sessions.set(name, session.startsWith("first-") ? session.slice(6) : "bookmark");
82 }
83 if (SESSION_SERVICES.has(name) && mayWrite(rpcMethodOf(input))) perf.wrote = true;
84 const from = Date.now();
85 const response = await binding.fetch(input, sent);
86 const to = Date.now();
87 perf.intervals.push([from, to]);
88 const timing = (perf.services[name] ??= { calls: 0, wallMs: 0, serviceMs: 0 });
89 timing.calls += 1;
90 timing.wallMs += to - from;
91 const reported = response.headers.get("server-timing");
92 timing.serviceMs += serviceDuration(reported) ?? 0;
93 const database = databaseTime(reported);
94 if (database) {
95 timing.dbMs = (timing.dbMs ?? 0) + database.ms;
96 timing.dbTrips = (timing.dbTrips ?? 0) + database.trips;
97 }
98 const bookmark = response.headers.get("x-d1-bookmark");
99 if (bookmark && SESSION_SERVICES.has(name)) perf.returned[name] = bookmark;
100 return response;
101 },
102 };
103}
104
105/**
106 * Whether this request must read current data: it writes, or the person
107 * wrote moments ago (lib/perf.ts `PRIMARY_WINDOW_SECONDS`). Caches step
108 * aside then (lib/cache.server.ts).
109 */
110export function mustReadFresh(): boolean {
111 const perf = scope.getStore();
112 if (!perf) return true;
113 if (perf.writing) return true;
114 const at = perf.bookmarks.at;
115 return at != null && Math.floor(Date.now() / 1000) - at < PRIMARY_WINDOW_SECONDS;
116}
117
118/** Records a loader's or action's time, from the route instrumentation. */
119export function recordHandler(id: string, kind: "loader" | "action", ms: number) {
120 scope.getStore()?.loaders.push({ id, kind, ms });
121}
122
123/**
124 * `response` with the request's `Server-Timing`, and, after a request
125 * that may have written, the bookmarks its services returned, so the
126 * person's next pages read at least what they just did.
127 */
128export function finishResponse(request: Request, response: Response): Response {
129 const perf = scope.getStore();
130 if (!perf) return response;
131 // A redirect's headers cannot be changed; a copy's can.
132 const answered = new Response(response.body, response);
133 const sessions = [...perf.sessions].map(([service, how]) => `${service}=${how}`).join(" ");
134 answered.headers.append(
135 "server-timing",
136 serverTiming({
137 totalMs: Date.now() - perf.started,
138 loaders: perf.loaders,
139 rpcMs: coveredMs(perf.intervals),
140 services: perf.services,
141 sessions,
142 }),
143 );
144 // Signing in with GitHub writes on a GET; the session it starts says so.
145 const signedIn = answered.headers.getSetCookie().some((cookie) => cookie.startsWith("g1t_session="));
146 const hasSession = /(?:^|;\s*)g1t_session=/.test(request.headers.get("cookie") ?? "");
147 if (setsBookmark({ writing: perf.writing, wrote: perf.wrote, hasSession, signedIn })) {
148 const next: Bookmarks = {
149 at: Math.floor(Date.now() / 1000),
150 services: { ...perf.bookmarks.services, ...perf.returned },
151 };
152 answered.headers.append("set-cookie", bookmarkCookie(next, new URL(request.url).protocol === "https:"));
153 }
154 return answered;
155}