Skip to content

Commit 498aab2

Browse files
habdelraclaude
andcommitted
Instrument the realm-server search path with a request→response timeline (CS-11363)
During a from-scratch index, prerendered cards block for tens of seconds on in-render `_search` round-trips while the SQL behind them runs in milliseconds. We could see the client wait (host-side `boxel_index.diagnostics`) but had no server-side view of where those seconds went. This adds that view: - A client-minted `x-boxel-request-id` correlation id, stamped on the prerendered host's `_federated-search` fetch (prerender-gated; live SPA traffic is untouched). Join key only — no new client measurement. - A `realm:search-timing` line keyed by that id with the request→response stage breakdown: parse, resolveRealms, sql, loadLinks, populate (relationship/query-field assembly), per-instance cache read/write + hit/miss, stringify, and result count. Emitted by `handle-search` for the full handler picture, and standalone by `searchRealms` on the host-test path. Gated on a correlation id, so normal traffic allocates nothing. - Request duration + the correlation id on the `realm:requests` log lines (the outermost request→response bound, incl. body read + send). - A `realm:health` sampler: event-loop lag (mean/p99/max) alongside the in-flight `_search` count and heap, logged only during saturation windows — the signal that confirms or refutes single-thread saturation directly. The timing collector is threaded through the shared `SearchOpts` and is a no-op without a correlation id; `RealmIndexQueryEngine` only ever runs in the realm-server, so the host stays unaffected. A host integration test exercises the whole path end-to-end: the in-realm browser mints the id, issues a real `store.search`, and the test asserts the id lands in the server's `realm:search-timing` line (and that live, non-prerender traffic stamps nothing and emits nothing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf375c4 commit 498aab2

15 files changed

Lines changed: 668 additions & 45 deletions

File tree

packages/host/app/lib/prerender-fetch-headers.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DURING_PRERENDER_HEADER,
33
X_BOXEL_CONSUMING_REALM_HEADER,
44
X_BOXEL_JOB_ID_HEADER,
5+
X_BOXEL_REQUEST_ID_HEADER,
56
} from '@cardstack/runtime-common';
67

78
// Set by the prerender server's `evaluateOnNewDocument` before the
@@ -46,3 +47,33 @@ export function jobIdHeader(): Record<string, string> {
4647
let j = (globalThis as unknown as { __boxelJobId?: string }).__boxelJobId;
4748
return j ? { [X_BOXEL_JOB_ID_HEADER]: j } : {};
4849
}
50+
51+
// Per-search correlation id. Minted fresh for each `_federated-search`
52+
// fetch the SPA issues while rendering inside a prerender tab, and stamped
53+
// as `x-boxel-request-id`. The realm-server reads it back out and keys its
54+
// `realm:search-timing` line on it, so a search the prerender observes as
55+
// slow (surfaced in its `queryLoadsInFlight` diagnostics) can be joined to
56+
// the realm-server's stage-by-stage view of the same request. Gated on the
57+
// prerender context — exactly like the job-id / consuming-realm headers —
58+
// so live SPA traffic is unaffected and emits no server-side timing line.
59+
export function requestIdHeader(): Record<string, string> {
60+
let flag = (globalThis as unknown as { __boxelRenderContext?: boolean })
61+
.__boxelRenderContext;
62+
if (flag !== true) {
63+
return {};
64+
}
65+
return { [X_BOXEL_REQUEST_ID_HEADER]: newCorrelationId() };
66+
}
67+
68+
function newCorrelationId(): string {
69+
let c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
70+
if (c?.randomUUID) {
71+
return c.randomUUID();
72+
}
73+
// Fallback for the rare environment without `crypto.randomUUID` — the id
74+
// only needs to disambiguate concurrent searches in a log line, not be
75+
// cryptographically strong.
76+
return `r-${Date.now().toString(36)}-${Math.floor(
77+
Math.random() * 1e9,
78+
).toString(36)}`;
79+
}

packages/host/app/services/store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
consumingRealmHeader,
9090
duringPrerenderHeaders,
9191
jobIdHeader,
92+
requestIdHeader,
9293
} from '../lib/prerender-fetch-headers';
9394
import { searchCacheKey } from '../lib/search-cache-key';
9495
import { searchInFlightKey } from '../lib/search-in-flight-key';
@@ -1153,6 +1154,7 @@ export default class StoreService extends Service implements StoreInterface {
11531154
...consumingRealmHeader(),
11541155
...jobIdHeader(),
11551156
...jobPriorityHeader(),
1157+
...requestIdHeader(),
11561158
},
11571159
body: JSON.stringify({ ...query, realms }),
11581160
},

