mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
## What this is Database Monitoring: an aggregation and presentation layer over telemetry OpenObserve already ingests. It reads three vantages — client spans, server-vantage logs, and collector metrics — and turns them into per-statement, per-instance and per-database views. No new storage engine and no new ingest path. Statements are normalized and rolled up into a stats stream, and the read API queries that stream through the existing search path. ## Shape of the change | | | |---|---| | Read endpoints | 17 | | UI pages | 8 (plus a shell) | | Commits | 141 | **Backend** — `src/core/src/db_monitoring/` holds the non-HTTP half (normalization, rollup, server-vantage parsing). The read API lives in `src/api/management/src/request/db_monitoring/` as a handler / models / service trio; the service layer is split one file per feature. **Frontend** — `web/src/views/DatabaseMonitoring/`, with shared logic in `web/src/composables/dbm/`. ## Feature availability Databases, queries, query detail and history, endpoints, samples, activity, insights, plans, server metrics, server queries, server samples, instances, instance metrics, badges — is available in OSS. ## Reviewing this It is a large diff, and most of it is additive: 327 files, and the two largest non-test files are the shared service module and the deadlocks service. Suggested order: 1. `src/api/management/src/request/db_monitoring/handler.rs` — the 17 entry points and their gating 2. `src/api/management/src/request/db_monitoring/service/common.rs` — auth, SQL building, the shared read path 3. `src/core/src/db_monitoring/rollup.rs` — how the stats stream is produced 4. `web/src/views/DatabaseMonitoring/` — the pages Time semantics are the subtlest part. Rollup rows are stamped at the window **end**, so reads are exclusive at both edges; `stats_read_range` carries the reasoning and a test pins it. ## Known follow-ups - **The server-vantage stream named as `_o2_dbm_server`.** ## Testing - Rust: unit tests across the DBM core and read service, including SQL-building, auth-gating and normalizer cases - Web: unit tests across the DBM pages and composables - Playwright: DBM suite covering the pages and cross-page navigation Verified before opening: OSS and enterprise builds both compile warning-free, `cargo fmt` clean, `vue-tsc` clean, and the full web unit suite passes (44,704 tests).
329 lines
16 KiB
JavaScript
329 lines
16 KiB
JavaScript
// databaseMonitoring.spec.js
|
|
// E2E tests for Database Monitoring (Infra -> Databases).
|
|
//
|
|
// WHAT THESE COVER, AND WHY THESE
|
|
// -------------------------------
|
|
// Every test here is a regression net under a defect that actually shipped and
|
|
// was found by hand against the tests/dbm-server-vantage rig. None of them are
|
|
// speculative:
|
|
//
|
|
// P0 route DBM moved from /traces/databases to /infra/databases. The old
|
|
// path must keep resolving — permalinks and bookmarks exist.
|
|
// P0 badge/tab Table health's badge read 5 while its own table rendered
|
|
// "No table statistics yet". A badge that disagrees with the
|
|
// tab it labels is the highest-value invariant on this page:
|
|
// the two come from different reads, so nothing but a test
|
|
// keeps them honest.
|
|
// P0 instance The chip the UI labels "database" is the INSTANCE dimension.
|
|
// It matched nothing while o2_dbm_instance was null on every
|
|
// row, silently emptying Slowest calls and Deadlocks. Picking
|
|
// a real value must not empty a tab that had data.
|
|
// P1 fallback Slowest calls is trace-fed. On a no-traces deployment its
|
|
// client list is legitimately empty and the DATABASE-reported
|
|
// fallback is the whole answer. A page showing neither list
|
|
// nor a stated reason is the failure this catches.
|
|
//
|
|
// DATA DEPENDENCY
|
|
// ---------------
|
|
// These read whatever the target org already holds; they ingest nothing. Point
|
|
// ORGNAME at an org a collector actually feeds (the dev rig uses `pg_server`),
|
|
// or the data-bearing assertions skip rather than fail — an empty org must not
|
|
// be reported as a passing DBM suite.
|
|
|
|
const { test, expect } = require('../utils/enhanced-baseFixtures.js');
|
|
const testLogger = require('../utils/test-logger.js');
|
|
const PageManager = require('../../pages/page-manager.js');
|
|
const { DBM_TABS } = require('../../pages/dbmPages/databaseMonitoringPage.js');
|
|
|
|
/** Tabs whose badge counts rows in a table this suite can locate. */
|
|
const COUNTED_TABS = [
|
|
{ key: DBM_TABS.tableHealth, tab: 'tableHealth' },
|
|
{ key: DBM_TABS.activity, tab: 'activity' },
|
|
];
|
|
|
|
test.describe('Database Monitoring', () => {
|
|
test.describe.configure({ mode: 'parallel' });
|
|
let pm;
|
|
let dbm;
|
|
|
|
test.beforeEach(async ({ page }, testInfo) => {
|
|
testLogger.testStart(testInfo.title, testInfo.file);
|
|
pm = new PageManager(page);
|
|
dbm = pm.databaseMonitoringPage;
|
|
});
|
|
|
|
test.afterEach(async ({}, testInfo) => {
|
|
testLogger.testEnd(testInfo.title, testInfo.status);
|
|
});
|
|
|
|
// =========================================================================
|
|
// P0: ROUTING
|
|
// =========================================================================
|
|
|
|
test('P0: DBM lives under /infra/databases and the tab strip renders', {
|
|
tag: ['@dbm', '@infra', '@smoke', '@P0', '@all'],
|
|
}, async ({ page }) => {
|
|
await dbm.navigate('overview');
|
|
await dbm.expectLoaded();
|
|
expect(page.url()).toContain('/infra/databases');
|
|
testLogger.info(`Landed on ${page.url()}`);
|
|
});
|
|
|
|
test('P0: the old /traces/databases path still resolves, preserving query', {
|
|
tag: ['@dbm', '@infra', '@P0', '@all'],
|
|
}, async ({ page }) => {
|
|
// The move must not break links already pasted into tickets and chats.
|
|
const org = process.env['ORGNAME'] || 'default';
|
|
const baseUrl = (process.env['ZO_BASE_URL'] || '').replace(/\/+$/, '');
|
|
await page.goto(
|
|
`${baseUrl}/web/traces/databases/table-health?org_identifier=${org}&period=1h`,
|
|
{ timeout: 60000 },
|
|
);
|
|
await page.waitForLoadState('load', { timeout: 20000 });
|
|
|
|
await expect(page).toHaveURL(/\/infra\/databases\/table-health/, { timeout: 20000 });
|
|
// The tab segment AND the query must survive — a redirect that drops the
|
|
// scope silently changes what the reader is looking at.
|
|
expect(page.url()).toContain('period=1h');
|
|
testLogger.info(`Redirected to ${page.url()}`);
|
|
});
|
|
|
|
// =========================================================================
|
|
// P0: BADGE / TABLE AGREEMENT
|
|
// =========================================================================
|
|
|
|
for (const { key, tab } of COUNTED_TABS) {
|
|
test(`P0: ${tab} badge agrees with the rows its own tab renders`, {
|
|
tag: ['@dbm', '@infra', '@P0', '@all'],
|
|
}, async () => {
|
|
await dbm.navigate(tab);
|
|
await dbm.expectLoaded();
|
|
|
|
const tableLocator = tab === 'tableHealth' ? dbm.tableHealthTable : dbm.activityTable;
|
|
const emptyLocators =
|
|
tab === 'tableHealth'
|
|
? [dbm.tableHealthNotCollecting, dbm.tableHealthNoMatches]
|
|
: [dbm.activityNotCollecting, dbm.activityHealthy];
|
|
|
|
// Badge and rows are captured together, after the table settles — see
|
|
// readBadgeAndRows. Sampling them separately compared a fresh badge
|
|
// against a stale row count and produced `badge=5 rows=0`, a
|
|
// disagreement the UI never showed.
|
|
const { outcome, badge, rows, truncated } = await dbm.readBadgeAndRows(
|
|
key,
|
|
tableLocator,
|
|
emptyLocators,
|
|
);
|
|
expect(outcome, `${tab} never settled — neither rows nor an empty state`).not.toBe('timeout');
|
|
testLogger.info(`${tab}: badge=${badge} rows=${rows} outcome=${outcome}`);
|
|
|
|
// A badge is expected only where the ENGINE can fill that tab.
|
|
//
|
|
// The strip renders a badge when the count is non-null (`v-if="section.count != null"`),
|
|
// and a count is null when the feed never answered — which is the honest
|
|
// state for a tab this engine has no recipe for. SQL Server ships only
|
|
// blocking and deadlock recipes: no activity source exists, so Activity
|
|
// renders `dbm-activity-not-collecting` and NO badge, and demanding one
|
|
// there fails a page that is behaving correctly.
|
|
//
|
|
// A missing badge is still a failure where the tab HAS data, which is
|
|
// what this originally caught: treating "no badge" as "nothing to
|
|
// reconcile" let the test pass while comparing nothing at all.
|
|
const apiRows = await dbm.apiCount(tab === 'tableHealth' ? 'table_health' : 'activity', {});
|
|
if (apiRows) {
|
|
expect(
|
|
badge,
|
|
`${tab} has ${apiRows} rows in the API but rendered no badge — the strip's fan-out never resolved`,
|
|
).not.toBeNull();
|
|
} else if (badge === null) {
|
|
testLogger.info(`${tab}: no data and no badge — this engine has no feed for it`);
|
|
return;
|
|
}
|
|
|
|
// The defect this pins: badge 5, table "No table statistics yet".
|
|
// A non-zero badge over an empty table is always wrong — the badge
|
|
// claims the tab has that many rows to show.
|
|
if (badge > 0) {
|
|
expect(
|
|
rows,
|
|
`${tab} badge claims ${badge} but the table rendered no rows`,
|
|
).toBeGreaterThan(0);
|
|
}
|
|
|
|
// Exact equality ONLY where the badge and the table count the same
|
|
// thing, which is not universal:
|
|
//
|
|
// Table health — badge counts the rows the tab lists. Comparable.
|
|
// Activity — badge counts the SESSION POPULATION, from the
|
|
// response's `by_state` (useDbmTabCounts.ts:356, "From
|
|
// `by_state`, the population"), while the table lists
|
|
// sampled activity ROWS. Measured on MySQL: by_state
|
|
// sums to 7 live sessions over 223 sampled rows, so
|
|
// badge 7 beside 20 rendered rows is both numbers being
|
|
// right about different questions. Postgres hid this —
|
|
// its by_state sums to 195, comfortably over the 20-row
|
|
// page, so the branch below never fired.
|
|
//
|
|
// The invariant that DOES hold everywhere is the one asserted above: a
|
|
// non-zero badge must never sit over an empty table.
|
|
const badgeCountsRows = tab === 'tableHealth';
|
|
if (badgeCountsRows && !truncated && badge > 0 && badge <= 20) {
|
|
expect(rows, `${tab} badge ${badge} != ${rows} rendered rows`).toBe(badge);
|
|
}
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// P0: THE INSTANCE ("database") FILTER
|
|
// =========================================================================
|
|
|
|
test('P0: filtering by a real instance keeps the tab populated', {
|
|
tag: ['@dbm', '@infra', '@P0', '@all'],
|
|
}, async () => {
|
|
// Table health is the cleanest probe: it accepts only instance/system, so
|
|
// nothing else can explain a change in row count.
|
|
await dbm.navigate('tableHealth');
|
|
await dbm.expectLoaded();
|
|
await dbm.settleTab('tableHealth');
|
|
|
|
const unfilteredRows = await dbm.getRowCount(dbm.tableHealthTable);
|
|
test.skip(
|
|
unfilteredRows === 0,
|
|
'no table-health data in this org — point ORGNAME at a collector-fed org',
|
|
);
|
|
|
|
// Take the instance from the API rather than the DOM. The Instance column
|
|
// renders no data-test of its own (only the three size-bar columns do), and
|
|
// the strict selector policy rules out reading it by text or nth-child. The
|
|
// API is also the better source: it gives the value the FILTER is matched
|
|
// against, not the string the cell happens to display.
|
|
const { instance, engine } = await dbm.firstScopeFromApi();
|
|
test.skip(!instance, 'table-health rows carry no instance value to filter on');
|
|
|
|
// The ENGINE comes from the data too, never hardcoded. Pinning
|
|
// `system: 'postgresql'` here passed against the Postgres rig and failed
|
|
// against the MySQL one for a reason that looked exactly like the original
|
|
// defect — `instance=my-prod-1` AND `system=postgresql` correctly matches
|
|
// nothing, so a healthy page reported "filtering emptied a populated
|
|
// table". A suite that only runs green against one engine is not testing
|
|
// the filter, it is testing the fixture.
|
|
testLogger.info(`Filtering table health by instance=${instance} system=${engine}`);
|
|
await dbm.navigateWithScope('tableHealth', { instance, system: engine });
|
|
await dbm.settleTab('tableHealth');
|
|
|
|
const filteredRows = await dbm.getRowCount(dbm.tableHealthTable);
|
|
testLogger.info(`unfiltered=${unfilteredRows} filtered=${filteredRows}`);
|
|
|
|
// The regression: selecting an instance the data DOES carry emptied the
|
|
// table, because the dimension was null on every row.
|
|
expect(
|
|
filteredRows,
|
|
`filtering by instance=${instance} emptied a table that had ${unfilteredRows} rows`,
|
|
).toBeGreaterThan(0);
|
|
expect(filteredRows).toBeLessThanOrEqual(unfilteredRows);
|
|
});
|
|
|
|
test('P1: an instance that matches nothing shows an empty STATE, not a blank page', {
|
|
tag: ['@dbm', '@infra', '@P1', '@all'],
|
|
}, async () => {
|
|
await dbm.navigateWithScope('tableHealth', {
|
|
instance: 'no-such-instance-exists',
|
|
system: 'postgresql',
|
|
});
|
|
await dbm.expectLoaded();
|
|
|
|
// `settleTab` rather than a hand-written list: these sites each named two
|
|
// empty states and omitted `tableHealthEngineUnsupported`, which is the one
|
|
// an engine with no table-stats recipe renders. On such an org the wait
|
|
// could only ever time out, blaming the page for a state the test never
|
|
// agreed to look for.
|
|
const outcome = await dbm.settleTab('tableHealth');
|
|
expect(outcome, 'an unmatched filter left the page in limbo').not.toBe('timeout');
|
|
|
|
// Zero rows is the CORRECT answer here; what matters is that the page says
|
|
// so rather than rendering an empty frame with no explanation.
|
|
const rows = await dbm.getRowCount(dbm.tableHealthTable);
|
|
if (rows === 0) {
|
|
const explained =
|
|
(await dbm.tableHealthNoMatches.isVisible().catch(() => false)) ||
|
|
(await dbm.tableHealthNotCollecting.isVisible().catch(() => false));
|
|
expect(explained, 'no rows and no empty state — the reader is told nothing').toBeTruthy();
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// P1: SLOWEST CALLS — THE TRACE-FED TAB
|
|
// =========================================================================
|
|
|
|
test('P1: Slowest calls shows the client list, the database fallback, or a stated reason', {
|
|
tag: ['@dbm', '@infra', '@P1', '@all'],
|
|
}, async () => {
|
|
await dbm.navigate('samples');
|
|
await dbm.expectLoaded();
|
|
|
|
// Settle against the tab's OWN empty-state list rather than a hand-written
|
|
// one. This test previously named three states and omitted the two SHARED
|
|
// ones (`emptyStateDiagnostic`, `emptyStateFiltered`) that this page
|
|
// actually renders when neither vantage has rows — so on an org where
|
|
// Slowest calls is legitimately empty it waited the full budget for a
|
|
// state that was never going to appear and reported a working page as
|
|
// hung. Measured: the diagnostic empty state renders at ~1s.
|
|
const outcome = await dbm.settleTab('samples');
|
|
expect(outcome, 'Slowest calls never settled').not.toBe('timeout');
|
|
|
|
const clientRows = await dbm.getRowCount(dbm.samplesTable);
|
|
const fallbackShown = await dbm.serverSamplesSection.isVisible().catch(() => false);
|
|
const logOff = await dbm.samplesLogOff.isVisible().catch(() => false);
|
|
const noMatches = await dbm.samplesNoMatches.isVisible().catch(() => false);
|
|
// The two SHARED empty states this page also renders. Omitting them is what
|
|
// made this assertion fail on an org where Slowest calls is legitimately
|
|
// empty: the page WAS explaining itself, through a state the test did not
|
|
// know to look at.
|
|
const diagnostic = await dbm.emptyStateDiagnostic.isVisible().catch(() => false);
|
|
const filtered = await dbm.emptyStateFiltered.isVisible().catch(() => false);
|
|
testLogger.info(
|
|
`samples: clientRows=${clientRows} fallback=${fallbackShown} logOff=${logOff} ` +
|
|
`noMatches=${noMatches} diagnostic=${diagnostic} filtered=${filtered}`,
|
|
);
|
|
|
|
// On a no-traces deployment the client list is EMPTY BY DESIGN. Exactly one
|
|
// of these must hold, and "none of them" is the bug: a page that shows an
|
|
// empty table while the databases are reporting their own slowest
|
|
// statements is hiding the only answer it has.
|
|
expect(
|
|
clientRows > 0 || fallbackShown || logOff || noMatches || diagnostic || filtered,
|
|
'Slowest calls rendered neither a list nor an explanation',
|
|
).toBeTruthy();
|
|
|
|
// If the fallback list is up, it must actually carry rows — an empty
|
|
// fallback section is worse than none, since its heading claims the
|
|
// databases answered.
|
|
if (fallbackShown) {
|
|
const serverRows = await dbm.getRowCount(dbm.serverSamplesTable);
|
|
expect(serverRows, 'fallback section rendered with no rows').toBeGreaterThan(0);
|
|
testLogger.info(`database-reported fallback rows: ${serverRows}`);
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// P1: SCOPE SURVIVES A TAB SWITCH
|
|
// =========================================================================
|
|
|
|
test('P1: the scope chip survives moving between tabs', {
|
|
tag: ['@dbm', '@infra', '@P1', '@all'],
|
|
}, async ({ page }) => {
|
|
await dbm.navigateWithScope('tableHealth', { system: 'postgresql' });
|
|
await dbm.expectLoaded();
|
|
|
|
const before = await dbm.getInstanceChipText();
|
|
await dbm.openTab(DBM_TABS.activity);
|
|
await expect(page).toHaveURL(/\/infra\/databases\/activity/, { timeout: 20000 });
|
|
|
|
// The tabs are views of ONE dataset over ONE scope; a filter that silently
|
|
// resets on tab switch makes the numbers across tabs incomparable.
|
|
const after = await dbm.getInstanceChipText();
|
|
expect(after, 'the instance chip changed across a tab switch').toBe(before);
|
|
expect(page.url(), 'the system scope was dropped from the URL').toContain('postgresql');
|
|
});
|
|
});
|