Files
searchi/apps/desktop/src/search-performance.ts
T

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));
}