Skip to content

Commit 242159d

Browse files
authored
Merge pull request #5098 from cardstack/worktree-cs-search-server-timing
Instrument the realm-server search path with a request→response timeline (CS-11363)
2 parents d7c6621 + 4c19e6f commit 242159d

17 files changed

Lines changed: 780 additions & 65 deletions

File tree

.claude/skills/indexing-diagnostics/SKILL.md

Lines changed: 84 additions & 17 deletions
Large diffs are not rendered by default.

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_LOGGING_CORRELATION_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-logging-correlation-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 loggingCorrelationIdHeader(): 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_LOGGING_CORRELATION_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
@@ -90,6 +90,7 @@ import {
9090
consumingRealmHeader,
9191
duringPrerenderHeaders,
9292
jobIdHeader,
93+
loggingCorrelationIdHeader,
9394
} from '../lib/prerender-fetch-headers';
9495
import { searchCacheKey } from '../lib/search-cache-key';
9596
import { searchInFlightKey } from '../lib/search-in-flight-key';
@@ -1154,6 +1155,7 @@ export default class StoreService extends Service implements StoreInterface {
11541155
...consumingRealmHeader(),
11551156
...jobIdHeader(),
11561157
...jobPriorityHeader(),
1158+
...loggingCorrelationIdHeader(),
11571159
},
11581160
body: JSON.stringify({ ...query, realms }),
11591161
},

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+
sanitizeLoggingCorrelationId,
1011
searchPrerenderedRealms,
1112
searchRealms,
1213
SupportedMimeType,
14+
X_BOXEL_LOGGING_CORRELATION_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 loggingCorrelationId = sanitizeLoggingCorrelationId(
128+
req.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER),
129+
);
121130
let combined = await searchRealms(
122131
realmList.map((realmURL) => getSearchableRealmForURL(realmURL)),
123132
cardsQuery,
133+
loggingCorrelationId ? { loggingCorrelationId } : undefined,
124134
);
125135

126136
return new Response(JSON.stringify(combined), {
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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_LOGGING_CORRELATION_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-logging-correlation-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+
// `loggingCorrelationIdHeader()` 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_LOGGING_CORRELATION_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) =>
143+
line.includes(`corr=${sentId}`),
144+
);
145+
assert.strictEqual(
146+
matching.length,
147+
1,
148+
`the server emitted exactly one realm:search-timing line keyed by the client's id (lines: ${JSON.stringify(
149+
timingLines,
150+
)})`,
151+
);
152+
assert.ok(
153+
/\bsql=\d+\b/.test(matching[0]),
154+
`the timing line carries the sql stage (${matching[0]})`,
155+
);
156+
assert.ok(
157+
/\bloadLinks=\d+\b/.test(matching[0]),
158+
`the timing line carries the loadLinks stage (${matching[0]})`,
159+
);
160+
});
161+
162+
test('a non-prerender search stamps no id and emits no timing line', async function (assert) {
163+
let store = getService('store') as StoreService;
164+
let network = getService('network') as NetworkService;
165+
166+
let timingLines: string[] = [];
167+
setSearchTimingSinkForTests((line) => timingLines.push(line));
168+
169+
let sawHeader = false;
170+
let spy = async (request: Request) => {
171+
if (
172+
new URL(request.url).pathname.endsWith('/_federated-search') &&
173+
request.headers.get(X_BOXEL_LOGGING_CORRELATION_ID_HEADER)
174+
) {
175+
sawHeader = true;
176+
}
177+
return null;
178+
};
179+
network.virtualNetwork.mount(spy, { prepend: true });
180+
181+
// No __boxelRenderContext: live SPA traffic must not stamp the header
182+
// (so it pays nothing and the server emits no timing line).
183+
let results = await store.search(personQuery, [testRealmURL]);
184+
await settled();
185+
186+
assert.strictEqual(results.length, 2, 'the search still returns results');
187+
assert.false(
188+
sawHeader,
189+
'live (non-prerender) traffic sends no x-boxel-logging-correlation-id header',
190+
);
191+
assert.strictEqual(
192+
timingLines.length,
193+
0,
194+
'no realm:search-timing line is emitted without a correlation id',
195+
);
196+
});
197+
});

0 commit comments

Comments
 (0)