231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
/** UI times use performance.now(); native spans use their own command clock. */
|
|
export interface NativeSpan {
|
|
name: string;
|
|
start_ms: number;
|
|
duration_ms: number;
|
|
parent: number | null;
|
|
counters: Record<string, number>;
|
|
}
|
|
export interface NativeMetrics {
|
|
total_ms: number;
|
|
result_count: number;
|
|
trace: NativeSpan[] | null;
|
|
dropped_spans: number;
|
|
}
|
|
export interface MeasuredResponse<T> {
|
|
results: T[];
|
|
metrics: NativeMetrics;
|
|
}
|
|
export type SearchLane = "lexical" | "hybrid";
|
|
export interface AttemptStart {
|
|
lane: SearchLane;
|
|
startedAt: number;
|
|
submittedAt: number;
|
|
submission: number;
|
|
}
|
|
interface Attempt {
|
|
lane: SearchLane;
|
|
submission: number;
|
|
attempt: number;
|
|
queued_ms: number;
|
|
retry_wait_ms: number;
|
|
invoke_ms: number;
|
|
native: NativeMetrics | null;
|
|
transport_estimate_ms: number | null;
|
|
failed: boolean;
|
|
ended_at: number;
|
|
}
|
|
interface InputTimingSummary {
|
|
debounce_ms: number;
|
|
first_results_ms: number | null;
|
|
latest_displayed_ms: number | null;
|
|
hybrid_final_results_ms: number | null;
|
|
}
|
|
|
|
export class SearchMeasurement {
|
|
private originalInput: Readonly<InputTimingSummary> | null = null;
|
|
debug: boolean;
|
|
inputAt: number;
|
|
scheduledAt: number;
|
|
context: Record<string, string | number | boolean | null>;
|
|
submissions = new Map<SearchLane, number>();
|
|
latestSubmission = new Map<SearchLane, number>();
|
|
submissionSequence = new Map<SearchLane, number>();
|
|
laneAttempts = new Map<SearchLane, number>();
|
|
attempts: Attempt[] = [];
|
|
droppedAttempts = 0;
|
|
droppedDisplays = 0;
|
|
firstDisplayMs: number | null = null;
|
|
displays: {
|
|
lane: SearchLane;
|
|
elapsed_ms: number;
|
|
dom_update_ms: number;
|
|
count: number;
|
|
}[] = [];
|
|
|
|
constructor(
|
|
inputAt: number,
|
|
scheduledAt: number,
|
|
debug: boolean,
|
|
context: Record<string, string | number | boolean | null> = {},
|
|
) {
|
|
this.inputAt = inputAt;
|
|
this.scheduledAt = scheduledAt;
|
|
this.debug = debug;
|
|
this.context = { ...context };
|
|
}
|
|
/** Filesystem updates are independent requests, not elapsed user idle time. */
|
|
freshForRefresh(now: number): SearchMeasurement {
|
|
const refreshed = new SearchMeasurement(now, now, this.debug, this.context);
|
|
// Preserve one fixed-size numeric snapshot, never an ancestor trace/history.
|
|
// Pending responses on the old request cannot rewrite this snapshot.
|
|
refreshed.originalInput = Object.freeze({
|
|
...(this.originalInput ?? {
|
|
debounce_ms: Math.max(0, this.scheduledAt - this.inputAt),
|
|
first_results_ms: this.firstDisplayMs,
|
|
latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null,
|
|
hybrid_final_results_ms:
|
|
[...this.displays]
|
|
.reverse()
|
|
.find((display) => display.lane === "hybrid")?.elapsed_ms ?? null,
|
|
}),
|
|
});
|
|
return refreshed;
|
|
}
|
|
submitted(lane: SearchLane, at: number) {
|
|
if (!this.submissions.has(lane)) this.submissions.set(lane, at);
|
|
this.latestSubmission.set(lane, at);
|
|
this.submissionSequence.set(
|
|
lane,
|
|
(this.submissionSequence.get(lane) ?? 0) + 1,
|
|
);
|
|
}
|
|
begin(lane: SearchLane, startedAt: number): AttemptStart {
|
|
return {
|
|
lane,
|
|
startedAt,
|
|
submittedAt: this.latestSubmission.get(lane) ?? startedAt,
|
|
submission: this.submissionSequence.get(lane) ?? 0,
|
|
};
|
|
}
|
|
completed(
|
|
token: AttemptStart,
|
|
end: number,
|
|
native: NativeMetrics | null,
|
|
failed: boolean,
|
|
) {
|
|
const { lane, startedAt: start, submittedAt, submission } = token;
|
|
const prior = this.attempts.filter((attempt) => attempt.lane === lane);
|
|
const previous = prior.at(-1);
|
|
const retry = previous?.failed && previous.submission === submission;
|
|
const attemptNumber = (this.laneAttempts.get(lane) ?? 0) + 1;
|
|
this.laneAttempts.set(lane, attemptNumber);
|
|
const elapsed = Math.max(0, end - start);
|
|
this.attempts.push({
|
|
lane,
|
|
submission,
|
|
attempt: attemptNumber,
|
|
queued_ms: retry ? 0 : Math.max(0, start - submittedAt),
|
|
retry_wait_ms: retry ? Math.max(0, start - previous.ended_at) : 0,
|
|
invoke_ms: elapsed,
|
|
native,
|
|
transport_estimate_ms: native
|
|
? Math.max(0, elapsed - native.total_ms)
|
|
: null,
|
|
failed,
|
|
ended_at: end,
|
|
});
|
|
if (this.attempts.length > (this.debug ? 32 : 4)) {
|
|
this.attempts.shift();
|
|
this.droppedAttempts++;
|
|
}
|
|
}
|
|
displayed(lane: SearchLane, start: number, end: number, count: number) {
|
|
if (this.firstDisplayMs === null)
|
|
this.firstDisplayMs = Math.max(0, end - this.inputAt);
|
|
this.displays.push({
|
|
lane,
|
|
elapsed_ms: Math.max(0, end - this.inputAt),
|
|
dom_update_ms: Math.max(0, end - start),
|
|
count,
|
|
});
|
|
if (this.displays.length > (this.debug ? 16 : 2)) {
|
|
this.displays.shift();
|
|
this.droppedDisplays++;
|
|
}
|
|
}
|
|
latestNative(lane: SearchLane) {
|
|
return (
|
|
this.attempts
|
|
.filter((attempt) => attempt.lane === lane && !attempt.failed)
|
|
.at(-1)?.native ?? null
|
|
);
|
|
}
|
|
/** Deliberately exclude query text, filenames, paths, snippets and errors. */
|
|
export() {
|
|
return {
|
|
version: 1,
|
|
trigger: this.originalInput ? "filesystem_refresh" : "user_search",
|
|
original_input: this.originalInput ? { ...this.originalInput } : null,
|
|
context: this.context,
|
|
clock_note:
|
|
"UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.",
|
|
debounce_ms: Math.max(0, this.scheduledAt - this.inputAt),
|
|
lane_schedule_ms: Object.fromEntries(
|
|
[...this.submissions].map(([lane, at]) => [
|
|
lane,
|
|
Math.max(0, at - this.scheduledAt),
|
|
]),
|
|
),
|
|
attempts: this.attempts.map(({ ended_at: _end, ...attempt }) => attempt),
|
|
displays: this.displays,
|
|
first_results_ms: this.firstDisplayMs,
|
|
latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null,
|
|
hybrid_final_results_ms:
|
|
[...this.displays]
|
|
.reverse()
|
|
.find((display) => display.lane === "hybrid")?.elapsed_ms ?? null,
|
|
dropped_attempts: this.droppedAttempts,
|
|
dropped_displays: this.droppedDisplays,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function measuredFailure(
|
|
error: unknown,
|
|
): { message: string; metrics: NativeMetrics } | null {
|
|
if (!error || typeof error !== "object") return null;
|
|
const value = error as { message?: unknown; metrics?: NativeMetrics };
|
|
return typeof value.message === "string" &&
|
|
value.metrics &&
|
|
Number.isFinite(value.metrics.total_ms)
|
|
? { message: value.message, metrics: value.metrics }
|
|
: null;
|
|
}
|
|
|
|
/** Malformed/cyclic parent chains cannot trap the debug renderer. */
|
|
export function spanDepth(spans: NativeSpan[], index: number) {
|
|
let depth = 0;
|
|
const visited = new Set([index]);
|
|
let parent = spans[index]?.parent;
|
|
while (
|
|
parent != null &&
|
|
spans[parent] &&
|
|
!visited.has(parent) &&
|
|
depth < 16
|
|
) {
|
|
visited.add(parent);
|
|
depth++;
|
|
parent = spans[parent].parent;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
export function briefMs(value: number) {
|
|
return value < 1
|
|
? "<1"
|
|
: value < 10
|
|
? value.toFixed(1)
|
|
: String(Math.round(value));
|
|
}
|