packages/host/tests/helpers/realm-server-mock/routes.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
parseSearchQueryFromPayload,
88
parseSearchRequestPayload,
99
SearchRequestError,
10+
sanitizeRequestId,
1011
searchPrerenderedRealms,
1112
searchRealms,
1213
SupportedMimeType,
14+
X_BOXEL_REQUEST_ID_HEADER,
1315
type RealmInfo,
1416
type Query,
1517
} from '@cardstack/runtime-common';
@@ -118,9 +120,17 @@ function registerSearchRoutes() {
118120
throw e;
119121
}
120122

123+
// Mirror the realm-server's `handle-search`: read the client's
124+
// correlation id off the request and thread it into searchRealms, so
125+
// the real `realm:search-timing` line is emitted (and observable by
126+
// host integration tests) keyed by the id the client minted.
127+
let requestId = sanitizeRequestId(
128+
req.headers.get(X_BOXEL_REQUEST_ID_HEADER),
129+
);
121130
let combined = await searchRealms(
122131
realmList.map((realmURL) => getSearchableRealmForURL(realmURL)),
123132
cardsQuery,
133+
requestId ? { requestId } : undefined,
124134
);
125135

126136
return new Response(JSON.stringify(combined), {
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type { RenderingTestContext } from '@ember/test-helpers';
2+
import { settled } from '@ember/test-helpers';
3+
4+
import { getService } from '@universal-ember/test-support';
5+
import { module, test } from 'qunit';
6+
7+
import {
8+
baseRealm,
9+
rri,
10+
setSearchTimingSinkForTests,
11+
X_BOXEL_REQUEST_ID_HEADER,
12+
} from '@cardstack/runtime-common';
13+
import type { Loader } from '@cardstack/runtime-common/loader';
14+
import type { Query } from '@cardstack/runtime-common/query';
15+
16+
import type NetworkService from '@cardstack/host/services/network';
17+
import type StoreService from '@cardstack/host/services/store';
18+
19+
import {
20+
testRealmURL,
21+
setupCardLogs,
22+
setupLocalIndexing,
23+
setupIntegrationTestRealm,
24+
} from '../helpers';
25+
import { setupMockMatrix } from '../helpers/mock-matrix';
26+
import { setupRenderingTest } from '../helpers/setup';
27+
28+
// End-to-end coverage for the search correlation id: the in-realm browser
29+
// (the prerendered host SPA) mints `x-boxel-request-id` on its
30+
// `_federated-search` fetch, and the realm-server's search path emits a
31+
// `realm:search-timing` line keyed by that same id. This proves the id
32+
// threads all the way from the client that originated it through to the
33+
// server log a triage would join against.
34+
//
35+
// The host test exercises the *real* code on both ends: the SPA's
36+
// `requestIdHeader()` stamps the header, and the realm-server-mock hands it
37+
// to the real `searchRealms`, which emits the line. Only the prerender
38+
// context flag is simulated (the host normally sets it inside a prerender
39+
// tab).
40+
41+
const personModule = `
42+
import { contains, field, CardDef } from 'https://cardstack.com/base/card-api';
43+
import StringField from 'https://cardstack.com/base/string';
44+
45+
export class Person extends CardDef {
46+
static displayName = 'Person';
47+
@field name = contains(StringField);
48+
}
49+
`;
50+
51+
let loader: Loader;
52+
53+
module('Integration | search correlation id', function (hooks) {
54+
setupRenderingTest(hooks);
55+
setupLocalIndexing(hooks);
56+
let mockMatrixUtils = setupMockMatrix(hooks);
57+
58+
hooks.beforeEach(function (this: RenderingTestContext) {
59+
loader = getService('loader-service').loader;
60+
});
61+
62+
setupCardLogs(
63+
hooks,
64+
async () => await loader.import(`${baseRealm.url}card-api`),
65+
);
66+
67+
hooks.beforeEach(async function () {
68+
await setupIntegrationTestRealm({
69+
mockMatrixUtils,
70+
contents: {
71+
'person.gts': personModule,
72+
'person-1.json': {
73+
data: {
74+
attributes: { name: 'Alice' },
75+
meta: { adoptsFrom: { module: './person', name: 'Person' } },
76+
},
77+
},
78+
'person-2.json': {
79+
data: {
80+
attributes: { name: 'Bob' },
81+
meta: { adoptsFrom: { module: './person', name: 'Person' } },
82+
},
83+
},
84+
},
85+
});
86+
});
87+
88+
// Restore globals + sink between tests so a failure can't leak into the
89+
// next test or the rest of the suite.
90+
hooks.afterEach(function () {
91+
delete (globalThis as Record<string, unknown>).__boxelRenderContext;
92+
setSearchTimingSinkForTests(undefined);
93+
});
94+
95+
const personQuery: Query = {
96+
filter: { type: { module: rri(`${testRealmURL}person`), name: 'Person' } },
97+
};
98+
99+
test('a client-issued search threads its correlation id into the server timing log', async function (assert) {
100+
let store = getService('store') as StoreService;
101+
let network = getService('network') as NetworkService;
102+
103+
// Capture the realm-server's `realm:search-timing` emissions.
104+
let timingLines: string[] = [];
105+
setSearchTimingSinkForTests((line) => timingLines.push(line));
106+
107+
// Capture the correlation id the client actually puts on the wire.
108+
let sentRequestIds: string[] = [];
109+
let spy = async (request: Request) => {
110+
if (new URL(request.url).pathname.endsWith('/_federated-search')) {
111+
let id = request.headers.get(X_BOXEL_REQUEST_ID_HEADER);
112+
if (id) {
113+
sentRequestIds.push(id);
114+
}
115+
}
116+
// Return null to fall through to the realm-server-mock route.
117+
return null;
118+
};
119+
network.virtualNetwork.mount(spy, { prepend: true });
120+
121+
// Simulate the prerender context, which is what gates the host's
122+
// correlation-id stamping (mirrors a card rendering inside a prerender
123+
// tab issuing a query-backed search).
124+
(globalThis as Record<string, unknown>).__boxelRenderContext = true;
125+
126+
let results = await store.search(personQuery, [testRealmURL]);
127+
await settled();
128+
129+
assert.strictEqual(results.length, 2, 'the search returned both people');
130+
131+
assert.strictEqual(
132+
sentRequestIds.length,
133+
1,
134+
'the client stamped exactly one correlation id on its _federated-search fetch',
135+
);
136+
let sentId = sentRequestIds[0];
137+
assert.ok(
138+
/^[A-Za-z0-9._:-]{8,}$/.test(sentId),
139+
`client-minted correlation id looks well-formed (${sentId})`,
140+
);
141+
142+
let matching = timingLines.filter((line) => line.includes(`req=${sentId}`));
143+
assert.strictEqual(
144+
matching.length,
145+
1,
146+
`the server emitted exactly one realm:search-timing line keyed by the client's id (lines: ${JSON.stringify(
147+
timingLines,
148+
)})`,
149+
);
150+
assert.ok(
151+
/\bsql=\d+\b/.test(matching[0]),
152+
`the timing line carries the sql stage (${matching[0]})`,
153+
);
154+
assert.ok(
155+
/\bloadLinks=\d+\b/.test(matching[0]),
156+
`the timing line carries the loadLinks stage (${matching[0]})`,
157+
);
158+
});
159+
160+
test('a non-prerender search stamps no id and emits no timing line', async function (assert) {
161+
let store = getService('store') as StoreService;
162+
let network = getService('network') as NetworkService;
163+
164+
let timingLines: string[] = [];
165+
setSearchTimingSinkForTests((line) => timingLines.push(line));
166+
167+
let sawHeader = false;
168+
let spy = async (request: Request) => {
169+
if (
170+
new URL(request.url).pathname.endsWith('/_federated-search') &&
171+
request.headers.get(X_BOXEL_REQUEST_ID_HEADER)
172+
) {
173+
sawHeader = true;
174+
}
175+
return null;
176+
};
177+
network.virtualNetwork.mount(spy, { prepend: true });
178+
179+
// No __boxelRenderContext: live SPA traffic must not stamp the header
180+
// (so it pays nothing and the server emits no timing line).
181+
let results = await store.search(personQuery, [testRealmURL]);
182+
await settled();
183+
184+
assert.strictEqual(results.length, 2, 'the search still returns results');
185+
assert.false(
186+
sawHeader,
187+
'live (non-prerender) traffic sends no x-boxel-request-id header',
188+
);
189+
assert.strictEqual(
190+
timingLines.length,
191+
0,
192+
'no realm:search-timing line is emitted without a correlation id',
193+
);
194+
});
195+
});

0 commit comments

Comments
 (0)