From 134e86de7f4c38a1618de0f99c202131b20ea170 Mon Sep 17 00:00:00 2001 From: mmosarafO2 Date: Tue, 3 Nov 2026 12:57:06 +0000 Subject: [PATCH 1/6] fix(dbm): one Databases row per server, with a Schema filter inside (#16278) Co-authored-by: Ashish Kolhe --- .../components/dbm/DbmCoverageLine.spec.ts | 16 + web/src/components/dbm/DbmCoverageLine.vue | 8 +- .../dbm/dbmScopeFilterSync.spec.ts | 2 +- .../dbm/dbmTabCountsResilience.spec.ts | 86 ++++ web/src/composables/dbm/useDbmTabCounts.ts | 13 +- web/src/locales/languages/en-US.json | 2 + web/src/utils/dbm/breakdown.spec.ts | 27 +- web/src/utils/dbm/breakdown.ts | 15 +- web/src/utils/dbm/breakdownRows.ts | 92 +++- web/src/utils/dbm/fleetRows.spec.ts | 27 + web/src/utils/dbm/fleetRows.ts | 9 +- web/src/utils/dbm/instanceRows.spec.ts | 461 ++++++++++++++++++ web/src/utils/dbm/instanceRows.ts | 131 +++++ .../DatabaseMonitoring/DatabasesPage.vue | 208 +++++--- .../dbmDatabasesSchemaScope.spec.ts | 439 +++++++++++++++++ .../dbmSectionTabCounts.spec.ts | 17 +- 16 files changed, 1467 insertions(+), 86 deletions(-) create mode 100644 web/src/utils/dbm/instanceRows.spec.ts create mode 100644 web/src/utils/dbm/instanceRows.ts create mode 100644 web/src/views/DatabaseMonitoring/dbmDatabasesSchemaScope.spec.ts diff --git a/web/src/components/dbm/DbmCoverageLine.spec.ts b/web/src/components/dbm/DbmCoverageLine.spec.ts index b1511e418c..d32e631a73 100644 --- a/web/src/components/dbm/DbmCoverageLine.spec.ts +++ b/web/src/components/dbm/DbmCoverageLine.spec.ts @@ -62,6 +62,22 @@ describe("DbmCoverageLine", () => { expect(lineText(wrapper)).toContain("they're exact"); }); + it("says a several-schema database shows its slowest schema's speeds", () => { + const wrapper = mountLine({ exactPercentiles: true, slowestSchemaSpeeds: true }); + expect(lineText(wrapper)).toBe( + "All databases reporting. Speeds are exact per schema; a database with several schemas shows its slowest one.", + ); + }); + + it("keeps the estimated copy when the server fused windows, several schemas or not", () => { + const wrapper = mountLine({ + exactPercentiles: true, + slowestSchemaSpeeds: true, + freshness: freshness({ percentiles_estimated: true }), + }); + expect(lineText(wrapper)).toContain("close but not exact"); + }); + it("never claims exactness when the server fused windows", () => { const wrapper = mountLine({ exactPercentiles: false, diff --git a/web/src/components/dbm/DbmCoverageLine.vue b/web/src/components/dbm/DbmCoverageLine.vue index e60961a4f2..3ada771d54 100644 --- a/web/src/components/dbm/DbmCoverageLine.vue +++ b/web/src/components/dbm/DbmCoverageLine.vue @@ -155,6 +155,8 @@ const props = withDefaults( * separate question the response answers via `percentiles_estimated`. */ exactPercentiles?: boolean; + /** Some row folds several schemas, so its speeds are its slowest schema's, not its own. */ + slowestSchemaSpeeds?: boolean; /** * What these rows ARE. `list` (default) is a ranked table, where the share * of total time is the headline claim. `query` is one statement, where that @@ -172,6 +174,7 @@ const props = withDefaults( other: () => [], topNSubset: false, exactPercentiles: false, + slowestSchemaSpeeds: false, filterLabel: null, subject: "list", inline: false, @@ -411,8 +414,9 @@ const summary = computed(() => { // The Overview grain measures every call, so it states exactness plainly // rather than borrowing the per-query "heaviest N" sentence. if (props.exactPercentiles) { - return estimatedSpeeds.value - ? t("dbm.coverage.lineExactEstimated") + if (estimatedSpeeds.value) return t("dbm.coverage.lineExactEstimated"); + return props.slowestSchemaSpeeds + ? t("dbm.coverage.lineExactSlowestSchema") : t("dbm.coverage.lineExact"); } if (otherTime.value <= 0) { diff --git a/web/src/composables/dbm/dbmScopeFilterSync.spec.ts b/web/src/composables/dbm/dbmScopeFilterSync.spec.ts index f88d1c308f..6ef38f17df 100644 --- a/web/src/composables/dbm/dbmScopeFilterSync.spec.ts +++ b/web/src/composables/dbm/dbmScopeFilterSync.spec.ts @@ -271,7 +271,7 @@ describe("every list page re-reads its scope when the reader returns", () => { const source = read(file); // Prettier splits the call across lines once the model map grows, so // match the call and its live-query argument rather than one spelling. - expect(source).toMatch(/useDbmOwnFilterSync\(\s*\(\) => route\.query/); + expect(source).toMatch(/useDbmOwnFilterSync\(\s*\(\) =>[^;]*?route\.query/); // ...and it must RELOAD, or the chip moves while the rows do not. expect(source, `${file} must refetch when it adopts a new scope`).toContain( "() => void load()", diff --git a/web/src/composables/dbm/dbmTabCountsResilience.spec.ts b/web/src/composables/dbm/dbmTabCountsResilience.spec.ts index 3af8b00319..c1978642d9 100644 --- a/web/src/composables/dbm/dbmTabCountsResilience.spec.ts +++ b/web/src/composables/dbm/dbmTabCountsResilience.spec.ts @@ -219,6 +219,58 @@ describe("the databases fallback counts the same refs the fleet union renders", // The client vantage answered 1; the fallback must not override it. expect(counts.databaseCount).toBe(1); }); + + it("counts servers, not schemas, and honours the Schema filter", async () => { + const databases = { + hits: [ + { db_system: "postgresql", db_instance: "o2dev", db_namespace: "o2dev" }, + { db_system: "postgresql", db_instance: "o2dev", db_namespace: "postgres" }, + { db_system: "postgresql", db_instance: "o2dev", db_namespace: "o2alpha" }, + { db_system: "postgresql", db_instance: "o2-introspection", db_namespace: "postgres" }, + ], + }; + const data = { + databases, + queries: { total: 0, hits: [] }, + activity: null, + deadlocks: null, + blocking: null, + table_health: null, + }; + service.getBadges.mockResolvedValueOnce({ data }); + expect((await fetchDbmTabCounts("acme", WINDOW)).databaseCount).toBe(2); + + queryClient.clear(); + service.getBadges.mockResolvedValueOnce({ data }); + expect((await fetchDbmTabCounts("acme", WINDOW, { namespace: "o2alpha" })).databaseCount).toBe( + 1, + ); + }); + + it("does not badge server-known instances under a Schema filter no traffic matched", async () => { + service.getBadges.mockResolvedValueOnce({ + data: { + databases: { + hits: [{ db_system: "postgresql", db_instance: "o2dev", db_namespace: "o2dev" }], + }, + queries: { total: 0, hits: [] }, + activity: { + by_state: [], + hits: [{ db_system: "postgresql", db_instance: "o2-introspection" }], + }, + deadlocks: null, + blocking: null, + table_health: null, + server_queries: { + hits: [{ db_system: "postgresql", db_instance: "o2dev" }], + truncated: false, + }, + server_samples: { hits: [], truncated: false }, + }, + }); + const counts = await fetchDbmTabCounts("acme", WINDOW, { namespace: "stale-schema" }); + expect(counts.databaseCount).toBe(0); + }); }); describe("one dead slice must not blank a badge the last window answered", () => { @@ -331,6 +383,40 @@ describe("what one page learns is visible from every tab", () => { expect(badgeCount(counts.value.databaseCount)).toBe("6"); }); + it("keeps a count the page publishes once the cached unfiltered scope is back", async () => { + const twoTraced = { + ...envelope(), + databases: { + hits: [ + { db_system: "postgresql", db_instance: "pg-orders", db_namespace: "orders" }, + { db_system: "postgresql", db_instance: "pg-billing", db_namespace: "billing" }, + ], + }, + }; + service.getBadges.mockResolvedValue({ data: twoTraced }); + const { counts, load, publishOwnCount } = useDbmTabCounts(); + await load("acme", RANGE, WINDOW, {}); + await load("acme", RANGE, WINDOW, { namespace: "orders" }); + publishOwnCount("databaseCount", 1); + + // Schema chip cleared: the URL settles first, so the shell re-keys before the page republishes. + await load("acme", RANGE, WINDOW, {}); + publishOwnCount("databaseCount", 3); + + expect(service.getBadges).toHaveBeenCalledTimes(2); + expect(badgeCount(counts.value.databaseCount)).toBe("3"); + }); + + it("still drops a count published under the scope the strip is leaving", async () => { + service.getBadges.mockResolvedValue({ data: envelope() }); + const { counts, load, publishOwnCount } = useDbmTabCounts(); + await load("acme", RANGE, WINDOW, { namespace: "orders" }); + publishOwnCount("databaseCount", 6); + await load("acme", RANGE, WINDOW, {}); + + expect(badgeCount(counts.value.databaseCount)).toBe("1"); + }); + it("treats an empty breakdown beside total:0 as a MEASURED zero, not an unknown", async () => { // THE REPORTED BUG, at its source. Scoping Activity to `mssql-prod-1` — // an engine with no session sampler at all — returns diff --git a/web/src/composables/dbm/useDbmTabCounts.ts b/web/src/composables/dbm/useDbmTabCounts.ts index 3044b3bc96..21a6cc9528 100644 --- a/web/src/composables/dbm/useDbmTabCounts.ts +++ b/web/src/composables/dbm/useDbmTabCounts.ts @@ -128,6 +128,7 @@ import { queryClient } from "@/composables/query/queryClient"; import { activitySampleTotal } from "@/utils/dbm/activity"; import { dedupeServerInstanceRefs, type DbmServerInstanceRef } from "@/utils/dbm/fleetRows"; import { countClaim, overlapClaim, type DbmCountClaim } from "@/utils/dbm/format"; +import { inSchema, instanceKey } from "@/utils/dbm/instanceRows"; /** * Everything the tab strips need, from one request. @@ -300,7 +301,14 @@ export const fetchDbmTabCounts = async ( const { databases, queries, activity, deadlocks, blocking, table_health: tableHealth } = badges; const counts: DbmTabCounts = { - databaseCount: databases ? (databases.hits?.length ?? 0) : null, + // The endpoint ignores `namespace`, so the Overview's client-side Schema filter applies here. + databaseCount: databases + ? new Set( + (databases.hits ?? []) + .filter((row) => inSchema(row, filters.namespace)) + .map((row) => instanceKey(row)), + ).size + : null, // The same databases member, summed instead of counted: its rows carry // the rollup's EXACT per-instance `calls` totals, and instances partition // calls, so the sum is the window's finished-call population. A row without @@ -414,7 +422,8 @@ export const fetchDbmTabCounts = async ( // list the fleet union renders, so this badge counts the rows the tile // counts. It can undercount an instance known only to the metric streams; // the Overview page's own exact union overrides this the moment it loads. - if (counts.databaseCount === 0 && counts.serverInstanceRefs.length) { + // Server-known refs name no schema, so under a Schema filter they prove nothing. + if (counts.databaseCount === 0 && counts.serverInstanceRefs.length && !filters.namespace) { counts.databaseCount = counts.serverInstanceRefs.length; } } diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index b1454171fa..882f44da23 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -24193,6 +24193,7 @@ "databases": { "searchPlaceholder": "Search database or service…", "unnamedInstance": "Instance not named", + "schemaCount": "{count} schema | {count} schemas", "unnamedInstanceHint": "The database's own statement reports prove this engine is running, but they don't name a host. The row will show an address once a feed that carries one — session sampling or instance metrics — is set up.", "moreServices": "+{count}", "columns": { @@ -24380,6 +24381,7 @@ "lineCountedTo": "Counted up to {time}; the last half-minute is still coming in.", "lineExact": "All databases reporting. Speeds here are measured across every call, so they're exact.", "lineExactEstimated": "All databases reporting. Speeds here are blended from several stretches of time, so they're close but not exact.", + "lineExactSlowestSchema": "All databases reporting. Speeds are exact per schema; a database with several schemas shows its slowest one.", "lineSubsetUnnamed": "Your filters narrow this below the level these totals add up at, so the percentages describe the rows shown rather than the whole database.", "lineQuery": "Timings are measured from your application, so they include network time and any wait for a free connection.", "lineUncodedErrors": "{percent} of the failures here came back without a reason code, so they can be counted but not explained.", diff --git a/web/src/utils/dbm/breakdown.spec.ts b/web/src/utils/dbm/breakdown.spec.ts index 361aa03d04..936d06eb46 100644 --- a/web/src/utils/dbm/breakdown.spec.ts +++ b/web/src/utils/dbm/breakdown.spec.ts @@ -17,7 +17,13 @@ import { describe, expect, it } from "vitest"; import type { QueryStatsRow } from "@/services/db_monitoring"; -import { buildDatabaseBreakdown, rowNamespace, rowService, SHORTFALL_FLOOR } from "./breakdown"; +import { + buildDatabaseBreakdown, + namesNoSchema, + rowNamespace, + rowService, + SHORTFALL_FLOOR, +} from "./breakdown"; /** * The lab shape, verbatim from the live endpoint: the merged rows carry @@ -55,6 +61,25 @@ describe("rowNamespace", () => { }); }); +describe("namesNoSchema", () => { + it("tells a row with no schema from one spanning several", () => { + expect(namesNoSchema(row())).toBe(true); + expect(namesNoSchema(row({ namespaces: ["", ""] }))).toBe(true); + expect(namesNoSchema(row({ namespaces: ["a", "b"] }))).toBe(false); + expect(namesNoSchema(row({ namespaces: ["a"] }))).toBe(false); + }); + + it("keeps a multi-schema row out of the unnamed services", () => { + const result = buildDatabaseBreakdown([ + row({ fingerprint: "m", namespaces: ["a", "b"], services: ["multi"] }), + row({ fingerprint: "n", services: ["noschema"], total_time_ns: 400 }), + ]); + expect(result.unnamed.map((node) => [node.name, node.totalTimeNs, node.share])).toEqual([ + ["noschema", 400, 1], + ]); + }); +}); + describe("rowService", () => { it("prefers service_name", () => { expect(rowService(row({ service_name: "checkout", services: ["cart"] }))).toBe("checkout"); diff --git a/web/src/utils/dbm/breakdown.ts b/web/src/utils/dbm/breakdown.ts index 0d1d0ea778..4ce6dc175e 100644 --- a/web/src/utils/dbm/breakdown.ts +++ b/web/src/utils/dbm/breakdown.ts @@ -85,6 +85,8 @@ export interface DbmBreakdown { levels: DbmBreakdownNode[]; /** No row named a schema, so the schema tier was dropped as empty. */ collapsed: boolean; + /** Services of the rows that named no schema at all; a row spanning several schemas is in none. */ + unnamed: DbmBreakdownNode[]; /** Time the fingerprint rows account for, nanoseconds. */ attributedNs: number; /** The database row's own exact total, nanoseconds — `null` when unknown. */ @@ -113,6 +115,10 @@ export const rowNamespace = (row: QueryStatsRow): string | null => { return list.length === 1 ? list[0] : null; }; +/** Whether a row named no schema at all, as distinct from naming several. */ +export const namesNoSchema = (row: QueryStatsRow): boolean => + !row.db_namespace && !(row.namespaces ?? []).some(Boolean); + /** * The service that issued a row. * @@ -245,6 +251,13 @@ export const buildDatabaseBreakdown = ( return node; }); + const schemaless = rows.filter(namesNoSchema); + const unnamed = serviceNodes( + schemaless, + schemaless.reduce((acc, row) => acc + num(row.total_time_ns), 0), + "service::", + ); + // Only a gap big enough to change how a share reads is worth a sentence; the // two grains are computed by different code paths and drift by rounding. const missing = @@ -253,5 +266,5 @@ export const buildDatabaseBreakdown = ( : null; const shortfall = missing !== null && missing >= SHORTFALL_FLOOR ? missing : null; - return { levels, collapsed, attributedNs, databaseTotalNs, shortfall }; + return { levels, collapsed, unnamed, attributedNs, databaseTotalNs, shortfall }; }; diff --git a/web/src/utils/dbm/breakdownRows.ts b/web/src/utils/dbm/breakdownRows.ts index 98bbbec52d..c46e5a9249 100644 --- a/web/src/utils/dbm/breakdownRows.ts +++ b/web/src/utils/dbm/breakdownRows.ts @@ -36,8 +36,10 @@ * row would be a nesting level whose only content is a missing fact. */ -import type { DbmBreakdown, DbmBreakdownNode } from "@/utils/dbm/breakdown"; +import { SHORTFALL_FLOOR, type DbmBreakdown, type DbmBreakdownNode } from "@/utils/dbm/breakdown"; +import type { DbTotalsRow } from "@/services/db_monitoring"; import { errorRate } from "@/utils/dbm/format"; +import { worstPercentile } from "@/utils/dbm/instanceRows"; /** * Which tier of the split a child row came from. @@ -190,6 +192,94 @@ export const toBreakdownRows = ( }); }; +/** The exact per-schema totals a schema row is built from. */ +export type DbmSchemaTotals = Pick< + DbTotalsRow, + "db_namespace" | "calls" | "errors" | "total_time_ns" | "p50_ns" | "p95_ns" | "p99_ns" +>; + +const UNATTRIBUTED_KEY = "schema:\u0000none"; + +const schemaRow = ( + name: string | null, + rows: DbmSchemaTotals[], + services: DbmBreakdownNode[], + serverTotal: number, + parentKey: string, +): DbmBreakdownRow => { + const key = `${parentKey}/${name === null ? UNATTRIBUTED_KEY : `schema:${name}`}`; + const calls = rows.reduce((acc, row) => acc + (row.calls ?? 0), 0); + const errors = rows.reduce((acc, row) => acc + (row.errors ?? 0), 0); + const time = rows.reduce((acc, row) => acc + (row.total_time_ns ?? 0), 0); + return { + rowKey: key, + kind: "schema", + name, + namespace: name, + service: null, + calls, + errors, + errorRate: errorRate(errors, calls), + total_time_ns: time, + share: serverTotal > 0 ? time / serverTotal : 0, + p50_ns: worstPercentile(rows, (row) => row.p50_ns) ?? null, + p95_ns: worstPercentile(rows, (row) => row.p95_ns) ?? null, + p99_ns: worstPercentile(rows, (row) => row.p99_ns) ?? null, + queryCount: services.reduce((acc, service) => acc + service.queryCount, 0), + status: null, + children: services.map((child) => serviceRow(child, name, key)), + }; +}; + +/** A server's children from its exact schema totals, plus one row for its unnamed totals. */ +export const toSchemaRows = ( + state: DbmBreakdownState | undefined, + schemas: DbmSchemaTotals[], + parentKey: string, +): DbmBreakdownRow[] => { + const named = schemas.filter((schema) => !!schema.db_namespace); + if (!named.length) return toBreakdownRows(state, parentKey); + if (!state || state.loading) return [statusRow("loading", parentKey)]; + + const nodes = new Map(); + if (!state.failed) { + for (const node of state.breakdown.collapsed ? [] : state.breakdown.levels) { + if (node.name) nodes.set(node.name, node); + } + } + const unnamed = schemas.filter((schema) => !schema.db_namespace); + const serverTotal = schemas.reduce((acc, schema) => acc + (schema.total_time_ns ?? 0), 0); + + const rows = named.map((schema) => { + const name = schema.db_namespace as string; + return schemaRow(name, [schema], nodes.get(name)?.children ?? [], serverTotal, parentKey); + }); + // A multi-schema statement's time sits in its schemas' totals, so listing it here would count it twice. + if (unnamed.length) { + const services = state.failed ? [] : state.breakdown.unnamed; + rows.push(schemaRow(null, unnamed, services, serverTotal, parentKey)); + } + if (state.failed) rows.push(statusRow("error", parentKey)); + return rows; +}; + +/** The share of the parent's time its children leave unaccounted, above the rounding floor. */ +export const childShortfall = ( + children: DbmBreakdownRow[], + parentTotalNs: number | null | undefined, +): number | null => { + if (!parentTotalNs || parentTotalNs <= 0) return null; + // A status row has no figures, so a split that is not in yet has no gap to state. + if (!children.some((child) => child.kind !== "status")) return null; + const covered = children.reduce((acc, child) => acc + child.total_time_ns, 0); + const missing = (parentTotalNs - covered) / parentTotalNs; + return missing >= SHORTFALL_FLOOR ? missing : null; +}; + +/** A schema row's own uncovered share; `null` on any other row and when nothing was attributed. */ +export const schemaShortfall = (row: DbmBreakdownRow): number | null => + row.kind === "schema" ? childShortfall(row.children, row.total_time_ns) : null; + /** * Whether this database's split may carry the coverage caveat. * diff --git a/web/src/utils/dbm/fleetRows.spec.ts b/web/src/utils/dbm/fleetRows.spec.ts index 95b9ad20db..f6fed12b4d 100644 --- a/web/src/utils/dbm/fleetRows.spec.ts +++ b/web/src/utils/dbm/fleetRows.spec.ts @@ -475,6 +475,33 @@ describe("unionFleetRows with server-vantage instances", () => { ); expect(withOption).toEqual(without); }); + + it("adds no trafficless or engine-only row under a Schema filter", () => { + const rows = unionFleetRows( + [totalsRow({ db_instance: "pgprod-1", db_namespace: "orders" })], + new Map([ + ["postgresql|pgprod-1", metrics()], + ["postgresql|idle-replica", metrics()], + ]), + { + namespace: "orders", + serverInstances: [ + { db_system: "postgresql", db_instance: "pg-reporting" }, + { db_system: "mysql", db_instance: null }, + ], + }, + ); + expect(rows.map((r) => [r.db_instance, r.trafficless])).toEqual([["pgprod-1", false]]); + expect(rows[0].metrics?.state).toBe("matched"); + }); + + it("leaves nothing to render when the Schema filter matched no traffic", () => { + const rows = unionFleetRows([], new Map([["postgresql|idle-replica", metrics()]]), { + namespace: "stale-schema", + serverInstances: [{ db_system: "postgresql", db_instance: "pg-reporting" }], + }); + expect(rows).toEqual([]); + }); }); // ── the server-vantage ref dedupe ──────────────────────────────────────────── diff --git a/web/src/utils/dbm/fleetRows.ts b/web/src/utils/dbm/fleetRows.ts index 0ac5c10678..9ec5b33740 100644 --- a/web/src/utils/dbm/fleetRows.ts +++ b/web/src/utils/dbm/fleetRows.ts @@ -175,6 +175,8 @@ export const unionFleetRows = ( metricsByKey: Map, context: DbmMergeContext & { system?: string | null; + /** The Schema filter; only query traffic can show a server holds that schema. */ + namespace?: string | null; /** Instances `_o2_dbm_server` rows name — the second discovery source. */ serverInstances?: readonly DbmServerInstanceRef[]; } = {}, @@ -194,16 +196,15 @@ export const unionFleetRows = ( if (aliasKey) claimed.add(aliasKey); return { ...row, - // The client grain includes the NAMESPACE — one host serving two - // databases is two rows — while an instance identity does not. Keying a - // client row on the identity would collapse those two onto one id and - // render one of them twice. + // Namespace included, so per-schema input rows on one instance never share an id. rowKey: clientRowKey(row), trafficless: false, metrics: resolveRowMetrics(row.db_system, row.db_instance, metricsByKey, context), }; }); + if (context.namespace) return clientRows; + // Both non-client vantages, folded onto one identity key so an instance // they both report appears exactly once — with the metric set when the // metrics read has one, and `null` when only a `_o2_dbm_server` row names it. diff --git a/web/src/utils/dbm/instanceRows.spec.ts b/web/src/utils/dbm/instanceRows.spec.ts new file mode 100644 index 0000000000..71eb0ae97d --- /dev/null +++ b/web/src/utils/dbm/instanceRows.spec.ts @@ -0,0 +1,461 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; + +import type { DbTotalsRow, QueryStatsRow } from "@/services/db_monitoring"; +import { buildDatabaseBreakdown } from "@/utils/dbm/breakdown"; +import { childShortfall, schemaShortfall, toSchemaRows } from "@/utils/dbm/breakdownRows"; +import { + drowningInstanceKeys, + foldByInstance, + hasWorstOfPercentiles, + instanceKey, + isCriticalServer, + serverRowKey, +} from "@/utils/dbm/instanceRows"; + +const totals = (over: Partial = {}): DbTotalsRow => ({ + db_system: "postgresql", + db_instance: "o2dev", + ...over, +}); + +/** The shape the live O2-introspection org returned: one host, many databases. */ +const o2dev = [ + totals({ + db_namespace: "o2dev", + calls: 300, + errors: 3, + total_time_ns: 9_000, + p95_ns: 30, + max_ns: 50, + qps: 0.3, + calling_services: ["o2-dbm-agent"], + }), + totals({ + db_namespace: "postgres", + calls: 100, + total_time_ns: 1_000, + p95_ns: 10, + max_ns: 90, + qps: 0.1, + calling_services: ["o2-dbm-agent", "pgbouncer"], + }), + totals({ db_instance: "valkey-cart", db_system: "redis", db_namespace: "", calls: 5 }), + totals({ db_namespace: "o2alpha", calls: 0, total_time_ns: 5_000 }), +]; + +describe("foldByInstance", () => { + it("returns one row per (system, instance), whatever the schema count", () => { + const folded = foldByInstance(o2dev); + expect(folded.map((row) => row.db_instance)).toEqual(["o2dev", "valkey-cart"]); + }); + + it("sums the additive figures exactly and keeps the worst max", () => { + const [server] = foldByInstance(o2dev); + expect(server.calls).toBe(400); + expect(server.errors).toBe(3); + expect(server.total_time_ns).toBe(15_000); + expect(server.qps).toBeCloseTo(0.4); + expect(server.max_ns).toBe(90); + }); + + it("takes the worst schema's percentile, never a mean of them", () => { + const [server] = foldByInstance(o2dev); + expect(server.p95_ns).toBe(30); + expect(server.p50_ns).toBeUndefined(); + }); + + it("unions the calling services", () => { + const [server] = foldByInstance(o2dev); + expect(server.calling_services).toEqual(["o2-dbm-agent", "pgbouncer"]); + }); + + it("names no namespace for a multi-schema server, and keeps the only one otherwise", () => { + const [server, redis] = foldByInstance(o2dev); + expect(server.db_namespace).toBeUndefined(); + expect(redis.db_namespace).toBe(""); + }); + + it("lists the named schemas, so one named schema beside unnamed traffic is still named", () => { + const [server] = foldByInstance(o2dev); + expect(server.namedSchemas).toEqual(["o2dev", "o2alpha", "postgres"]); + const [mixed] = foldByInstance([ + totals({ db_namespace: "orders", calls: 10, total_time_ns: 100 }), + totals({ db_namespace: "", calls: 4, total_time_ns: 40 }), + ]); + expect(mixed.db_namespace).toBeUndefined(); + expect(mixed.namedSchemas).toEqual(["orders"]); + }); + + it("keys a server row on its identity alone, whatever schemas fold into it", () => { + const [all] = foldByInstance(o2dev); + const [one] = foldByInstance(o2dev, "postgres"); + expect(serverRowKey(all)).toBe(serverRowKey(one)); + expect(serverRowKey(all)).not.toBe(serverRowKey(foldByInstance(o2dev)[1])); + }); + + it("keeps the schemas heaviest first", () => { + const [server] = foldByInstance(o2dev); + expect(server.schemas.map((row) => row.db_namespace)).toEqual(["o2dev", "o2alpha", "postgres"]); + }); + + it("narrows to one schema under the Schema filter and drops servers without it", () => { + const folded = foldByInstance(o2dev, "postgres"); + expect(folded).toHaveLength(1); + expect(folded[0].db_namespace).toBe("postgres"); + expect(folded[0].calls).toBe(100); + expect(folded[0].p95_ns).toBe(10); + }); +}); + +describe("toSchemaRows", () => { + const split: QueryStatsRow[] = [ + { + fingerprint: "a", + db_system: "postgresql", + db_instance: "o2dev", + db_namespace: "o2dev", + service_name: "o2-dbm-agent", + calls: 10, + total_time_ns: 4_000, + }, + ]; + const state = (rows: QueryStatsRow[]) => ({ + breakdown: buildDatabaseBreakdown(rows), + loading: false, + failed: false, + }); + + it("builds one schema child per schema from the exact totals", () => { + const [server] = foldByInstance(o2dev); + const children = toSchemaRows(state(split), server.schemas, "p"); + expect(children.map((child) => [child.kind, child.name, child.calls])).toEqual([ + ["schema", "o2dev", 300], + ["schema", "o2alpha", 0], + ["schema", "postgres", 100], + ]); + expect(children[0].share).toBeCloseTo(9_000 / 15_000); + }); + + it("hangs the split's services under their own schema only", () => { + const [server] = foldByInstance(o2dev); + const [o2devRow, o2alpha] = toSchemaRows(state(split), server.schemas, "p"); + expect(o2devRow.children.map((child) => child.name)).toEqual(["o2-dbm-agent"]); + expect(o2devRow.queryCount).toBe(1); + expect(o2alpha.children).toEqual([]); + }); + + it("keeps the schemas and says the split failed when it did", () => { + const [server] = foldByInstance(o2dev); + const children = toSchemaRows({ ...state([]), failed: true }, server.schemas, "p"); + expect(children.map((child) => [child.kind, child.status])).toEqual([ + ["schema", null], + ["schema", null], + ["schema", null], + ["status", "error"], + ]); + expect(children[3].rowKey).toBe("p/status"); + }); + + it("adds one trailing child for traffic no single schema owns, so the children add up", () => { + const schemas = [ + totals({ db_namespace: "orders", calls: 600, total_time_ns: 6_000, p95_ns: 40 }), + totals({ db_namespace: "", calls: 200, errors: 2, total_time_ns: 3_000, p95_ns: 90 }), + totals({ db_namespace: "billing", calls: 100, total_time_ns: 1_000, p95_ns: 20 }), + ]; + const [server] = foldByInstance(schemas); + const splitRows: QueryStatsRow[] = [ + { + fingerprint: "f1", + db_system: "postgresql", + db_instance: "o2dev", + db_namespace: "orders", + service_name: "checkout", + calls: 500, + total_time_ns: 5_000, + }, + { + fingerprint: "f2", + db_system: "postgresql", + db_instance: "o2dev", + namespaces: ["orders", "billing"], + service_name: "healthcheck", + calls: 50, + total_time_ns: 500, + }, + { + fingerprint: "f3", + db_system: "postgresql", + db_instance: "o2dev", + service_name: "migrator", + calls: 150, + total_time_ns: 2_500, + }, + ]; + const children = toSchemaRows(state(splitRows), server.schemas, "p"); + expect(children.map((child) => [child.name, child.calls])).toEqual([ + ["orders", 600], + ["billing", 100], + [null, 200], + ]); + const unattributed = children[2]; + expect(unattributed.kind).toBe("schema"); + expect(unattributed.errors).toBe(2); + expect(unattributed.p95_ns).toBe(90); + expect(unattributed.children.map((child) => child.name)).toEqual(["migrator"]); + const time = children.reduce((acc, child) => acc + child.total_time_ns, 0); + expect(time).toBe(server.total_time_ns); + expect(children.reduce((acc, child) => acc + child.share, 0)).toBeCloseTo(1); + expect(childShortfall(children, server.total_time_ns)).toBeNull(); + }); + + it("counts a multi-schema statement once: no unattributed child without unnamed totals", () => { + const [server] = foldByInstance([ + totals({ db_namespace: "orders", calls: 600, total_time_ns: 6_000, p95_ns: 40 }), + totals({ db_namespace: "billing", calls: 100, total_time_ns: 1_000, p95_ns: 20 }), + ]); + const children = toSchemaRows( + state([ + { + fingerprint: "f1", + db_system: "postgresql", + db_instance: "o2dev", + db_namespace: "orders", + service_name: "checkout", + calls: 500, + total_time_ns: 5_000, + }, + { + fingerprint: "f2", + db_system: "postgresql", + db_instance: "o2dev", + namespaces: ["orders", "billing"], + service_name: "healthcheck", + calls: 50, + total_time_ns: 500, + }, + ]), + server.schemas, + "p", + ); + expect(children.map((child) => child.name)).toEqual(["orders", "billing"]); + expect(children.reduce((acc, child) => acc + child.total_time_ns, 0)).toBe( + server.total_time_ns, + ); + expect(children.reduce((acc, child) => acc + child.calls, 0)).toBe(server.calls); + }); + + it("takes the unattributed figures from unnamed totals, never a multi-schema statement's", () => { + const [server] = foldByInstance([ + totals({ db_namespace: "orders", calls: 600, total_time_ns: 6_000 }), + totals({ db_namespace: "", calls: 40, errors: 1, total_time_ns: 400, p99_ns: 70 }), + ]); + const children = toSchemaRows( + state([ + { + fingerprint: "f9", + db_system: "postgresql", + db_instance: "o2dev", + namespaces: ["orders", "reports"], + service_name: "healthcheck", + calls: 900, + total_time_ns: 9_000, + p99_ns: 999, + }, + ]), + server.schemas, + "p", + ); + const unattributed = children[1]; + expect([unattributed.name, unattributed.calls, unattributed.errors]).toEqual([null, 40, 1]); + expect(unattributed.total_time_ns).toBe(400); + expect(unattributed.p99_ns).toBe(70); + expect(unattributed.children).toEqual([]); + expect(children.reduce((acc, child) => acc + child.total_time_ns, 0)).toBe( + server.total_time_ns, + ); + }); + + it("states the shortfall when the children do not cover the parent", () => { + const children = toSchemaRows( + state(split), + [totals({ db_namespace: "o2dev", calls: 300, total_time_ns: 9_000 })], + "p", + ); + expect(childShortfall(children, 9_000)).toBeNull(); + expect(childShortfall(children, 12_000)).toBeCloseTo(0.25); + }); + + describe("a server with one named schema beside unnamed traffic", () => { + const [server] = foldByInstance([ + totals({ db_namespace: "app", calls: 100, total_time_ns: 100, p95_ns: 5 }), + totals({ db_namespace: "", calls: 10, total_time_ns: 10, p95_ns: 50 }), + ]); + const split: QueryStatsRow[] = [ + { + fingerprint: "a", + db_system: "postgresql", + db_instance: "o2dev", + namespaces: ["app", "billing"], + service_name: "multi", + calls: 60, + total_time_ns: 60, + }, + { + fingerprint: "b", + db_system: "postgresql", + db_instance: "o2dev", + service_name: "noschema", + calls: 10, + total_time_ns: 10, + }, + { + fingerprint: "c", + db_system: "postgresql", + db_instance: "o2dev", + db_namespace: "app", + service_name: "appsvc", + calls: 30, + total_time_ns: 30, + }, + ]; + const children = toSchemaRows( + { + breakdown: buildDatabaseBreakdown(split, server.total_time_ns), + loading: false, + failed: false, + }, + server.schemas, + "k", + ); + + it("lists only schema-less statements under the unnamed child", () => { + const unnamed = children[1]; + expect([unnamed.name, unnamed.total_time_ns]).toEqual([null, 10]); + expect(unnamed.children.map((child) => [child.name, child.total_time_ns])).toEqual([ + ["noschema", 10], + ]); + }); + + it("states a schema's own shortfall when its services cover less than its total", () => { + const [app, unnamed] = children; + expect(app.children.map((child) => child.name)).toEqual(["appsvc"]); + expect(schemaShortfall(app)).toBeCloseTo(0.7); + expect(schemaShortfall(unnamed)).toBeNull(); + expect(schemaShortfall(app.children[0])).toBeNull(); + }); + + it("states no shortfall for a schema nothing was attributed to", () => { + const [, empty] = toSchemaRows( + { breakdown: buildDatabaseBreakdown(split), loading: false, failed: false }, + [...server.schemas, totals({ db_namespace: "reports", calls: 4, total_time_ns: 40 })], + "k", + ); + expect([empty.name, empty.children]).toEqual(["reports", []]); + expect(schemaShortfall(empty)).toBeNull(); + }); + + it("states no shortfall while the split is still loading", () => { + const loading = toSchemaRows( + { breakdown: buildDatabaseBreakdown(split), loading: true, failed: false }, + server.schemas, + "k", + ); + expect(childShortfall(loading, server.total_time_ns)).toBeNull(); + expect(childShortfall(toSchemaRows(undefined, server.schemas, "k"), 110)).toBeNull(); + }); + + it("counts the server's speeds as a worst-of over its rows", () => { + expect(hasWorstOfPercentiles([server])).toBe(true); + expect( + hasWorstOfPercentiles(foldByInstance([totals({ db_namespace: "app", calls: 1 })])), + ).toBe(false); + }); + }); + + it("shows a loading placeholder until the split is filed", () => { + const [server] = foldByInstance(o2dev); + expect(toSchemaRows(undefined, server.schemas, "p")[0].kind).toBe("status"); + }); + + it("drops straight to services when no schema was named", () => { + const redis = foldByInstance(o2dev)[1]; + const children = toSchemaRows( + state([ + { + fingerprint: "r", + db_system: "redis", + db_instance: "valkey-cart", + service_name: "cart", + calls: 5, + total_time_ns: 10, + }, + ]), + redis.schemas, + "r", + ); + expect(children.map((child) => [child.kind, child.name])).toEqual([["service", "cart"]]); + }); +}); + +describe("drowningInstanceKeys", () => { + const slow = 400_000_000; + const normal = 100_000_000; + const now = [ + totals({ db_namespace: "orders", calls: 5_000, total_time_ns: 9e12, p95_ns: 500_000_000 }), + totals({ db_namespace: "reports", calls: 200, total_time_ns: 1e11, p95_ns: slow }), + totals({ db_instance: "pg-billing", db_namespace: "billing", calls: 900, p95_ns: normal }), + ]; + const before = [ + totals({ db_namespace: "orders", calls: 5_000, total_time_ns: 9e12, p95_ns: 500_000_000 }), + totals({ db_namespace: "reports", calls: 200, total_time_ns: 1e10, p95_ns: normal }), + totals({ db_instance: "pg-billing", db_namespace: "billing", calls: 900, p95_ns: normal }), + ]; + + it("flags the server when one schema slows down, though its worst p95 did not move", () => { + expect([...drowningInstanceKeys(now, before)]).toEqual([instanceKey(now[0])]); + }); + + it("compares schema to schema even when the server's schema count changed", () => { + expect([...drowningInstanceKeys(now, before.slice(1))]).toEqual([instanceKey(now[0])]); + }); + + it("honours the Schema filter", () => { + expect(drowningInstanceKeys(now, before, "orders").size).toBe(0); + expect(drowningInstanceKeys(now, before, "reports").size).toBe(1); + }); +}); + +describe("isCriticalServer", () => { + it("is critical when one schema fails critically, even if the server's rate is diluted", () => { + const [server] = foldByInstance([ + totals({ db_namespace: "orders", calls: 1_000, errors: 5 }), + totals({ db_namespace: "reports", calls: 30, errors: 3 }), + ]); + expect(isCriticalServer(server)).toBe(true); + }); + + it("keeps the server rule", () => { + const [server] = foldByInstance([ + totals({ db_namespace: "orders", calls: 15, errors: 1 }), + totals({ db_namespace: "reports", calls: 15, errors: 1 }), + ]); + expect(isCriticalServer(server)).toBe(true); + const [calm] = foldByInstance([totals({ db_namespace: "orders", calls: 1_000, errors: 1 })]); + expect(isCriticalServer(calm)).toBe(false); + }); +}); diff --git a/web/src/utils/dbm/instanceRows.ts b/web/src/utils/dbm/instanceRows.ts new file mode 100644 index 0000000000..f8546af560 --- /dev/null +++ b/web/src/utils/dbm/instanceRows.ts @@ -0,0 +1,131 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// db_totals arrives per schema, but the reader's unit is the server, so rows fold here. + +import type { DbTotalsRow } from "@/services/db_monitoring"; +import { detectDrowningDatabases, isCriticalErrorRate } from "@/utils/dbm/insights"; + +/** A server row, with the per-schema rows it was folded from. */ +export interface DbmInstanceTotals extends DbTotalsRow { + /** The per-namespace rows, heaviest first. Always at least one. */ + schemas: DbTotalsRow[]; + /** The schemas that carry a name, heaviest first. */ + namedSchemas: string[]; +} + +const num = (value: number | undefined | null): number => + typeof value === "number" && Number.isFinite(value) ? value : 0; + +/** NUL-separated, like `totalsKey`: no host or engine name can contain it. */ +export const instanceKey = (row: Pick): string => + `${row.db_system}\u0000${row.db_instance}`; + +/** The table identity of a server row; it must not move when the schema count or filter does. */ +export const serverRowKey = (row: Pick): string => + `db\u0000${instanceKey(row)}`; + +/** Whether a namespace-grain row falls inside the Schema filter. */ +export const inSchema = (row: Pick, namespace?: string | null) => + !namespace || (row.db_namespace ?? "") === namespace; + +/** Percentiles cannot be pooled, so a group reports its worst; `undefined` when none reported. */ +export const worstPercentile = ( + rows: T[], + pick: (row: T) => number | null | undefined, +): number | undefined => { + const values = rows + .map(pick) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + return values.length ? Math.max(...values) : undefined; +}; + +const optionalSum = ( + rows: DbTotalsRow[], + pick: (row: DbTotalsRow) => number | undefined, +): number | undefined => + rows.some((row) => typeof pick(row) === "number") + ? rows.reduce((acc, row) => acc + num(pick(row)), 0) + : undefined; + +const foldOne = (rows: DbTotalsRow[]): DbmInstanceTotals => { + const [first] = rows; + const schemas = [...rows].sort((a, b) => num(b.total_time_ns) - num(a.total_time_ns)); + const names = [...new Set(rows.map((row) => row.db_namespace ?? ""))]; + return { + db_system: first.db_system, + db_instance: first.db_instance, + // A server row stands for all its schemas, so it names one only when it holds exactly one. + db_namespace: names.length === 1 ? first.db_namespace : undefined, + statements: optionalSum(rows, (row) => row.statements), + calls: optionalSum(rows, (row) => row.calls), + errors: optionalSum(rows, (row) => row.errors), + total_time_ns: optionalSum(rows, (row) => row.total_time_ns), + traces: optionalSum(rows, (row) => row.traces), + qps: optionalSum(rows, (row) => row.qps), + p50_ns: worstPercentile(rows, (row) => row.p50_ns), + p95_ns: worstPercentile(rows, (row) => row.p95_ns), + p99_ns: worstPercentile(rows, (row) => row.p99_ns), + max_ns: worstPercentile(rows, (row) => row.max_ns), + trace_stream_name: first.trace_stream_name, + calling_services: [ + ...new Set(rows.flatMap((row) => row.calling_services ?? []).filter(Boolean)), + ], + schemas, + namedSchemas: [ + ...new Set(schemas.map((row) => row.db_namespace).filter((name): name is string => !!name)), + ], + }; +}; + +/** One row per (system, instance); under the Schema filter a server without it drops out. */ +export const foldByInstance = ( + rows: DbTotalsRow[], + namespace?: string | null, +): DbmInstanceTotals[] => { + const groups = new Map(); + for (const row of rows) { + if (!inSchema(row, namespace)) continue; + const key = instanceKey(row); + const group = groups.get(key); + if (group) group.push(row); + else groups.set(key, [row]); + } + return [...groups.values()].map(foldOne); +}; + +/** Whether any server's speeds are the worst of several db_totals rows rather than its own. */ +export const hasWorstOfPercentiles = (rows: { schemas?: DbTotalsRow[] }[]): boolean => + rows.some((row) => (row.schemas?.length ?? 0) > 1); + +/** The servers with a schema slowing against its own baseline, by `instanceKey`. */ +export const drowningInstanceKeys = ( + rows: DbTotalsRow[], + previousRows: DbTotalsRow[], + namespace?: string | null, +): Set => + new Set( + detectDrowningDatabases( + rows.filter((row) => inSchema(row, namespace)), + previousRows.filter((row) => inSchema(row, namespace)), + ).map((found) => instanceKey(found.row)), + ); + +/** A server is critical on its own rate, or when any one schema's rate is critical. */ +export const isCriticalServer = ( + row: Pick & { schemas?: DbTotalsRow[] }, +): boolean => + isCriticalErrorRate(row.errors, row.calls) || + (row.schemas ?? []).some((schema) => isCriticalErrorRate(schema.errors, schema.calls)); diff --git a/web/src/views/DatabaseMonitoring/DatabasesPage.vue b/web/src/views/DatabaseMonitoring/DatabasesPage.vue index 545e599fb5..2d908346d6 100644 --- a/web/src/views/DatabaseMonitoring/DatabasesPage.vue +++ b/web/src/views/DatabaseMonitoring/DatabasesPage.vue @@ -109,6 +109,7 @@ along with this program. If not, see . :top-n-subset="topNSubset" :error-count="errorCount" exact-percentiles + :slowest-schema-speeds="showsSlowestSchemaSpeeds" data-test="dbm-databases-coverage" /> @@ -205,9 +206,21 @@ along with this program. If not, see .
-