mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +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).
788 lines
31 KiB
TypeScript
788 lines
31 KiB
TypeScript
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
/**
|
|
* Instance metrics for the Databases page — a READ-TIME join against metrics
|
|
* the user's collector already ships. Nothing here ingests anything.
|
|
*
|
|
* Three things this module exists to get right, each a defect in the obvious
|
|
* version:
|
|
*
|
|
* • A connection COUNT means nothing. `postgresql.backends` is rendered
|
|
* against `postgresql.connection.max` as a ratio, and where no limit is
|
|
* published the count says so rather than implying a denominator.
|
|
*
|
|
* • A point-in-time gauge cannot be overlaid on a latency spike, which is the
|
|
* only reason anyone reads these numbers at 3am. Every metric therefore
|
|
* folds to a TIMESTAMPED series, not a scalar.
|
|
*
|
|
* • The two vantages disagree about what an instance is called, and behind a
|
|
* pooler they never agree. A Databases row is the address the CLIENT
|
|
* dialled — `server.address`, port already stripped — while the receiver
|
|
* reports the endpoint it was configured with, `host:port`. Both sides
|
|
* normalise to a bare lowercase host, joined as `(system, host)`. When that
|
|
* fails the row says so and names the likely cause: a blank cell is how a
|
|
* reader concludes the feature is broken rather than unmatched.
|
|
*
|
|
* The engine asymmetries are upstream's, and all three bite: postgresqlreceiver
|
|
* puts the endpoint in `service.instance.id` while mysqlreceiver puts a UUID
|
|
* there and the endpoint in `mysql.instance.endpoint`; mysqlreceiver publishes
|
|
* no `max_connections` (the setup card's `sqlquery/mysql_limits` recipe fills
|
|
* the gap); and replication lag is BYTES on one engine and SECONDS on the
|
|
* other.
|
|
*/
|
|
|
|
import type { DbTotalsRow } from "@/services/db_monitoring";
|
|
|
|
export type DbmMetricAggregate = "sum" | "single" | "max";
|
|
|
|
export type DbmMetricRole =
|
|
"connections" | "connectionLimit" | "replicationLag" | "cacheHit" | "cacheRead" | "deadlocks";
|
|
|
|
export interface DbmMetricSpec {
|
|
/** Canonical engine token, matching the client vantage's `db_system`. */
|
|
system: string;
|
|
role: DbmMetricRole;
|
|
/** The metric name as OpenObserve stores it — one stream per metric. */
|
|
stream: string;
|
|
/** Upstream ships it on, so absence means the receiver is not reporting. */
|
|
defaultEnabled: boolean;
|
|
/** A monotonic counter: the window's figure is its delta, not its reading. */
|
|
cumulative: boolean;
|
|
/**
|
|
* How rows sharing a timestamp combine into the instance's figure:
|
|
* `sum` — one row per database, and only their total is the instance's.
|
|
* `single` — one reading per instance, repeated; summing it would multiply
|
|
* the denominator and halve every saturation figure.
|
|
* `max` — one row per REPLICA. An instance's lag is its worst replica's;
|
|
* summing reports a lag no replica actually has.
|
|
*/
|
|
aggregate: DbmMetricAggregate;
|
|
/**
|
|
* Columns that split one instance's rows into separate series — the database
|
|
* on a per-database metric, the replica on replication lag. They are grouped
|
|
* on by the fold, so the SQL projects them from this same list: a column in
|
|
* the grouping but not the SELECT collapses every series into one and lets
|
|
* same-timestamp rows overwrite each other.
|
|
*/
|
|
seriesColumns?: readonly string[];
|
|
/** The column carrying this engine's instance endpoint. */
|
|
identityColumn: string;
|
|
/** Narrows a stream carrying several series, e.g. mysql.threads' kinds. */
|
|
filter?: { column: string; value: string };
|
|
}
|
|
|
|
export interface DbmMetricRow {
|
|
_timestamp: number;
|
|
value: number;
|
|
service_instance_id?: string;
|
|
mysql_instance_endpoint?: string;
|
|
postgresql_database_name?: string;
|
|
[column: string]: unknown;
|
|
}
|
|
|
|
/** One reading and when it was taken — the strip needs both. */
|
|
export interface DbmMetricPoint {
|
|
timestamp: number;
|
|
value: number;
|
|
}
|
|
|
|
export interface DbmMetricSeries {
|
|
/**
|
|
* The window's figure: a gauge's last reading, a counter's delta. `null`
|
|
* when it cannot be determined — one sample of a counter differences to
|
|
* nothing, and calling that `0` is a measurement we never made.
|
|
*/
|
|
latest: number | null;
|
|
series: number[];
|
|
points: DbmMetricPoint[];
|
|
}
|
|
|
|
export type DbmInstanceMetricSet = Partial<Record<DbmMetricRole, DbmMetricSeries>>;
|
|
|
|
export type DbmSaturationState = "measured" | "no-limit" | "absent";
|
|
|
|
export interface DbmSaturation {
|
|
state: DbmSaturationState;
|
|
used: number | null;
|
|
limit: number | null;
|
|
ratio: number | null;
|
|
}
|
|
|
|
/**
|
|
* Every state describes a read that happened — matched, matched nothing, or
|
|
* returned nothing. (A fourth `disabled` state existed while the join sat
|
|
* behind `ZO_DB_MONITORING_INSTANCE_METRICS`; that per-signal knob is gone —
|
|
* with DBM enabled the join always runs.)
|
|
*/
|
|
export type DbmMetricsState = "matched" | "unmatched" | "no-data";
|
|
|
|
export interface DbmReplicationLag {
|
|
value: number;
|
|
/** Postgres reports bytes of WAL; MySQL reports seconds behind the source. */
|
|
unit: "bytes" | "seconds";
|
|
}
|
|
|
|
/** What the metrics read could not do, so the merge can name the right cause. */
|
|
export interface DbmMergeContext {
|
|
failedStreams?: string[];
|
|
}
|
|
|
|
export interface DbmRowMetrics {
|
|
state: DbmMetricsState;
|
|
saturation: DbmSaturation;
|
|
cacheHitRatio: number | null;
|
|
replicationLag: DbmReplicationLag | null;
|
|
deadlocks: number | null;
|
|
connectionSeries: number[];
|
|
connectionPoints: DbmMetricPoint[];
|
|
/** Why no receiver instance matched, so the cell can say it. */
|
|
unmatchedReason: DbmUnmatchedReason | null;
|
|
}
|
|
|
|
export type DbmUnmatchedReason = "pooler" | "loopback" | "no-receiver" | "unreadable";
|
|
|
|
const PG = "postgresql";
|
|
const MYSQL = "mysql";
|
|
const MARIADB = "mariadb";
|
|
|
|
/**
|
|
* Engines that RIDE another engine's metric streams.
|
|
*
|
|
* No mariadb receiver exists upstream — a MariaDB server is scraped by the
|
|
* MYSQL receiver pointed at it, so its readings land in the `mysql_*` streams
|
|
* under `mysql.*` metric names (verified against the capture rig's catalog,
|
|
* which holds no `mariadb_*` metric stream). The join must therefore look a
|
|
* `mariadb` client row up under the `mysql` identity, or the health column
|
|
* stays permanently empty for MariaDB.
|
|
*
|
|
* An ALIAS at the join, deliberately not duplicate `mariadb` catalog entries:
|
|
* duplicated specs would read every mysql stream a second time, and — because
|
|
* the receiver cannot say which engine an endpoint really is — would fabricate
|
|
* a second `mariadb` fleet row for every MySQL instance the metrics discover.
|
|
*/
|
|
const METRIC_SYSTEM_ALIASES: Readonly<Record<string, string>> = { [MARIADB]: MYSQL };
|
|
|
|
/** The engine whose metric streams carry this system's readings, lowercased. */
|
|
export const metricSystemFor = (system: string | null | undefined): string => {
|
|
const engine = (system ?? "").trim().toLowerCase();
|
|
return METRIC_SYSTEM_ALIASES[engine] ?? engine;
|
|
};
|
|
|
|
/** postgresqlreceiver writes its endpoint here; mysqlreceiver writes a UUID. */
|
|
const PG_IDENTITY = "service_instance_id";
|
|
/** mysqlreceiver's endpoint — the only MySQL column worth joining on. */
|
|
const MYSQL_IDENTITY = "mysql_instance_endpoint";
|
|
|
|
/**
|
|
* The metrics we read, and everything about each the reader cannot infer.
|
|
*
|
|
* Stream names are the metric names after OpenObserve's sanitisation (every run
|
|
* outside `[A-Za-z0-9_:]` becomes `_`), so a typo here reads an empty stream
|
|
* forever and is indistinguishable from a receiver that is switched off.
|
|
*
|
|
* Every column name below is a CLAIM about the collector, not a fact about the
|
|
* stream: collectors rename attributes across versions, so a spec'd name — and
|
|
* especially a semconv-promised one — is never trusted ahead of the stream's
|
|
* own schema. The labels name RECEIVER attributes for that reason (the
|
|
* receiver writes `postgresql_database_name` where semconv promises
|
|
* `db.namespace`), and the read layer intersects the spec with the live schema
|
|
* before building SQL (`DbmMetricsCollectOptions.fieldsByStream`).
|
|
*
|
|
* The MySQL `connectionLimit` does NOT come from mysqlreceiver — it publishes
|
|
* no `max_connections`, and its `mysql.connection.count` counts attempts while
|
|
* `mysql.max_used_connections` is a high-water mark, so either under a
|
|
* saturation percentage would be a fabricated denominator. The limit comes
|
|
* from the MySQL setup card's `sqlquery/mysql_limits` recipe (`SELECT
|
|
* @@max_connections` emitted as `mysql.connection.max`), so the stream exists
|
|
* only where that recipe is installed — `defaultEnabled: false`, and a MySQL
|
|
* row without it renders the honest count-with-no-denominator state.
|
|
*/
|
|
export const DBM_INSTANCE_METRICS: readonly DbmMetricSpec[] = [
|
|
{
|
|
system: PG,
|
|
role: "connections",
|
|
stream: "postgresql_backends",
|
|
defaultEnabled: true,
|
|
cumulative: false,
|
|
// One row per database per scrape. The label is the RECEIVER's attribute
|
|
// (`postgresql.database.name`, sanitised), NOT semconv `db.namespace` —
|
|
// see the catalog comment above on trusting schemas over specs.
|
|
aggregate: "sum",
|
|
seriesColumns: ["postgresql_database_name"],
|
|
identityColumn: PG_IDENTITY,
|
|
},
|
|
{
|
|
system: PG,
|
|
role: "connectionLimit",
|
|
stream: "postgresql_connection_max",
|
|
defaultEnabled: true,
|
|
cumulative: false,
|
|
aggregate: "single",
|
|
identityColumn: PG_IDENTITY,
|
|
},
|
|
{
|
|
system: PG,
|
|
role: "replicationLag",
|
|
stream: "postgresql_replication_data_delay",
|
|
defaultEnabled: true,
|
|
cumulative: false,
|
|
// One row per replica (`replication_client` is the replica's address).
|
|
aggregate: "max",
|
|
seriesColumns: ["replication_client"],
|
|
identityColumn: PG_IDENTITY,
|
|
},
|
|
{
|
|
system: PG,
|
|
role: "cacheHit",
|
|
stream: "postgresql_blks_hit",
|
|
defaultEnabled: false,
|
|
cumulative: true,
|
|
aggregate: "sum",
|
|
seriesColumns: ["postgresql_database_name"],
|
|
identityColumn: PG_IDENTITY,
|
|
},
|
|
{
|
|
system: PG,
|
|
role: "cacheRead",
|
|
stream: "postgresql_blks_read",
|
|
defaultEnabled: false,
|
|
cumulative: true,
|
|
aggregate: "sum",
|
|
seriesColumns: ["postgresql_database_name"],
|
|
identityColumn: PG_IDENTITY,
|
|
},
|
|
{
|
|
system: PG,
|
|
role: "deadlocks",
|
|
stream: "postgresql_deadlocks",
|
|
defaultEnabled: false,
|
|
cumulative: true,
|
|
aggregate: "sum",
|
|
seriesColumns: ["postgresql_database_name"],
|
|
identityColumn: PG_IDENTITY,
|
|
},
|
|
{
|
|
system: MYSQL,
|
|
role: "connections",
|
|
stream: "mysql_threads",
|
|
defaultEnabled: true,
|
|
cumulative: false,
|
|
aggregate: "sum",
|
|
identityColumn: MYSQL_IDENTITY,
|
|
// One stream carries all four thread kinds; `created` is a lifetime total,
|
|
// and summing it into the connection count reads as massive saturation.
|
|
filter: { column: "kind", value: "connected" },
|
|
},
|
|
{
|
|
system: MYSQL,
|
|
role: "replicationLag",
|
|
stream: "mysql_replica_time_behind_source",
|
|
defaultEnabled: false,
|
|
cumulative: false,
|
|
aggregate: "max",
|
|
identityColumn: MYSQL_IDENTITY,
|
|
},
|
|
// One stream, two roles. Postgres publishes hits and disk reads as two
|
|
// separate metrics; mysqlreceiver publishes `mysql.buffer_pool.operations`
|
|
// with an `operation` dimension, so both roles read the same stream and are
|
|
// told apart by `filter` — the arrangement `connections` already uses for
|
|
// `mysql_threads.kind`.
|
|
//
|
|
// `defaultEnabled: true` is the receiver's own default (`enabled: true` on
|
|
// mysql.buffer_pool.operations in its metadata.yaml), and the shipped setup
|
|
// card declares no `metrics:` block, so these arrive with no config change.
|
|
//
|
|
// The two values are NOT disjoint — `read_requests` counts every logical
|
|
// read and `reads` is the subset that missed. `metricsForSet` routes MySQL
|
|
// through `overlappingCacheHitRatio` for that reason; see its doc comment.
|
|
{
|
|
system: MYSQL,
|
|
role: "cacheHit",
|
|
stream: "mysql_buffer_pool_operations",
|
|
defaultEnabled: true,
|
|
cumulative: true,
|
|
aggregate: "sum",
|
|
identityColumn: MYSQL_IDENTITY,
|
|
filter: { column: "operation", value: "read_requests" },
|
|
},
|
|
{
|
|
system: MYSQL,
|
|
role: "cacheRead",
|
|
stream: "mysql_buffer_pool_operations",
|
|
defaultEnabled: true,
|
|
cumulative: true,
|
|
aggregate: "sum",
|
|
identityColumn: MYSQL_IDENTITY,
|
|
filter: { column: "operation", value: "reads" },
|
|
},
|
|
{
|
|
system: MYSQL,
|
|
role: "connectionLimit",
|
|
// `mysql.connection.max` from the setup card's sqlquery/mysql_limits
|
|
// recipe (see the module note above) — the twin of
|
|
// `postgresql_connection_max`, and `single` for the same reason: one
|
|
// reading per instance, and summing repeats of it would multiply the
|
|
// denominator and halve every saturation figure.
|
|
stream: "mysql_connection_max",
|
|
defaultEnabled: false,
|
|
cumulative: false,
|
|
aggregate: "single",
|
|
identityColumn: MYSQL_IDENTITY,
|
|
},
|
|
];
|
|
|
|
const IDENTITY_COLUMNS: Record<string, string> = {
|
|
[PG]: PG_IDENTITY,
|
|
[MYSQL]: MYSQL_IDENTITY,
|
|
};
|
|
|
|
/** The key's separator. Not a colon — an IPv6 host is full of those. */
|
|
const KEY_SEPARATOR = "|";
|
|
|
|
/** A stream name is interpolated as a table name and can never be escaped. */
|
|
const SAFE_STREAM = /^[A-Za-z0-9_:-]+$/;
|
|
|
|
/**
|
|
* A receiver endpoint or a client address, reduced to the bare host the two
|
|
* vantages can agree on: lowercased, port removed, trailing root dot dropped.
|
|
*
|
|
* The port has to go because the client side never has one — the span
|
|
* canonicalizer strips it — so keeping it would mean the two never match.
|
|
*/
|
|
export const normalizeInstanceHost = (value: string | null | undefined): string | null => {
|
|
const raw = (value ?? "").trim();
|
|
if (!raw) return null;
|
|
|
|
let host = raw;
|
|
if (host.startsWith("[")) {
|
|
// [IPv6]:port — the brackets exist precisely to delimit the address.
|
|
const end = host.indexOf("]");
|
|
if (end > 0) host = host.slice(1, end);
|
|
} else {
|
|
const colon = host.lastIndexOf(":");
|
|
// Only a trailing all-digit segment is a port, and only when there is a
|
|
// single colon: a bare IPv6 address is nothing but colons and must survive
|
|
// whole. `:5432` strips to nothing, which is correctly no host at all.
|
|
if (colon >= 0 && colon === host.indexOf(":") && /^\d+$/.test(host.slice(colon + 1))) {
|
|
host = host.slice(0, colon);
|
|
}
|
|
}
|
|
|
|
host = host.replace(/\.$/, "").trim().toLowerCase();
|
|
return host || null;
|
|
};
|
|
|
|
/**
|
|
* The join key. The engine is part of it because two engines routinely share a
|
|
* host, and folding them would report one's connections under the other.
|
|
*/
|
|
export const instanceIdentityKey = (
|
|
system: string | null | undefined,
|
|
host: string | null | undefined,
|
|
): string | null => {
|
|
const engine = (system ?? "").trim().toLowerCase();
|
|
const resolved = normalizeInstanceHost(host);
|
|
if (!engine || !resolved) return null;
|
|
return `${engine}${KEY_SEPARATOR}${resolved}`;
|
|
};
|
|
|
|
/** Split a key back into its parts. The host may itself contain colons. */
|
|
export const splitIdentityKey = (key: string): { system: string; host: string } | null => {
|
|
const at = key.indexOf(KEY_SEPARATOR);
|
|
if (at <= 0) return null;
|
|
const host = key.slice(at + KEY_SEPARATOR.length);
|
|
return host ? { system: key.slice(0, at), host } : null;
|
|
};
|
|
|
|
/**
|
|
* The instance a metric row describes, read from the column THIS engine
|
|
* publishes it in. MySQL's `service_instance_id` is a UUID v5 of the endpoint,
|
|
* so falling back to it would produce a populated key that joins to nothing.
|
|
*/
|
|
export const receiverHostOf = (row: DbmMetricRow, system: string): string | null => {
|
|
const column = IDENTITY_COLUMNS[(system ?? "").trim().toLowerCase()];
|
|
if (!column) return null;
|
|
const value = row[column];
|
|
return typeof value === "string" ? normalizeInstanceHost(value) : null;
|
|
};
|
|
|
|
/**
|
|
* One metric stream over the window.
|
|
*
|
|
* Both bounds are repeated in the SQL even though the search API is given the
|
|
* same window: the API's window selects partitions, and a predicate is what
|
|
* makes the query correct on its own terms.
|
|
*/
|
|
export const buildInstanceMetricsSql = (
|
|
spec: DbmMetricSpec,
|
|
window: { startTime: number; endTime: number },
|
|
): string => {
|
|
if (!SAFE_STREAM.test(spec.stream)) {
|
|
throw new Error(`unsafe metrics stream name: ${spec.stream}`);
|
|
}
|
|
if (!Number.isFinite(window.startTime) || !Number.isFinite(window.endTime)) {
|
|
throw new Error("metrics window bounds must be finite");
|
|
}
|
|
// Newest FIRST. The search API caps the rows it returns, and ordered oldest
|
|
// first that cap silently discards the most recent readings — "latest" then
|
|
// becomes a value from early in the window and a saturated instance reads as
|
|
// calm. The fold re-sorts, so the cap costs history rather than currency.
|
|
//
|
|
// The filter column is PROJECTED as well as constrained. `foldMetricRows`
|
|
// re-checks the filter against the row it is handed, so a column that is in
|
|
// the WHERE but not the SELECT reads back `undefined`, fails its own filter,
|
|
// and drops every row — with the query and the fold each correct alone.
|
|
const filter = spec.filter ? ` AND ${spec.filter.column} = '${spec.filter.value}'` : "";
|
|
// Every column the fold reads back — the filter it re-checks and the columns
|
|
// it groups series by — has to be in the projection, and both come from the
|
|
// spec so the query and the fold cannot drift apart.
|
|
const extra = [...(spec.seriesColumns ?? []), ...(spec.filter ? [spec.filter.column] : [])];
|
|
const extraColumns = extra.length ? `, ${extra.join(", ")}` : "";
|
|
return (
|
|
`SELECT _timestamp, value, ${spec.identityColumn}${extraColumns} ` +
|
|
`FROM "${spec.stream}" ` +
|
|
`WHERE _timestamp >= ${window.startTime} AND _timestamp <= ${window.endTime}${filter} ` +
|
|
`ORDER BY _timestamp DESC`
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Rows to one series per instance.
|
|
*
|
|
* Rows are grouped per SERIES first — one instance can emit a row per database
|
|
* or a row per replica — and only then combined per the spec's `aggregate`.
|
|
* The order matters for a cumulative counter: summing the databases and then
|
|
* differencing reads a database that came into view mid-window as a burst of
|
|
* activity, because it brings its whole since-boot total with it. Differencing
|
|
* each series and summing the deltas cannot.
|
|
*
|
|
* A cumulative counter reports its window DELTA. A counter that went backwards
|
|
* restarted, so the post-reset reading is the most the window can honestly
|
|
* claim; a single sample differences to nothing at all and reports `null`
|
|
* rather than a zero that would read as a clean bill of health.
|
|
*/
|
|
export const foldMetricRows = (
|
|
rows: DbmMetricRow[],
|
|
spec: DbmMetricSpec,
|
|
): Map<string, DbmMetricSeries> => {
|
|
// instance -> series (database, replica, or the instance itself) -> samples
|
|
const byInstance = new Map<string, Map<string, Map<number, number>>>();
|
|
|
|
for (const row of rows) {
|
|
if (spec.filter && String(row[spec.filter.column] ?? "") !== spec.filter.value) continue;
|
|
const key = instanceIdentityKey(spec.system, receiverHostOf(row, spec.system));
|
|
if (!key) continue;
|
|
|
|
const value = Number(row.value);
|
|
const timestamp = Number(row._timestamp);
|
|
if (!Number.isFinite(value) || !Number.isFinite(timestamp)) continue;
|
|
|
|
let series = byInstance.get(key);
|
|
if (!series) {
|
|
series = new Map<string, Map<number, number>>();
|
|
byInstance.set(key, series);
|
|
}
|
|
const seriesKey = seriesKeyOf(row, spec);
|
|
let samples = series.get(seriesKey);
|
|
if (!samples) {
|
|
samples = new Map<number, number>();
|
|
series.set(seriesKey, samples);
|
|
}
|
|
// Two rows of ONE series at one timestamp is a duplicate, not a quantity.
|
|
samples.set(timestamp, value);
|
|
}
|
|
|
|
const folded = new Map<string, DbmMetricSeries>();
|
|
for (const [key, series] of byInstance) {
|
|
const perSeries = [...series.values()].map((samples) =>
|
|
[...samples.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([timestamp, value]) => ({ timestamp, value })),
|
|
);
|
|
const points = combinePoints(perSeries, spec.aggregate);
|
|
folded.set(key, {
|
|
latest: instanceFigure(perSeries, spec),
|
|
series: points.map((point) => point.value),
|
|
points,
|
|
});
|
|
}
|
|
return folded;
|
|
};
|
|
|
|
/**
|
|
* What distinguishes two rows of one instance at one timestamp — read from the
|
|
* catalog, not guessed from column names. A metric with no series columns is
|
|
* one series, and its duplicate rows at a timestamp are the same reading
|
|
* twice rather than a quantity to add up.
|
|
*/
|
|
const seriesKeyOf = (row: DbmMetricRow, spec: DbmMetricSpec): string =>
|
|
(spec.seriesColumns ?? []).map((column) => String(row[column] ?? "")).join("\u0000");
|
|
|
|
/** The per-timestamp shape the strip draws, combined across the instance's series. */
|
|
const combinePoints = (
|
|
perSeries: DbmMetricPoint[][],
|
|
aggregate: DbmMetricAggregate,
|
|
): DbmMetricPoint[] => {
|
|
const byTimestamp = new Map<number, number>();
|
|
for (const points of perSeries) {
|
|
for (const point of points) {
|
|
const existing = byTimestamp.get(point.timestamp);
|
|
if (existing === undefined) {
|
|
byTimestamp.set(point.timestamp, point.value);
|
|
} else if (aggregate === "sum") {
|
|
byTimestamp.set(point.timestamp, existing + point.value);
|
|
} else if (aggregate === "max") {
|
|
byTimestamp.set(point.timestamp, Math.max(existing, point.value));
|
|
}
|
|
}
|
|
}
|
|
return [...byTimestamp.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([timestamp, value]) => ({ timestamp, value }));
|
|
};
|
|
|
|
/**
|
|
* The instance's figure for the window.
|
|
*
|
|
* A cumulative counter is differenced PER SERIES and the deltas summed, so a
|
|
* database appearing or disappearing partway through cannot manufacture one. A
|
|
* gauge takes the newest reading of each series and combines those.
|
|
*/
|
|
const instanceFigure = (perSeries: DbmMetricPoint[][], spec: DbmMetricSpec): number | null => {
|
|
const figures = perSeries
|
|
.map((points) => seriesFigure(points, spec.cumulative))
|
|
.filter((value): value is number => value !== null);
|
|
if (figures.length === 0) return null;
|
|
if (spec.aggregate === "max") return Math.max(...figures);
|
|
if (spec.aggregate === "single") return figures[0];
|
|
return figures.reduce((total, value) => total + value, 0);
|
|
};
|
|
|
|
const seriesFigure = (points: DbmMetricPoint[], cumulative: boolean): number | null => {
|
|
if (points.length === 0) return null;
|
|
const last = points[points.length - 1].value;
|
|
if (!cumulative) return last;
|
|
// One sample cannot be differenced — that is unknown, not zero.
|
|
if (points.length < 2) return null;
|
|
const first = points[0].value;
|
|
// Went backwards: the counter restarted, so everything before it is lost and
|
|
// the post-reset reading is the floor of what this window saw.
|
|
return last >= first ? last - first : last;
|
|
};
|
|
|
|
/**
|
|
* Connections against the limit.
|
|
*
|
|
* The three states are genuinely different facts and each renders differently:
|
|
* a ratio we measured, a count with no published limit (every MySQL instance,
|
|
* permanently), and no reading at all.
|
|
*/
|
|
export const connectionSaturation = (
|
|
used: number | null | undefined,
|
|
limit: number | null | undefined,
|
|
): DbmSaturation => {
|
|
// A negative count cannot be a real reading, and -5% is a figure the page
|
|
// would have to explain rather than report.
|
|
const current =
|
|
Number.isFinite(used as number) && (used as number) >= 0 ? (used as number) : null;
|
|
const ceiling =
|
|
Number.isFinite(limit as number) && (limit as number) > 0 ? (limit as number) : null;
|
|
if (current === null) return { state: "absent", used: null, limit: ceiling, ratio: null };
|
|
if (ceiling === null) return { state: "no-limit", used: current, limit: null, ratio: null };
|
|
// Deliberately uncapped: being over the limit is the thing worth seeing.
|
|
return { state: "measured", used: current, limit: ceiling, ratio: current / ceiling };
|
|
};
|
|
|
|
/**
|
|
* Hits as a share of all block reads.
|
|
*
|
|
* A window that touched no blocks is `null`, not 1 — "100% cache hit" on an
|
|
* idle database is a measurement nobody made.
|
|
*/
|
|
export const cacheHitRatio = (
|
|
hits: number | null | undefined,
|
|
reads: number | null | undefined,
|
|
): number | null => {
|
|
if (!Number.isFinite(hits as number) || !Number.isFinite(reads as number)) return null;
|
|
const hit = hits as number;
|
|
const read = reads as number;
|
|
if (hit < 0 || read < 0) return null;
|
|
const total = hit + read;
|
|
return total > 0 ? hit / total : null;
|
|
};
|
|
|
|
/**
|
|
* The same ratio where the two counters OVERLAP instead of partitioning.
|
|
*
|
|
* Postgres's `blks_hit` and `blks_read` are disjoint — a block read is either a
|
|
* cache hit or a disk read — so the share is `hit / (hit + read)`.
|
|
*
|
|
* InnoDB does not count that way. `Innodb_buffer_pool_read_requests` is EVERY
|
|
* logical read, and `Innodb_buffer_pool_reads` is the subset of those that
|
|
* missed and went to disk, so `reads` is already inside `requests`. Feeding
|
|
* these to `cacheHitRatio` would compute `requests / (requests + reads)` and
|
|
* understate the ratio on exactly the busy, disk-bound instances the column
|
|
* exists to surface — a 90% real hit rate would render as 91.7% only because
|
|
* the denominator is inflated, and a genuinely bad instance would still read
|
|
* as healthy. Hence `1 - reads/requests`, the standard InnoDB formula.
|
|
*
|
|
* Clamped at 0: the two counters are sampled from one status snapshot but the
|
|
* server may increment them between reads, so `reads > requests` is possible by
|
|
* a hair, and a negative "hit rate" is not a thing.
|
|
*/
|
|
export const overlappingCacheHitRatio = (
|
|
totalRequests: number | null | undefined,
|
|
diskReads: number | null | undefined,
|
|
): number | null => {
|
|
if (!Number.isFinite(totalRequests as number) || !Number.isFinite(diskReads as number)) {
|
|
return null;
|
|
}
|
|
const requests = totalRequests as number;
|
|
const reads = diskReads as number;
|
|
if (requests < 0 || reads < 0) return null;
|
|
// Same rule as the disjoint form: an idle window measured nothing.
|
|
if (requests === 0) return null;
|
|
return Math.max(0, 1 - reads / requests);
|
|
};
|
|
|
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
|
|
/**
|
|
* The likeliest reason a client-vantage row found no receiver instance. Each
|
|
* points at a different fix, which is why the cell names one.
|
|
*/
|
|
export const unmatchedReason = (
|
|
system: string,
|
|
host: string | null | undefined,
|
|
knownKeys: string[],
|
|
): DbmUnmatchedReason => {
|
|
// The ALIASED engine: a MariaDB row's metrics live under the mysql keys, so
|
|
// "is this engine reported at all" must ask about mysql or every MariaDB
|
|
// miss blames a receiver that is running.
|
|
const engine = metricSystemFor(system);
|
|
const resolved = normalizeInstanceHost(host);
|
|
// Upstream swaps a loopback endpoint for the collector's own hostname, so a
|
|
// client row pointing at localhost cannot match however healthy everything is.
|
|
if (resolved && LOOPBACK_HOSTS.has(resolved)) return "loopback";
|
|
const engineReported = knownKeys.some((key) => key.startsWith(`${engine}${KEY_SEPARATOR}`));
|
|
// The receiver watches this engine, just not at the address the client
|
|
// dialled — which is what a pooler in between looks like.
|
|
return engineReported ? "pooler" : "no-receiver";
|
|
};
|
|
|
|
const EMPTY_SATURATION: DbmSaturation = { state: "absent", used: null, limit: null, ratio: null };
|
|
|
|
// Aliased: MariaDB's lag rides `mysql.replica.time_behind_source`, which is
|
|
// seconds behind the source — the mysql semantics, not Postgres's WAL bytes.
|
|
const lagUnitFor = (system: string): "bytes" | "seconds" =>
|
|
metricSystemFor(system) === MYSQL ? "seconds" : "bytes";
|
|
|
|
/**
|
|
* How this engine's two cache counters relate.
|
|
*
|
|
* Aliased through `metricSystemFor`, so MariaDB — which rides mysqlreceiver's
|
|
* streams — gets the InnoDB reading rather than the Postgres one.
|
|
*/
|
|
const cacheHitRatioFor = (system: string) =>
|
|
metricSystemFor(system) === MYSQL ? overlappingCacheHitRatio : cacheHitRatio;
|
|
|
|
/** The row-shaped view of one instance's metrics. */
|
|
export const metricsForSet = (system: string, set: DbmInstanceMetricSet): DbmRowMetrics => {
|
|
const lag = set.replicationLag?.latest;
|
|
return {
|
|
state: "matched",
|
|
saturation: connectionSaturation(set.connections?.latest, set.connectionLimit?.latest),
|
|
cacheHitRatio: cacheHitRatioFor(system)(set.cacheHit?.latest, set.cacheRead?.latest),
|
|
replicationLag:
|
|
lag === null || lag === undefined ? null : { value: lag, unit: lagUnitFor(system) },
|
|
deadlocks: set.deadlocks?.latest ?? null,
|
|
connectionSeries: set.connections?.series ?? [],
|
|
connectionPoints: set.connections?.points ?? [],
|
|
unmatchedReason: null,
|
|
};
|
|
};
|
|
|
|
export const absentMetrics = (
|
|
state: DbmMetricsState,
|
|
unmatched: DbmUnmatchedReason | null,
|
|
): DbmRowMetrics => ({
|
|
state,
|
|
saturation: EMPTY_SATURATION,
|
|
cacheHitRatio: null,
|
|
replicationLag: null,
|
|
deadlocks: null,
|
|
connectionSeries: [],
|
|
connectionPoints: [],
|
|
unmatchedReason: unmatched,
|
|
});
|
|
|
|
/** Whether a stream we could not read was one this engine would have used. */
|
|
const engineHadUnreadableStream = (system: string, failedStreams: string[]): boolean => {
|
|
// Aliased for the same reason as the key lookup: MariaDB's readings come
|
|
// from the mysql streams, so an unreadable mysql stream is ITS problem too.
|
|
const engine = metricSystemFor(system);
|
|
const streams = new Set(
|
|
DBM_INSTANCE_METRICS.filter((spec) => spec.system === engine).map((spec) => spec.stream),
|
|
);
|
|
return failedStreams.some((stream) => streams.has(stream));
|
|
};
|
|
|
|
/**
|
|
* The metrics for one client-vantage row, or a stated reason there are none.
|
|
*/
|
|
export const resolveRowMetrics = (
|
|
system: string,
|
|
instance: string,
|
|
metricsByKey: Map<string, DbmInstanceMetricSet>,
|
|
context: DbmMergeContext = {},
|
|
): DbmRowMetrics => {
|
|
const failedStreams = context.failedStreams ?? [];
|
|
// The ALIASED system: the fold keys every reading under the engine whose
|
|
// receiver produced it, so a MariaDB row must look itself up under mysql.
|
|
const key = instanceIdentityKey(metricSystemFor(system), instance);
|
|
const set = key ? metricsByKey.get(key) : undefined;
|
|
if (set) return metricsForSet(system, set);
|
|
|
|
// Nothing was read at all — the page asked and got nothing back. That is not
|
|
// the same claim as "the receiver has never heard of this instance".
|
|
if (metricsByKey.size === 0 && failedStreams.length === 0) {
|
|
return absentMetrics("no-data", null);
|
|
}
|
|
// A stream we could not READ is a permission or retention problem, not a
|
|
// collector that is switched off, and sends the reader somewhere else.
|
|
const reason = engineHadUnreadableStream(system, failedStreams)
|
|
? "unreadable"
|
|
: unmatchedReason(system, instance, [...metricsByKey.keys()]);
|
|
return absentMetrics("unmatched", reason);
|
|
};
|
|
|
|
/**
|
|
* Attach each row's instance metrics, or state why there are none.
|
|
*
|
|
* Strictly additive: every input row comes back, in order, with its query-side
|
|
* figures untouched. A metrics failure must never remove or reorder a database
|
|
* the page would otherwise show.
|
|
*/
|
|
export const mergeInstanceMetrics = <T extends DbTotalsRow>(
|
|
rows: T[],
|
|
metricsByKey: Map<string, DbmInstanceMetricSet>,
|
|
context: DbmMergeContext = {},
|
|
): (T & { metrics?: DbmRowMetrics })[] =>
|
|
rows.map((row) => ({
|
|
...row,
|
|
metrics: resolveRowMetrics(row.db_system, row.db_instance, metricsByKey, context),
|
|
}));
|