feat(elasticsearch): add visual search profiler
This commit is contained in:
1 parent
5d1061d490
commit
3ee0640fc2
22 files changed
+1093
-11
No files matched your search
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countProfileNodes, elasticsearchProfileBodyForResult, formatProfileNanos, maxProfileDepth, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const PROFILE_BODY = JSON.stringify({
|
||||
took: 42,
|
||||
timed_out: false,
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "[2aE02wQiS1y8JQxQ4YrW3w][products][0]",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "BooleanQuery",
|
||||
description: "title:text description:text",
|
||||
time_in_nanos: 1234567,
|
||||
breakdown: {
|
||||
build_scorer_count: 1,
|
||||
build_scorer: 40000,
|
||||
next_doc: 300000,
|
||||
score: 450000,
|
||||
match: 0,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "title:dbx",
|
||||
time_in_nanos: 500000,
|
||||
breakdown: { score: 250000, next_doc: 100000 },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "description:profiler",
|
||||
time_in_nanos: 300000,
|
||||
breakdown: { score: 150000, next_doc: 50000 },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "[2aE02wQiS1y8JQxQ4YrW3w][products][1]",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "title:dbx",
|
||||
time_in_nanos: 900000,
|
||||
breakdown: { score: 300000 },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe("parseElasticsearchProfile", () => {
|
||||
it("maps ES profile query nodes onto the profiler tree", () => {
|
||||
const parsed = parseElasticsearchProfile(PROFILE_BODY);
|
||||
expect(parsed).not.toBeNull();
|
||||
const root = parsed!.shards[0]!.tree;
|
||||
expect(root.type).toBe("BooleanQuery");
|
||||
expect(root.description).toBe("title:text description:text");
|
||||
expect(root.timeInNanos).toBe(1234567);
|
||||
expect(root.children).toHaveLength(2);
|
||||
expect(root.children[0]!.type).toBe("TermQuery");
|
||||
expect(root.children[0]!.timeInNanos).toBe(500000);
|
||||
});
|
||||
|
||||
it("computes selfTime as cumulative time minus children", () => {
|
||||
const root = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
expect(root.selfTimeInNanos).toBe(1234567 - 500000 - 300000);
|
||||
expect(root.children[0]!.selfTimeInNanos).toBe(500000);
|
||||
expect(root.selfTimeInNanos).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("clamps selfTime to zero when children exceed the parent", () => {
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "shard-0",
|
||||
searches: [{ query: [{ type: "ParentQuery", time_in_nanos: 100, children: [{ type: "ChildQuery", time_in_nanos: 250, children: [] }] }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tree = parseElasticsearchProfile(body)!.shards[0]!.tree;
|
||||
expect(tree.selfTimeInNanos).toBe(0);
|
||||
expect(tree.costShare).toBe(0);
|
||||
expect(tree.heatLevel).toBe("none");
|
||||
});
|
||||
|
||||
it("computes costShare relative to the tree root time and assigns heat levels", () => {
|
||||
const tree = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
// selfCosts telescope to the root: 1234567.
|
||||
expect(tree.costShare).toBeCloseTo((1234567 - 800000) / 1234567, 6);
|
||||
expect(tree.children[0]!.costShare).toBeCloseTo(500000 / 1234567, 6);
|
||||
expect(tree.children[1]!.costShare).toBeCloseTo(300000 / 1234567, 6);
|
||||
// ~35% self share is >20% → hot, matching planCanvas thresholds.
|
||||
expect(tree.heatLevel).toBe("hot");
|
||||
});
|
||||
|
||||
it("marks the critical path recursively to the leaf", () => {
|
||||
const tree = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
const [left, right] = tree.children;
|
||||
// The highest-cost child is TermQuery title:dbx (500000).
|
||||
expect(tree.isCriticalPath).toBe(true);
|
||||
expect(left!.isCriticalPath).toBe(true);
|
||||
expect(right!.isCriticalPath).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the critical path by cumulative time, not self time", () => {
|
||||
// Regression: the longest branch has most of its cost in descendants, while
|
||||
// a sibling has more local (self) work. Selecting by costShare/selfTime
|
||||
// would wrongly mark the sibling as the critical path.
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "s0",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "BooleanQuery",
|
||||
time_in_nanos: 1000,
|
||||
children: [
|
||||
{
|
||||
type: "SpanQuery",
|
||||
description: "cheap node, deep cost",
|
||||
time_in_nanos: 900,
|
||||
children: [{ type: "TermQuery", description: "deep leaf", time_in_nanos: 899, children: [] }],
|
||||
},
|
||||
{
|
||||
type: "TermQuery",
|
||||
description: "local work, short branch",
|
||||
time_in_nanos: 100,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tree = parseElasticsearchProfile(body)!.shards[0]!.tree;
|
||||
const [deep, local] = tree.children;
|
||||
// SpanQuery (900 cumulative, 1 self) has less self time than TermQuery
|
||||
// (100 self) but is the genuinely longest branch — the critical path must
|
||||
// follow cumulative time all the way to the deep leaf.
|
||||
expect(deep!.isCriticalPath).toBe(true);
|
||||
expect(deep!.children[0]!.isCriticalPath).toBe(true);
|
||||
expect(local!.isCriticalPath).toBe(false);
|
||||
});
|
||||
|
||||
it("sorts shards so the highest total is first", () => {
|
||||
const parsed = parseElasticsearchProfile(PROFILE_BODY)!;
|
||||
expect(parsed.shards).toHaveLength(2);
|
||||
expect(parsed.shards[0]!.totalTimeInNanos).toBeGreaterThanOrEqual(parsed.shards[1]!.totalTimeInNanos);
|
||||
expect(parsed.shards[0]!.id).toContain("[0]");
|
||||
});
|
||||
|
||||
it("merges multiple searches into one tree per shard", () => {
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "s0",
|
||||
searches: [{ query: [{ type: "TermQuery", time_in_nanos: 100, children: [] }] }, { query: [{ type: "MatchAllQuery", time_in_nanos: 50, children: [] }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const shard = parseElasticsearchProfile(body)!.shards[0]!;
|
||||
expect(shard.tree.type).toBe("search");
|
||||
expect(shard.tree.children).toHaveLength(2);
|
||||
expect(shard.tree.timeInNanos).toBe(150);
|
||||
expect(shard.searchCount).toBe(2);
|
||||
});
|
||||
|
||||
it("parses time_in_nanos beyond Number.MAX_SAFE_INTEGER without corrupting the tree", () => {
|
||||
// 2^53 is exactly representable but exceeds Number.MAX_SAFE_INTEGER, so the
|
||||
// safe JSON parser keeps the literal verbatim and the tree still parses to a
|
||||
// finite, usable timing instead of dropping or re-quoting the value.
|
||||
const huge = 2 ** 53;
|
||||
const body = JSON.stringify({
|
||||
profile: { shards: [{ id: "s0", searches: [{ query: [{ type: "BigQuery", time_in_nanos: huge, children: [] }] }] }] },
|
||||
});
|
||||
const shard = parseElasticsearchProfile(body)!.shards[0]!;
|
||||
expect(shard.tree.timeInNanos).toBe(huge);
|
||||
expect(shard.tree.selfTimeInNanos).toBe(huge);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
expect(parseElasticsearchProfile("not json")).toBeNull();
|
||||
expect(parseElasticsearchProfile("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when profile or shards are missing", () => {
|
||||
expect(parseElasticsearchProfile(JSON.stringify({ took: 1 }))).toBeNull();
|
||||
expect(parseElasticsearchProfile(JSON.stringify({ profile: {} }))).toBeNull();
|
||||
expect(parseElasticsearchProfile(JSON.stringify({ profile: { shards: [] } }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when every shard has no query nodes", () => {
|
||||
const body = JSON.stringify({ profile: { shards: [{ id: "s0", searches: [{ query: [] }] }] } });
|
||||
expect(parseElasticsearchProfile(body)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchProfileBodyForResult", () => {
|
||||
const esResult = (partial: Partial<QueryResult>): QueryResult => ({
|
||||
columns: [],
|
||||
rows: [],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
...partial,
|
||||
});
|
||||
|
||||
it("reads the preserved raw body for ES-compatible databases", () => {
|
||||
const body = '{"profile":{"shards":[]}}';
|
||||
const result = esResult({ elasticsearch_raw_body: body });
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", result)).toBe(body);
|
||||
expect(elasticsearchProfileBodyForResult("easysearch", result)).toBe(body);
|
||||
});
|
||||
|
||||
it("falls back to the two-column status/response row shape", () => {
|
||||
const result = esResult({ columns: ["status", "response"], rows: [[200, '{"profile":{}}']] });
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", result)).toBe('{"profile":{}}');
|
||||
});
|
||||
|
||||
it("returns null for non-ES databases and empty bodies", () => {
|
||||
const result = esResult({ elasticsearch_raw_body: '{"profile":{}}' });
|
||||
expect(elasticsearchProfileBodyForResult("mysql", result)).toBeNull();
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", esResult({}))).toBeNull();
|
||||
expect(elasticsearchProfileBodyForResult("elasticsearch", esResult({ elasticsearch_raw_body: "" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile helpers", () => {
|
||||
it("counts nodes and measures depth", () => {
|
||||
const tree = parseElasticsearchProfile(PROFILE_BODY)!.shards[0]!.tree;
|
||||
expect(countProfileNodes(tree)).toBe(3);
|
||||
expect(maxProfileDepth(tree)).toBe(1);
|
||||
});
|
||||
|
||||
it("formats nanoseconds into readable units", () => {
|
||||
expect(formatProfileNanos(0)).toBe("0ns");
|
||||
expect(formatProfileNanos(500)).toBe("500ns");
|
||||
expect(formatProfileNanos(1500)).toBe("1.5µs");
|
||||
expect(formatProfileNanos(2_500_000)).toBe("2.5ms");
|
||||
expect(formatProfileNanos(1.2e9)).toBe("1.2s");
|
||||
});
|
||||
|
||||
it("shares the planCanvas heatLevel thresholds", () => {
|
||||
// Assert the wiring: shares >20% → hot, 5–20% → warm, else cool.
|
||||
const body = JSON.stringify({
|
||||
profile: {
|
||||
shards: [
|
||||
{
|
||||
id: "s0",
|
||||
searches: [
|
||||
{
|
||||
query: [
|
||||
{
|
||||
type: "Root",
|
||||
time_in_nanos: 1000,
|
||||
children: [
|
||||
{ type: "Hot", time_in_nanos: 250, children: [] },
|
||||
{ type: "Warm", time_in_nanos: 100, children: [] },
|
||||
{ type: "Cool", time_in_nanos: 10, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tree = parseElasticsearchProfile(body)!.shards[0]!.tree;
|
||||
expect(tree.children[0]!.heatLevel).toBe("hot");
|
||||
expect(tree.children[1]!.heatLevel).toBe("warm");
|
||||
expect(tree.children[2]!.heatLevel).toBe("cool");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { SavedOpenTab } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages";
|
||||
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
|
||||
export interface DetachedTabRuntimeState {
|
||||
editorViewport?: QueryTab["editorViewport"];
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import { heatLevel as planHeatLevel } from "@/lib/diagram/planCanvas";
|
||||
import { isElasticsearchCompatibleDatabaseType, type DatabaseType, type QueryResult } from "@/types/database";
|
||||
|
||||
/**
|
||||
* Visual Search Profiler for Elasticsearch `_search?profile=true` responses.
|
||||
*
|
||||
* Parses the `profile.shards[].searches[].query[]` timing tree so the frontend
|
||||
* can render it as a collapsible tree with cost-share progress bars. The
|
||||
* response body is preserved end-to-end by the REST-DSL query path
|
||||
* (`QueryResult.elasticsearch_raw_body` or the two-column status/response
|
||||
* result), so this is a pure-frontend feature — no Rust changes.
|
||||
*
|
||||
* `time_in_nanos` is cumulative over a node's subtree; `breakdown` is the
|
||||
* node's own per-phase time. `selfTimeInNanos = timeInNanos − Σ children`,
|
||||
* matching the self-cost accounting used by the SQL explain `planCanvas`.
|
||||
*/
|
||||
|
||||
export type ElasticsearchProfileHeatLevel = "none" | "cool" | "warm" | "hot";
|
||||
|
||||
export interface ElasticsearchProfileNode {
|
||||
/** Lucene query type, e.g. "BooleanQuery" / "TermQuery". */
|
||||
type: string;
|
||||
/** Human-readable query description when Elasticsearch provides one. */
|
||||
description?: string;
|
||||
/** Cumulative time including children, in nanoseconds. */
|
||||
timeInNanos: number;
|
||||
/** The node's own per-phase timings (next_doc / build_scorer / score / …). */
|
||||
breakdown?: Record<string, number>;
|
||||
children: ElasticsearchProfileNode[];
|
||||
/** timeInNanos minus the children's cumulative time, clamped to ≥ 0. */
|
||||
selfTimeInNanos: number;
|
||||
/** selfTime / total self time of the shard's tree (planCanvas semantics). */
|
||||
costShare: number;
|
||||
heatLevel: ElasticsearchProfileHeatLevel;
|
||||
/** Marked along the single highest-cost child chain down to a leaf. */
|
||||
isCriticalPath: boolean;
|
||||
}
|
||||
|
||||
export interface ElasticsearchProfileShard {
|
||||
id: string;
|
||||
tree: ElasticsearchProfileNode;
|
||||
totalTimeInNanos: number;
|
||||
/** Number of _search requests profiled on this shard (>1 when merged under a wrapper node). */
|
||||
searchCount?: number;
|
||||
}
|
||||
|
||||
export interface ElasticsearchProfileData {
|
||||
/** Shards sorted by total time, highest first. */
|
||||
shards: ElasticsearchProfileShard[];
|
||||
/** The parsed `profile` section, kept for a potential raw view. */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
interface RawQueryNode {
|
||||
type?: unknown;
|
||||
description?: unknown;
|
||||
time_in_nanos?: unknown;
|
||||
breakdown?: unknown;
|
||||
children?: unknown;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/** Reads a numeric literal, including large numbers preserved by safeJsonFormat. */
|
||||
function readProfileNumber(value: unknown): number {
|
||||
if (isLosslessJsonNumber(value)) {
|
||||
const parsed = Number(value.raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mapQueryNode(raw: RawQueryNode): ElasticsearchProfileNode {
|
||||
const childrenRaw = Array.isArray(raw.children) ? raw.children : [];
|
||||
const children: ElasticsearchProfileNode[] = [];
|
||||
for (const child of childrenRaw) {
|
||||
const record = readRecord(child);
|
||||
if (record) children.push(mapQueryNode(record as RawQueryNode));
|
||||
}
|
||||
|
||||
const timeInNanos = Math.max(0, readProfileNumber(raw.time_in_nanos));
|
||||
const childrenTotal = children.reduce((sum, child) => sum + child.timeInNanos, 0);
|
||||
const selfTimeInNanos = Math.max(0, timeInNanos - childrenTotal);
|
||||
|
||||
let breakdown: Record<string, number> | undefined;
|
||||
const breakdownRaw = readRecord(raw.breakdown);
|
||||
if (breakdownRaw) {
|
||||
const entries = Object.entries(breakdownRaw)
|
||||
.map(([name, value]) => [name, readProfileNumber(value)] as const)
|
||||
.filter(([, value]) => value > 0);
|
||||
if (entries.length > 0) breakdown = Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return {
|
||||
type: typeof raw.type === "string" && raw.type ? raw.type : "Query",
|
||||
description: typeof raw.description === "string" && raw.description ? raw.description : undefined,
|
||||
timeInNanos,
|
||||
breakdown,
|
||||
children,
|
||||
selfTimeInNanos,
|
||||
costShare: 0,
|
||||
heatLevel: "none",
|
||||
isCriticalPath: false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCostShares(node: ElasticsearchProfileNode, denominator: number): void {
|
||||
node.costShare = node.selfTimeInNanos / denominator;
|
||||
// Zero-cost nodes render a plain label, not a green "cool" bar.
|
||||
node.heatLevel = node.selfTimeInNanos > 0 ? planHeatLevel(node.costShare) : "none";
|
||||
for (const child of node.children) applyCostShares(child, denominator);
|
||||
}
|
||||
|
||||
function markCriticalPath(node: ElasticsearchProfileNode): void {
|
||||
node.isCriticalPath = true;
|
||||
if (node.children.length === 0) return;
|
||||
let best = node.children[0];
|
||||
for (const child of node.children) {
|
||||
// Select by cumulative time_in_nanos (inclusive of descendants), not self
|
||||
// time: a long branch whose cost is mostly in descendants must win over a
|
||||
// child with more local work but a shorter subtree. Ties keep the first
|
||||
// child (stable, deterministic).
|
||||
if (child.timeInNanos > best.timeInNanos) best = child;
|
||||
}
|
||||
markCriticalPath(best);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates costShare/heatLevel/isCriticalPath for the tree. The total self
|
||||
* time telescopes to the tree root's cumulative time, so `root.timeInNanos`
|
||||
* is the same denominator planCanvas uses for SQL explain nodes.
|
||||
*/
|
||||
function finalizeTree(node: ElasticsearchProfileNode): void {
|
||||
if (node.timeInNanos > 0) applyCostShares(node, node.timeInNanos);
|
||||
markCriticalPath(node);
|
||||
}
|
||||
|
||||
interface BuiltShard {
|
||||
tree: ElasticsearchProfileNode;
|
||||
total: number;
|
||||
searchCount: number;
|
||||
}
|
||||
|
||||
/** Builds a single query tree for one shard from its `searches[]` array. */
|
||||
function buildShard(searches: unknown): BuiltShard | null {
|
||||
const searchList = Array.isArray(searches) ? searches : [];
|
||||
const queryRoots: ElasticsearchProfileNode[] = [];
|
||||
for (const search of searchList) {
|
||||
const record = readRecord(search);
|
||||
if (!record) continue;
|
||||
const queryArray = Array.isArray(record.query) ? record.query : [];
|
||||
for (const query of queryArray) {
|
||||
const queryRecord = readRecord(query);
|
||||
if (queryRecord) queryRoots.push(mapQueryNode(queryRecord as RawQueryNode));
|
||||
}
|
||||
}
|
||||
if (queryRoots.length === 0) return null;
|
||||
|
||||
const total = queryRoots.reduce((sum, root) => sum + root.timeInNanos, 0);
|
||||
// A single search keeps its real query root as the tree; multiple searches
|
||||
// are merged under a synthetic "search" container so cost shares stay on one
|
||||
// comparable scale.
|
||||
let tree: ElasticsearchProfileNode;
|
||||
if (queryRoots.length === 1) {
|
||||
tree = queryRoots[0];
|
||||
} else {
|
||||
tree = {
|
||||
type: "search",
|
||||
timeInNanos: total,
|
||||
children: queryRoots,
|
||||
selfTimeInNanos: 0,
|
||||
costShare: 0,
|
||||
heatLevel: "none",
|
||||
isCriticalPath: false,
|
||||
};
|
||||
}
|
||||
finalizeTree(tree);
|
||||
return { tree, total, searchCount: queryRoots.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Elasticsearch `_search` response body into the profiler tree.
|
||||
* Returns `null` for malformed JSON, a missing/invalid `profile` section,
|
||||
* absent/empty `shards`, or searches with no query nodes — never throws.
|
||||
*/
|
||||
export function parseElasticsearchProfile(body: string): ElasticsearchProfileData | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonPreservingLargeNumbers(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const root = readRecord(parsed);
|
||||
if (!root) return null;
|
||||
const profile = readRecord(root.profile);
|
||||
if (!profile) return null;
|
||||
|
||||
const shardsRaw = profile.shards;
|
||||
if (!Array.isArray(shardsRaw) || shardsRaw.length === 0) return null;
|
||||
|
||||
const shards: ElasticsearchProfileShard[] = [];
|
||||
for (const shardRaw of shardsRaw) {
|
||||
const record = readRecord(shardRaw);
|
||||
if (!record) continue;
|
||||
const id = typeof record.id === "string" && record.id ? record.id : String(shards.length + 1);
|
||||
const built = buildShard(record.searches);
|
||||
if (!built) continue;
|
||||
shards.push({ id, tree: built.tree, totalTimeInNanos: built.total, searchCount: built.searchCount });
|
||||
}
|
||||
if (shards.length === 0) return null;
|
||||
|
||||
shards.sort((a, b) => b.totalTimeInNanos - a.totalTimeInNanos);
|
||||
return { shards, raw: profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the ES response body from a query result, either the preserved raw
|
||||
* REST body (`elasticsearch_raw_body`) or the two-column status/response
|
||||
* single-row shape used for large/error responses. Non-ES databases return null.
|
||||
*/
|
||||
export function elasticsearchProfileBodyForResult(databaseType: DatabaseType | undefined, result: QueryResult | undefined): string | null {
|
||||
if (!isElasticsearchCompatibleDatabaseType(databaseType) || !result) return null;
|
||||
if (typeof result.elasticsearch_raw_body === "string" && result.elasticsearch_raw_body.length > 0) return result.elasticsearch_raw_body;
|
||||
|
||||
if (result.columns.length === 2 && result.columns[0] === "status" && result.columns[1] === "response" && result.rows.length === 1) {
|
||||
const body = result.rows[0]?.[1];
|
||||
if (typeof body === "string" && body.length > 0) return body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts every node in the tree, including the root. */
|
||||
export function countProfileNodes(node: ElasticsearchProfileNode): number {
|
||||
let count = 1;
|
||||
for (const child of node.children) count += countProfileNodes(child);
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Maximum depth below the given node (leaf = 0). */
|
||||
export function maxProfileDepth(node: ElasticsearchProfileNode): number {
|
||||
if (node.children.length === 0) return 0;
|
||||
let max = 0;
|
||||
for (const child of node.children) max = Math.max(max, maxProfileDepth(child));
|
||||
return 1 + max;
|
||||
}
|
||||
|
||||
function trimZero(value: string): string {
|
||||
return value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
|
||||
}
|
||||
|
||||
/** Formats nanoseconds as ns / µs / ms / s with sensible rounding. */
|
||||
export function formatProfileNanos(nanos: number): string {
|
||||
const value = Math.max(0, nanos);
|
||||
if (value >= 1e9) return `${trimZero((value / 1e9).toFixed(2))}s`;
|
||||
if (value >= 1e6) {
|
||||
const ms = value / 1e6;
|
||||
return `${trimZero(ms.toFixed(ms >= 100 ? 0 : 1))}ms`;
|
||||
}
|
||||
if (value >= 1e3) {
|
||||
const us = value / 1e3;
|
||||
return `${trimZero(us.toFixed(us >= 100 ? 0 : 1))}µs`;
|
||||
}
|
||||
return `${value}ns`;
|
||||
}
|
||||
Reference in new issue
Block a user