From 3966dc93b0ee988dc090d42d1b749242bea8c608 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 18 Sep 2026 16:40:28 -0400
Subject: [PATCH 1/9] PostgreSQL's log is read by a classifier now, not by two
regexes: errors, connections and lock waits land in pg_log_events off the
same tail and the same RDS API the deadlock and plan readers use, redacted
with the plan parser's own patterns (#3601)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The server log is the engine's primary event record and Darling read exactly two shapes out of it.
This makes the shared part of those two readers explicit — PgServerLogTail is the one spelling of the
pg_read_file tailer both siblings now splice byte-for-byte, PgLogEntryAssembler is the prefix/zone/
companion-line assembly done once — and adds the classifier on top: IPgLogFamilyParser is the seam,
three families ship with parsers (error, connection, lock_wait), three are recognised and stored under
their own name for #3602/#3603 to structure (temp_file, autovacuum, checkpoint). One cursor, every
parser on every line, one identity per entry (raw_line_hash) for the overlapping tail to dedupe on.
V129 creates collect.pg_log_events exactly as the generator emits it (one index; the family index is a
separate rung for V104's reason). Both transports: PgLogEventsCollector (self-hosted; the body crosses
the wire so no family's recogniser is spelled twice) and RdsLogEventIngestor (its own RdsLogSource,
marker committed after the write). get_pg_log_events reads it with the #3594/#3613 page contract.
Retention 30 d, operator-tunable. Censuses moved: 28 PG collectors, 70 collector tables, 71 hypertables
(workflows 73/84), 34 get_pg_* tools, 152 tools.
---
.github/workflows/build.yml | 8 +-
.github/workflows/nightly.yml | 8 +-
.../CollectorMeasurementSeamTests.cs | 4 +-
.../ConsumedTimestampFrameDisciplineTests.cs | 16 +-
.../DarlingPgReadSqlParsesLiveTests.cs | 4 +
.../DeltaFamilyIntervalCompletionRungTests.cs | 32 +-
.../Darling.Tests/PgLogEventsPipelineTests.cs | 922 ++++++++++++++++++
.../Darling.Tests/PgSchemaGeneratorTests.cs | 7 +-
Darling/Darling.Tests/ServerPageTabsTests.cs | 1 +
.../DarlingCollectorRunner.cs | 34 +
.../DarlingWebEndpoints.cs | 2 +
.../DarlingWorker.cs | 14 +-
.../Mcp/DarlingMcpHostService.cs | 3 +
.../Mcp/DarlingMcpInstructions.cs | 2 +-
.../Mcp/DarlingMcpPgLogEventTools.cs | 161 +++
.../Targets/RdsLogEventIngestor.cs | 208 ++++
.../wwwroot/js/pages/server-tabs.js | 32 +
.../DarlingPgLogEventReader.cs | 181 ++++
.../PgMigrations.cs | 60 ++
.../StorageVersion.cs | 2 +-
.../ViewerDataService.Postgres.cs | 10 +
.../ViewerDataService.cs | 31 +-
.../ViewerPostgresTabs.cs | 2 +-
.../ViewerServerTab.Postgres.cs | 35 +
.../ViewerServerTab.xaml | 33 +
Darling/README.md | 10 +-
.../CrossAppMcpToolInventoryPinTests.cs | 2 +
Lite.Tests/LogTailOverlapThresholdPinTests.cs | 25 +-
Lite.Tests/PgServerLogPathPinTests.cs | 12 +-
.../CollectorCatalog.cs | 4 +
.../CollectorEngineCapability.cs | 3 +
.../CollectorScheduleDefaults.cs | 19 +
.../IPgLogFamilyParser.cs | 120 +++
.../PgDeadlocksCollector.cs | 42 +-
PerformanceMonitor.Collectors/PgLogEntry.cs | 95 ++
.../PgLogEntryAssembler.cs | 297 ++++++
PerformanceMonitor.Collectors/PgLogEvent.cs | 86 ++
.../PgLogEventClassifier.cs | 104 ++
.../PgLogEventsCollector.cs | 155 +++
.../PgLogFamilyParsers.cs | 201 ++++
.../PgLogTextRedactor.cs | 119 +++
.../PgPlanCaptureCollector.cs | 62 +-
.../PgPlanLogParser.cs | 9 +-
.../PgServerLogTail.cs | 102 ++
docs/postgres-first-target-runbook.md | 36 +-
45 files changed, 3162 insertions(+), 153 deletions(-)
create mode 100644 Darling/Darling.Tests/PgLogEventsPipelineTests.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLogEventTools.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Storage/DarlingPgLogEventReader.cs
create mode 100644 PerformanceMonitor.Collectors/IPgLogFamilyParser.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogEntry.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogEntryAssembler.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogEvent.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogEventClassifier.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogEventsCollector.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogFamilyParsers.cs
create mode 100644 PerformanceMonitor.Collectors/PgLogTextRedactor.cs
create mode 100644 PerformanceMonitor.Collectors/PgServerLogTail.cs
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4b1b1b7a42..dec9f6e6f4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -925,8 +925,8 @@ jobs:
#
# The values are the product's own derivation from the live hypertable count
# (TimescaleSupport.HypertableCount = the 62-collector catalog + collection_log = 63):
- # timescaledb.max_background_workers = HypertableCount + 2 = 72
- # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 83
+ # timescaledb.max_background_workers = HypertableCount + 2 = 73
+ # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 84
# Hard-coded here because a workflow cannot call into the product — so
# CiClusterWorkerSizingTests parses THIS FILE and fails the build if either number stops
# matching the formula as collectors are added, and CiClusterWorkerSizingLiveTests asserts
@@ -944,8 +944,8 @@ jobs:
Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb'"
Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541"
Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'"
- Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 72"
- Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 83"
+ Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 73"
+ Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 84"
& "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start
if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" }
& "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index f37b0d9ea8..6114b598bf 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -437,8 +437,8 @@ jobs:
# customer runs and made scheduler-racing failures luck-of-the-slot instead of reproducible.
# Values are the product's own derivation from the live hypertable count
# (TimescaleSupport.HypertableCount = the 62-collector catalog + collection_log = 63):
- # timescaledb.max_background_workers = HypertableCount + 2 = 72
- # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 83
+ # timescaledb.max_background_workers = HypertableCount + 2 = 73
+ # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 84
# Kept honest by CiClusterWorkerSizingTests (parses this file against the formula) and
# CiClusterWorkerSizingLiveTests (asserts the running cluster serves them). Must configure
# the cluster identically to build.yml's darling-pg job: that guard parses the appended
@@ -459,8 +459,8 @@ jobs:
Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb'"
Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541"
Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'"
- Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 72"
- Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 83"
+ Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 73"
+ Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 84"
& "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start
if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" }
& "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling
diff --git a/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs b/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs
index 6c7ad29224..f00a6ab3e3 100644
--- a/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs
+++ b/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs
@@ -288,7 +288,7 @@ than in whatever order the last cycle happened to touch them. */
[Fact]
public void AnOrdinaryRunLeavesTheColumnNull_ExactlyAsBefore()
{
- /* 69 collectors measure nothing. None of them may start writing a note. */
+ /* 70 collectors measure nothing. None of them may start writing a note. */
Assert.Null(CollectorMeasurementNote.Render(CollectorContext.NoMeasurements));
Assert.Null(CollectorMeasurementNote.Compose(null, CollectorContext.NoMeasurements));
Assert.Null(new CollectorRunResult(12, 34, 56, CollectorContext.NoMeasurements).Note);
@@ -435,7 +435,7 @@ An argument it CANNOT resolve FAILS rather than being skipped. That is the direc
a skip would silently exempt exactly the case a scanner cannot reason about.
WHAT THIS SCAN CANNOT SEE, stated rather than assumed. It reads PerformanceMonitor.Collectors
- only - which is where every definition lives (69 concrete ones, flat, one-to-one with
+ only - which is where every definition lives (70 concrete ones, flat, one-to-one with
CollectorCatalog.All), but Measure is public, so a HOST could call it and this would not know.
Widening the sweep would be worse rather than better: `.Measure(` is a name this codebase uses
heavily for unrelated things - WPF's UIElement.Measure(Size), and the Compose subsystem's
diff --git a/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs b/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs
index 08b52bd542..64b054faf1 100644
--- a/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs
+++ b/Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs
@@ -72,11 +72,12 @@ private enum ClockFrame
/* Floors, so a broken walk cannot report a clean bill of health. Measured on dev at cb208f6a4:
66 Timestamp columns over 69 catalog definitions, 49 of them on SqlServer definitions and 17 on
- PostgreSql ones. Pinned exactly rather than as a floor because the whole point is a closed census —
- a floor would let a column vanish. */
- private const int TimestampColumnCount = 66;
+ PostgreSql ones; #3601 added pg_log_events.occurred_at, so 67 over 70, 18 PostgreSql. Pinned exactly
+ rather than as a floor because the whole point is a closed census — a floor would let a column
+ vanish. */
+ private const int TimestampColumnCount = 67;
private const int SqlServerTimestampColumnCount = 49;
- private const int PostgresTimestampColumnCount = 17;
+ private const int PostgresTimestampColumnCount = 18;
private static IReadOnlyList<(string Table, string Column, string Collector, CollectorTargetEngine Engine)>
TimestampColumns() =>
@@ -92,13 +93,14 @@ a floor would let a column vanish. */
/// The census is the catalog's own columns, and the engine
/// split is rather than a name prefix or a hand list.
///
- /// The T-SQL classifier below reaches the SqlServer arm ONLY. The 17 PostgreSQL columns are a
+ /// The T-SQL classifier below reaches the SqlServer arm ONLY. The 18 PostgreSQL columns are a
/// different provenance mechanism, not a shortfall of this one: PostgreSQL has no
/// column = expression alias form at all (there it is a boolean comparison), so those collectors
/// write expression AS column, and their timestamps arrive as timestamptz normalised in
/// four different spellings — AT TIME ZONE 'UTC' inline, the same inside an interpolated SQL
- /// fragment, a bare min() handled in the reader, and two computed entirely in C#
- /// (pg_cpu_utilization.sample_time, pg_deadlocks.occurred_at). Counting them here is what
+ /// fragment, a bare min() handled in the reader, and three computed entirely in C#
+ /// (pg_cpu_utilization.sample_time, pg_deadlocks.occurred_at, pg_log_events.occurred_at
+ /// — the last through the same zone check as the deadlock one, #3601). Counting them here is what
/// makes the T-SQL arm's reach a measured fact instead of an implied one.
///
[Fact]
diff --git a/Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs b/Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs
index 896e767c80..1b93ef274e 100644
--- a/Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs
+++ b/Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs
@@ -97,6 +97,10 @@ predicate spliced into CurrentLongRunningSessionsSqlTemplate by BuildCurrentLong
in the parse-checked population, so the fragment is verified where it is used. */
"DarlingPgSessionStatesReader.BackupUtilitiesFilter",
"DarlingPgTableBloatReader.StaleStatisticsChurnRatioSql",
+ /* #3601: the severity CASE expression, spliced into EventsSql so the read and the tool's label-to-rank
+ mapping share one spelling. A fragment, not a statement; the query it is spliced into IS in the
+ parse-checked population, so the fragment is verified where it is used. */
+ "DarlingPgLogEventReader.SeverityRankSql",
};
private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
diff --git a/Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs b/Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs
index 0ace3a7bc0..224a8dd603 100644
--- a/Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs
+++ b/Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs
@@ -25,10 +25,11 @@ namespace Darling.Tests;
/// over, so the calculator's (delta 0, interval 0) "no delta knowable" marker reaches the store from every
/// family, and the restart seed can rebuild the one key the store could not reproduce.
///
-/// This file carries the "I am the top rung" claims that moved off
-/// (V127) when this rung landed, the same handoff that file
-/// received from (V126) — a fully-migrated store must map to
-/// EXACTLY this version, or the viewer's connect-time gate refuses a store that is actually current.
+/// The "I am the top rung" claims this file carried moved to PgLogEventsRungTests (V129) when
+/// that rung landed, the same handoff this file received from
+/// (V127) and that one from (V126). What stays here is the
+/// one-rung-behind half: a store carrying this and not V129 maps to 128, which is the honest answer for it
+/// and what makes the upgrade banner correct in both directions.
///
public sealed class DeltaFamilyIntervalCompletionRungTests
{
@@ -36,7 +37,9 @@ public sealed class DeltaFamilyIntervalCompletionRungTests
private const int PreviousVersion = 127;
- /// This rung's sentinel ordinal in the viewer probe — the newest, so the last argument.
+ /// This rung's sentinel ordinal in the viewer probe. No longer the last argument — V129 appended
+ /// its own — so the invariant that outlives the handoff is that the ordinal is FIXED: a later rung
+ /// appends after it and never shifts it.
private const int ProbeOrdinal = 103;
private const string IntervalColumn = "sample_interval_seconds";
@@ -58,7 +61,9 @@ public void TheRungIsRegisteredAtTheTopOfADenseLadder()
Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version);
Assert.Equal(StorageVersion.SchemaVersion, versions.Max());
- Assert.Equal(RungVersion, StorageVersion.SchemaVersion);
+ /* One below the top since V129 landed; the "RungVersion == StorageVersion.SchemaVersion" half of
+ the top-arm claim moved to PgLogEventsRungTests with the top. */
+ Assert.True(RungVersion < StorageVersion.SchemaVersion, "V128 is expected to sit below the ladder's top now that V129 has landed");
Assert.Equal(versions.Distinct().OrderBy(v => v), versions);
}
@@ -283,7 +288,6 @@ public void TheProbeMapsAFullyMigratedStoreToThisTopRung()
var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs");
Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal);
- Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal);
Assert.Contains("hasDeltaFamilyIntervalCompletion", viewer, StringComparison.Ordinal);
Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion);
@@ -292,13 +296,9 @@ public void TheProbeMapsAFullyMigratedStoreToThisTopRung()
.GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!;
var arity = method.GetParameters().Length;
- /* The top rung's sentinel IS the last argument. */
- Assert.Equal(ProbeOrdinal, arity - 1);
-
- /* Every sentinel true = a fully-migrated store, which must map to exactly this version. Built by
- reflection so the arity tracks the signature. */
- var all = Enumerable.Repeat((object)true, arity).ToArray();
- Assert.Equal(StorageVersion.SchemaVersion, (int)method.Invoke(null, all)!);
+ /* This rung's sentinel sits strictly BELOW the last argument now that V129 has appended its own; the
+ "is the last argument" claim moved to PgLogEventsRungTests with the top. */
+ Assert.True(ProbeOrdinal < arity - 1, "V128's sentinel is expected to sit below the top rung's now that V129 has landed");
/* This rung's own arm answers for a store that stopped here. Expressed as "false above" rather than
as one named ordinal, so a rung landing on top of this one does not quietly turn this case into a
@@ -319,8 +319,8 @@ that method — and returns this build's version rather than a literal that coul
Assert.True(previousArm >= 0, "the previous rung's arm is gone, so this pin is comparing against nothing");
Assert.True(thisArm < previousArm, "the V128 arm sits below the previous rung's, so a current store maps one rung low");
Assert.Contains(
- "return " + StorageVersion.SchemaVersion.ToString(CultureInfo.InvariantCulture) + ";",
- viewer[thisArm..], StringComparison.Ordinal);
+ "return " + RungVersion.ToString(CultureInfo.InvariantCulture) + ";",
+ viewer[thisArm..previousArm], StringComparison.Ordinal);
}
/* ---- the writers ---------------------------------------------------------------------------------- */
diff --git a/Darling/Darling.Tests/PgLogEventsPipelineTests.cs b/Darling/Darling.Tests/PgLogEventsPipelineTests.cs
new file mode 100644
index 0000000000..0029c85d56
--- /dev/null
+++ b/Darling/Darling.Tests/PgLogEventsPipelineTests.cs
@@ -0,0 +1,922 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.RDS;
+using Amazon.RDS.Model;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Service.Targets;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The log-event pipeline (#3601): assembly, the three proving family parsers, the recognised-only
+/// families, redaction, the shared tailer, and both transports.
+///
+/// Fixtures are real log lines. Every prefix-and-message shape below is what PostgreSQL 16–18
+/// writes at stderr under the two log_line_prefix families the deadlock parser already meets
+/// (its own fixtures are the source of the %Q-glued query id and the managed %t:%r:%u@%d:[%p]:
+/// prefix). Identifiers are synthesised; shapes are verbatim.
+///
+public sealed class PgLogEventsPipelineTests
+{
+ /* ---- fixtures ------------------------------------------------------------------------------------ */
+
+ private const string P = "2026-09-18 03:07:12.345 UTC ";
+
+ /// One of everything, self-hosted default prefix, in log order.
+ private const string SelfHostedLog =
+ P + "[4102] LOG: connection received: host=192.0.2.10 port=52345\n"
+ + P + "[4102] LOG: connection authenticated: identity=\"app_rw\" method=scram-sha-256 (/etc/postgresql/pg_hba.conf:117)\n"
+ + P + "[4102] LOG: connection authorized: user=app_rw database=app_db application_name=psql SSL enabled (protocol=TLSv1.3, cipher=TLS_AES_256_GCM_SHA384, bits=256)\n"
+ + P + "[4102] LOG: process 4102 still waiting for ShareLock on transaction 809 after 1000.123 ms\n"
+ + P + "[4102] DETAIL: Process holding the lock: 4099. Wait queue: 4102.\n"
+ + P + "[4102] CONTEXT: while updating tuple (0,7) in relation \"orders\"\n"
+ + P + "[4102] STATEMENT: UPDATE orders SET status = 'shipped', note = 'gift for O''Brien' WHERE id = 42 AND customer_id = 1007\n"
+ + P + "[4102] LOG: process 4102 acquired ShareLock on transaction 809 after 2345.678 ms\n"
+ + P + "[4102] STATEMENT: UPDATE orders SET status = 'shipped', note = 'gift for O''Brien' WHERE id = 42 AND customer_id = 1007\n"
+ + P + "[4102] ERROR: duplicate key value violates unique constraint \"customers_email_key\"\n"
+ + P + "[4102] DETAIL: Key (email)=(someone@example.com) already exists.\n"
+ + P + "[4102] STATEMENT: INSERT INTO customers (email, name) VALUES ('someone@example.com', 'Someone')\n"
+ + P + "[4102] LOG: temporary file: path \"base/pgsql_tmp/pgsql_tmp4102.0\", size 4294967296\n"
+ + P + "[4102] STATEMENT: SELECT * FROM orders ORDER BY created_at\n"
+ + P + "[3001] LOG: automatic vacuum of table \"app_db.public.orders\": index scans: 1\n"
+ + "\tpages: 0 removed, 12345 remain, 12345 scanned (100.00% of total)\n"
+ + "\ttuples: 4567 removed, 890123 remain, 0 are dead but not yet removable\n"
+ + P + "[2999] LOG: checkpoint starting: time\n"
+ + P + "[2999] LOG: checkpoint complete: wrote 42 buffers (0.3%); 0 WAL file(s) added, 0 removed, 1 recycled\n"
+ + P + "[4103] FATAL: password authentication failed for user \"intruder\"\n"
+ + P + "[4103] DETAIL: Connection matched file \"/etc/postgresql/pg_hba.conf\" line 117: \"host all all 0.0.0.0/0 scram-sha-256\"\n"
+ + P + "[4102] LOG: disconnection: session time: 0:00:03.412 user=app_rw database=app_db host=192.0.2.10 port=52345\n"
+ + P + "[1200] LOG: database system is ready to accept connections\n"
+ + P + "[4104] WARNING: there is already a transaction in progress\n"
+ + P + "[4105] ERROR: canceling statement due to user request\n"
+ + P + "[4105] STATEMENT: SELECT pg_sleep(30)\n";
+
+ /// The managed parameter-group prefix, no fractional seconds, %r and %u@%d before the pid.
+ private const string ManagedLog =
+ "2026-09-18 03:07:12 UTC:192.0.2.10(52345):app_rw@app_db:[4102]:ERROR: canceling statement due to statement timeout\n"
+ + "2026-09-18 03:07:12 UTC:192.0.2.10(52345):app_rw@app_db:[4102]:STATEMENT: SELECT count(*) FROM orders WHERE customer_id = 1007\n"
+ + "2026-09-18 03:07:13 UTC::@:[3001]:LOG: checkpoint starting: time\n";
+
+ /// A %e-bearing prefix and a %Q-glued one.
+ private const string PrefixVariantsLog =
+ "2026-09-18 03:07:12.345 UTC [4102] 28P01 FATAL: password authentication failed for user \"app_rw\"\n"
+ + "2026-09-18 03:07:12.345 UTC [1549] 322048460535975151ERROR: deadlock detected\n"
+ + "2026-09-18 03:07:12.345 UTC [1549] 322048460535975151DETAIL: Process 1549 waits for ShareLock on transaction 809; blocked by process 1556.\n"
+ + "\tProcess 1556 waits for ShareLock on transaction 808; blocked by process 1549.\n"
+ + "2026-09-18 03:07:12.345 UTC [1549] 322048460535975151HINT: See server log for query details.\n";
+
+ private static List Classify(string text) => PgLogEventClassifier.Default.Classify(text);
+
+ /* ---- assembly ------------------------------------------------------------------------------------ */
+
+ [Fact]
+ public void TheAssembler_FoldsCompanionLinesAndTabContinuations_IntoOneEntryPerPrimaryLine()
+ {
+ var entries = PgLogEntryAssembler.Assemble(SelfHostedLog);
+
+ /* 15 primary lines in the fixture; DETAIL / CONTEXT / STATEMENT / tab lines are folded, not counted. */
+ Assert.Equal(15, entries.Count);
+
+ var lockWait = entries.Single(e => e.Message.StartsWith("process 4102 still waiting", StringComparison.Ordinal));
+ Assert.Equal("LOG", lockWait.Severity);
+ Assert.Equal(4102, lockWait.Pid);
+ Assert.Equal("Process holding the lock: 4099. Wait queue: 4102.", lockWait.Detail);
+ Assert.Equal("while updating tuple (0,7) in relation \"orders\"", lockWait.Context);
+ Assert.StartsWith("UPDATE orders SET status = 'shipped'", lockWait.Statement, StringComparison.Ordinal);
+ Assert.Equal(new DateTime(2026, 9, 18, 3, 7, 12, 345, DateTimeKind.Utc), lockWait.OccurredAtUtc);
+ Assert.Equal(DateTimeKind.Utc, lockWait.OccurredAtUtc.Kind);
+
+ var vacuum = entries.Single(e => e.Message.StartsWith("automatic vacuum", StringComparison.Ordinal));
+ Assert.Contains("\npages: 0 removed, 12345 remain", vacuum.Message, StringComparison.Ordinal);
+ Assert.Contains("\ntuples: 4567 removed", vacuum.Message, StringComparison.Ordinal);
+
+ /* The raw text is the whole entry, verbatim, and it is what the identity hash is over. */
+ Assert.StartsWith(P + "[4102] LOG: process 4102 still waiting", lockWait.RawText, StringComparison.Ordinal);
+ Assert.Contains(P + "[4102] STATEMENT: UPDATE orders", lockWait.RawText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheAssembler_ReadsBothPrefixFamilies_AndLiftsUserDatabaseAndSqlStateFromThePrefix()
+ {
+ var managed = PgLogEntryAssembler.Assemble(ManagedLog);
+ Assert.Equal(2, managed.Count);
+ Assert.Equal("app_rw", managed[0].UserName);
+ Assert.Equal("app_db", managed[0].DatabaseName);
+ Assert.Equal(4102, managed[0].Pid);
+ Assert.Equal(new DateTime(2026, 9, 18, 3, 7, 12, DateTimeKind.Utc), managed[0].OccurredAtUtc);
+ Assert.StartsWith("SELECT count(*)", managed[0].Statement, StringComparison.Ordinal);
+ /* A background process renders `@` alone under %u@%d: neither half is a name. */
+ Assert.Null(managed[1].UserName);
+ Assert.Null(managed[1].DatabaseName);
+
+ var variants = PgLogEntryAssembler.Assemble(PrefixVariantsLog);
+ Assert.Equal(2, variants.Count);
+ Assert.Equal("28P01", variants[0].SqlState);
+ Assert.Equal("FATAL", variants[0].Severity);
+ /* %Q glues the query id to the label: the label is still found, the id is not mistaken for a
+ SQLSTATE (18 digits, not 5), and the pid is the bracketed one. */
+ Assert.Equal("ERROR", variants[1].Severity);
+ Assert.Equal(1549, variants[1].Pid);
+ Assert.Null(variants[1].SqlState);
+ Assert.Equal("deadlock detected", variants[1].Message);
+ Assert.Contains("Process 1556 waits for ShareLock", variants[1].Detail, StringComparison.Ordinal);
+ Assert.Equal("See server log for query details.", variants[1].Hint);
+ }
+
+ [Fact]
+ public void TheAssembler_DropsTheCutHead_AndTheCutTail()
+ {
+ /* The self-hosted tail starts at an arbitrary byte; the RDS chunk ends at one. */
+ var cutHead = "tail end of a line that started before the window\n" + P + "[1] ERROR: whole\n";
+ Assert.Single(PgLogEntryAssembler.Assemble(cutHead));
+
+ var cutTail = P + "[1] ERROR: whole\n" + P + "[2] ERROR: half a li";
+ var entries = PgLogEntryAssembler.Assemble(cutTail);
+ Assert.Single(entries);
+ Assert.Equal("whole", entries[0].Message);
+ }
+
+ [Fact]
+ public void TheAssembler_RefusesANonUtcLog_Whole_WithTheDeadlockRoutesException()
+ {
+ var local = SelfHostedLog.Replace(" UTC ", " EST ", StringComparison.Ordinal);
+ var refusal = Assert.Throws(() => PgLogEntryAssembler.Assemble(local));
+ Assert.Equal("EST", refusal.ObservedZone);
+
+ /* And the zero-offset spellings the deadlock parser admits are admitted here, because it is the
+ same method: GMT, UCT, +00, +00:00. */
+ foreach (var zone in new[] { "GMT", "UCT", "+00", "+00:00" })
+ {
+ Assert.Equal(15, PgLogEntryAssembler.Assemble(SelfHostedLog.Replace(" UTC ", $" {zone} ", StringComparison.Ordinal)).Count);
+ }
+ }
+
+ /* ---- classification ------------------------------------------------------------------------------ */
+
+ [Fact]
+ public void TheClassifier_RoutesEveryFamily_AndDropsWhatNoFamilyClaims()
+ {
+ var events = Classify(SelfHostedLog);
+ var byFamily = events.GroupBy(e => e.Family).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
+
+ /* Four connection lines (received, authenticated, authorized, disconnection), two lock-wait lines
+ (still waiting, acquired), four errors (duplicate key, FATAL auth, WARNING, cancel), one spill,
+ one autovacuum, two checkpoints. "database system is ready" is claimed by nobody and is dropped. */
+ Assert.Equal(4, byFamily[PgLogFamilies.Connection]);
+ Assert.Equal(2, byFamily[PgLogFamilies.LockWait]);
+ Assert.Equal(4, byFamily[PgLogFamilies.Error]);
+ Assert.Equal(1, byFamily[PgLogFamilies.TempFile]);
+ Assert.Equal(1, byFamily[PgLogFamilies.Autovacuum]);
+ Assert.Equal(2, byFamily[PgLogFamilies.Checkpoint]);
+ Assert.Equal(14, events.Count);
+ Assert.DoesNotContain(events, e => e.Message.Contains("ready to accept", StringComparison.Ordinal));
+ Assert.All(events, e => Assert.True(PgLogFamilies.IsKnown(e.Family), e.Family));
+ }
+
+ [Fact]
+ public void TheErrorFamily_IsSeverityFirst_SoAFatalConnectionRefusal_IsAnErrorCarryingItsUser()
+ {
+ var fatal = Classify(SelfHostedLog).Single(e => e.Severity == "FATAL");
+ Assert.Equal(PgLogFamilies.Error, fatal.Family);
+ Assert.Equal("intruder", fatal.UserName);
+ Assert.Equal(5, fatal.SeverityRank);
+
+ /* The %e prefix's SQLSTATE rides along; the message's user is lifted; the deadlock ERROR lands in
+ the error family beside its full graph in pg_deadlocks. */
+ var variants = Classify(PrefixVariantsLog);
+ Assert.Equal("28P01", variants[0].SqlState);
+ Assert.Equal("app_rw", variants[0].UserName);
+ Assert.Equal(PgLogFamilies.Error, variants[1].Family);
+ Assert.Equal("deadlock detected", variants[1].Message);
+ }
+
+ [Fact]
+ public void TheConnectionFamily_LiftsUserDatabaseAndApplication_FromTheMessage()
+ {
+ var authorized = Classify(SelfHostedLog).Single(e => e.Message.StartsWith("connection authorized", StringComparison.Ordinal));
+ Assert.Equal(PgLogFamilies.Connection, authorized.Family);
+ Assert.Equal("app_rw", authorized.UserName);
+ Assert.Equal("app_db", authorized.DatabaseName);
+ Assert.Equal("psql", authorized.ApplicationName);
+
+ var disconnection = Classify(SelfHostedLog).Single(e => e.Message.StartsWith("disconnection", StringComparison.Ordinal));
+ Assert.Equal("app_rw", disconnection.UserName);
+ Assert.Equal("app_db", disconnection.DatabaseName);
+ Assert.Contains("session time: 0:00:03.412", disconnection.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheLockWaitFamily_KeepsPidsModesAndDurations_AndFingerprintsTheStatement()
+ {
+ var waits = Classify(SelfHostedLog).Where(e => e.Family == PgLogFamilies.LockWait).ToList();
+ Assert.Equal(2, waits.Count);
+ Assert.Equal("process 4102 still waiting for ShareLock on transaction 809 after 1000.123 ms", waits[0].Message);
+ Assert.Equal("Process holding the lock: 4099. Wait queue: 4102.", waits[0].Detail);
+ Assert.Equal("process 4102 acquired ShareLock on transaction 809 after 2345.678 ms", waits[1].Message);
+
+ /* The same statement under both lines fingerprints alike — that is the pairing key beside the pid. */
+ Assert.NotNull(waits[0].StatementFingerprint);
+ Assert.Equal(waits[0].StatementFingerprint, waits[1].StatementFingerprint);
+ Assert.Equal(32, waits[0].StatementFingerprint!.Length);
+ /* Different entries, different identity. */
+ Assert.NotEqual(waits[0].RawLineHash, waits[1].RawLineHash);
+ }
+
+ [Fact]
+ public void TheRecognisedOnlyFamilies_AreStoredUnderTheirOwnName_WithNothingLifted()
+ {
+ var events = Classify(SelfHostedLog);
+ var spill = events.Single(e => e.Family == PgLogFamilies.TempFile);
+ Assert.StartsWith("temporary file: path \"base/pgsql_tmp/pgsql_tmp4102.0\", size 4294967296", spill.Message, StringComparison.Ordinal);
+ Assert.NotNull(spill.StatementFingerprint);
+
+ var vacuum = events.Single(e => e.Family == PgLogFamilies.Autovacuum);
+ Assert.StartsWith("automatic vacuum of table \"app_db.public.orders\"", vacuum.Message, StringComparison.Ordinal);
+
+ /* The recognised-only list is exactly the three, and the parsed list exactly the three, and
+ together with the reserved word they are the vocabulary. */
+ Assert.Equal(new[] { "temp_file", "autovacuum", "checkpoint" }, PgLogFamilies.RecognisedOnly);
+ Assert.Equal(new[] { "error", "connection", "lock_wait" }, PgLogFamilies.Parsed);
+ Assert.Equal(PgLogFamilies.Parsed.Concat(PgLogFamilies.RecognisedOnly).Append(PgLogFamilies.Other), PgLogFamilies.All);
+
+ /* And the seam has exactly the registration order the interface documents: severity first, then
+ the LOG-level shapes, then the recognised-only arm LAST so a sibling's parser goes before it. */
+ Assert.Equal(
+ new[] { typeof(PgErrorEventParser), typeof(PgConnectionEventParser), typeof(PgLockWaitEventParser), typeof(PgRecognisedFamilyParser) },
+ PgLogEventClassifier.DefaultParsers.Select(p => p.GetType()));
+ Assert.Equal(typeof(PgRecognisedFamilyParser), PgLogEventClassifier.DefaultParsers[^1].GetType());
+ }
+
+ /// A sibling family parser registers ahead of the recognised-only arm and takes its lines out of it — the #3602 / #3603 shape, proven with a stand-in.
+ [Fact]
+ public void ASiblingParser_RegisteredAheadOfTheRecognisedArm_TakesItsFamilysLines()
+ {
+ var parsers = PgLogEventClassifier.DefaultParsers.ToList();
+ parsers.Insert(parsers.Count - 1, new StandInTempFileParser());
+ var classifier = new PgLogEventClassifier(parsers);
+
+ var events = classifier.Classify(SelfHostedLog);
+ var spill = events.Single(e => e.Family == PgLogFamilies.TempFile);
+ /* The stand-in lifted the database from the statement's context; the generic arm would have left it null. */
+ Assert.Equal("from-sibling", spill.DatabaseName);
+ Assert.Equal(14, events.Count);
+ }
+
+ private sealed class StandInTempFileParser : IPgLogFamilyParser
+ {
+ public string Family => PgLogFamilies.TempFile;
+
+ public bool TryParse(in PgLogEntry entry, out PgLogEvent logEvent)
+ {
+ if (entry.Severity != "LOG" || !entry.Message.StartsWith("temporary file: ", StringComparison.Ordinal))
+ {
+ logEvent = default;
+ return false;
+ }
+
+ logEvent = PgLogEvent.From(entry, Family, databaseName: "from-sibling");
+ return true;
+ }
+ }
+
+ /* ---- redaction ----------------------------------------------------------------------------------- */
+
+ /// The issue's scope note, as a pin: a literal fed in never reaches the row.
+ [Fact]
+ public void Redaction_StripsEveryLiteral_BeforeAnythingIsStored()
+ {
+ var events = Classify(SelfHostedLog);
+
+ foreach (var e in events)
+ {
+ Assert.DoesNotContain("someone@example.com", e.Message + e.Detail + e.StatementFingerprint, StringComparison.Ordinal);
+ Assert.DoesNotContain("O'Brien", e.Message + e.Detail, StringComparison.Ordinal);
+ Assert.DoesNotContain("shipped", e.Message + e.Detail, StringComparison.Ordinal);
+ Assert.DoesNotContain("gift", e.Message + e.Detail, StringComparison.Ordinal);
+ }
+
+ /* The unique-violation DETAIL's value tuple is the one unquoted value shape in PostgreSQL's prose. */
+ var duplicate = events.Single(e => e.Message.StartsWith("duplicate key", StringComparison.Ordinal));
+ Assert.Equal("Key (email)=(?) already exists.", duplicate.Detail);
+ /* Identifiers stay: the constraint name is double-quoted and is not a value. */
+ Assert.Equal("duplicate key value violates unique constraint \"customers_email_key\"", duplicate.Message);
+
+ /* The statement is never a column at all: the row type has no member carrying it. */
+ Assert.DoesNotContain(typeof(PgLogEvent).GetProperties(), p => p.Name.Contains("Statement", StringComparison.Ordinal) && p.PropertyType == typeof(string) && p.Name != "StatementFingerprint");
+ Assert.DoesNotContain(PgLogEventsCollector.Instance.PayloadColumns, c => c.Name is "statement" or "statement_text");
+ }
+
+ [Fact]
+ public void Redaction_IsThePlanParsersOwnPatterns_AppliedAtTwoStrengths()
+ {
+ /* Statement: literals and bare numbers go, identifier-glued digits stay — exactly the plan parser's
+ condition-field rule over SQL text. */
+ Assert.Equal(
+ "UPDATE transactionitems1 SET note = '?' WHERE id = ? AND amount > ?",
+ PgLogTextRedactor.RedactStatement("UPDATE transactionitems1 SET note = 'it''s' WHERE id = 42 AND amount > 10.5"));
+
+ /* Prose: literals go, numbers stay — a lock wait's numbers are its evidence. */
+ Assert.Equal(
+ "process 4102 still waiting for ShareLock on transaction 809 after 1000.123 ms",
+ PgLogTextRedactor.RedactMessage("process 4102 still waiting for ShareLock on transaction 809 after 1000.123 ms"));
+ Assert.Equal("invalid input syntax for type integer: '?'", PgLogTextRedactor.RedactMessage("invalid input syntax for type integer: 'abc'"));
+
+ /* Same shape, different values, one fingerprint; and the fingerprint is over the REDACTED text, so
+ the raw literal is not even hashed. */
+ var a = PgLogTextRedactor.Fingerprint(PgLogTextRedactor.RedactStatement("SELECT * FROM t WHERE id = 1 AND name = 'a'"));
+ var b = PgLogTextRedactor.Fingerprint(PgLogTextRedactor.RedactStatement("SELECT * FROM t\nWHERE id = 999 AND name = 'zzz'"));
+ Assert.Equal(a, b);
+ Assert.Null(PgLogTextRedactor.Fingerprint(null));
+
+ /* The two regexes are the plan parser's own instances, not copies: the field is internal and this
+ type reads it. A second spelling is the drift the scope note forbids. */
+ var source = RepoFile.ReadRepoFile("PerformanceMonitor.Collectors", "PgLogTextRedactor.cs");
+ Assert.Contains("PgPlanLogParser.s_quotedLiteral.Replace", source, StringComparison.Ordinal);
+ Assert.Contains("PgPlanLogParser.s_bareNumber.Replace", source, StringComparison.Ordinal);
+ Assert.DoesNotMatch(@"new Regex\(""'\(", source);
+ }
+
+ /* ---- the self-hosted collector and the shared tailer --------------------------------------------- */
+
+ ///
+ /// Extracting the tailer left the two siblings' SHIPPED SQL byte-for-byte what it was: pinned against
+ /// the text as it stood at the parent commit, reconstructed from the inline constants.
+ ///
+ [Fact]
+ public void TheTailerExtraction_LeftBothSiblingsSqlByteIdentical()
+ {
+ const string tail = "\nWITH newest AS (\n SELECT name, size\n FROM pg_catalog.pg_ls_logdir()\n WHERE pg_catalog.current_setting('logging_collector') = 'on'\n ORDER BY modification DESC\n LIMIT 1\n),\ntail AS (\n SELECT pg_catalog.pg_read_file(\n pg_catalog.current_setting('log_directory') || '/' || n.name,\n greatest(n.size - 4194304, 0),\n 4194304) AS body\n FROM newest AS n\n)";
+
+ const string plansBefore = tail + "\nSELECT\n (m[1])::bigint AS query_id,\n (m[2])::double precision AS duration_ms,\n replace(m[3], chr(9), '') AS plan_json\nFROM tail,\n regexp_matches(\n tail.body,\n '\\[\\d+\\] (-?\\d+) LOG: duration: ([0-9.]+) ms plan:\\s*\\n((?:\\t[^\\n]*\\n)+)',\n 'g') AS m\nUNION ALL\nSELECT NULL::bigint, NULL::double precision, 'logging_collector=off'\nWHERE pg_catalog.current_setting('logging_collector') <> 'on'\nLIMIT 2000";
+
+ const string deadlocksBefore = tail + "\nSELECT\n m[1] AS occurred_at_text,\n m[2] AS log_zone_text,\n m[3] AS victim_pid_text,\n m[4] AS detail_body\nFROM tail,\n regexp_matches(\n tail.body,\n '^(\\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d\\.\\d+) ([^ \\n]+) \\[(\\d+)\\][^\\n]*ERROR: deadlock detected\\s*\\n[^\\n]*DETAIL: ((?:[^\\n]*\\n)(?:\\t[^\\n]*\\n)*)',\n 'gn') AS m\nUNION ALL\nSELECT 'logging_collector=off', NULL, NULL, NULL\nWHERE pg_catalog.current_setting('logging_collector') <> 'on'\nLIMIT 500";
+
+ /* Line endings normalised on both sides: the repo's `text=auto eol=crlf` checks the sources out as
+ CRLF on Windows and this pin's literals are LF, and a verbatim string carries whatever its file
+ does. PostgreSQL does not care; the pin is about the SQL, not the newline flavour. */
+ static string Lf(string sql) => sql.Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ var context = TestContext();
+ Assert.Equal(plansBefore, Lf(PgPlanCaptureCollector.Instance.BuildQuery(context).Text));
+ Assert.Equal(deadlocksBefore, Lf(PgDeadlocksCollector.Instance.BuildQuery(context).Text));
+
+ /* And the third reader opens with the same tailer and returns the body whole. */
+ var events = Lf(PgLogEventsCollector.Instance.BuildQuery(context).Text);
+ Assert.StartsWith(tail, events, StringComparison.Ordinal);
+ Assert.Contains("SELECT tail.body AS log_body", events, StringComparison.Ordinal);
+ Assert.Contains("'" + PgLoggingCollectorOffException.Marker + "'", events, StringComparison.Ordinal);
+ Assert.Equal(tail, Lf(PgServerLogTail.TailCteSql));
+ }
+
+ [Fact]
+ public async Task TheCollector_ClassifiesTheBody_ThrowsOnTheMarker_AndWritesTheColumnsInOrder()
+ {
+ var definition = PgLogEventsCollector.Instance;
+ Assert.Equal("pg_log_events", definition.Name);
+ Assert.Equal("pg_log_events", definition.TargetTable);
+ Assert.True(definition.AppliesTo(new CollectorTargetInfo { Engine = CollectorTargetEngine.PostgreSql, IsAurora = true }));
+ Assert.False(definition.RunsPerDatabase(new CollectorTargetInfo { Engine = CollectorTargetEngine.PostgreSql }));
+
+ Assert.Equal(
+ new[] { "occurred_at", "family", "severity", "sqlstate", "database_name", "user_name", "application_name", "pid", "message", "detail", "statement_fingerprint", "raw_line_hash" },
+ definition.PayloadColumns.Select(c => c.Name));
+
+ var context = TestContext();
+
+ using var body = new FakeReader(new object?[][] { new object?[] { SelfHostedLog } });
+ var rows = await definition.ReadAsync(body, context, CancellationToken.None);
+ Assert.Equal(14, rows.Count);
+
+ using var marker = new FakeReader(new object?[][] { new object?[] { PgLoggingCollectorOffException.Marker } });
+ await Assert.ThrowsAsync(async () => await definition.ReadAsync(marker, context, CancellationToken.None));
+
+ var writer = new RecordingWriter();
+ definition.WritePayload(rows[0], writer, context);
+ Assert.Equal(definition.PayloadColumns.Count, writer.Values.Count);
+ Assert.Equal(DateTimeKind.Unspecified, ((DateTime)writer.Values[0]!).Kind);
+ Assert.Equal(PgLogFamilies.Connection, writer.Values[1]);
+ Assert.Equal("LOG", writer.Values[2]);
+ Assert.Equal(4102, writer.Values[7]);
+ Assert.Equal(rows[0].RawLineHash, writer.Values[11]);
+ }
+
+ [Fact]
+ public void TheCollector_IsWiredEverywhereItsSiblingsAre()
+ {
+ Assert.Contains(CollectorCatalog.All, d => ReferenceEquals(d, PgLogEventsCollector.Instance));
+ Assert.Equal(new CollectorScheduleDefaults.Entry(5, 30), CollectorScheduleDefaults.All["pg_log_events"]);
+ Assert.Equal(CollectorScheduleDefaults.All["pg_deadlocks"].FrequencyMinutes, CollectorScheduleDefaults.All["pg_log_events"].FrequencyMinutes);
+ Assert.True(CollectorScheduleDefaults.All["pg_log_events"].RetentionDays < CollectorScheduleDefaults.All["pg_deadlocks"].RetentionDays);
+
+ var worker = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs");
+ Assert.Contains("[\"pg_log_events\"] = (r, s, ct) =>", worker, StringComparison.Ordinal);
+ Assert.Contains("r.IngestRdsLogEventsAsync(s, ct)", worker, StringComparison.Ordinal);
+ Assert.Contains("r.RunAsync(PgLogEventsCollector.Instance, s, ct)", worker, StringComparison.Ordinal);
+ Assert.Matches(@"collectorName is ""pg_deadlocks"" or ""pg_plan_capture"" or ""pg_log_events""", worker);
+
+ Assert.Contains("pg_log_events", RepoFile.ReadRepoFile("PerformanceMonitor.Collectors", "CollectorEngineCapability.cs"), StringComparison.Ordinal);
+
+ /* The two RDS notes are a PAIR and are different — #3017's shape. */
+ Assert.NotEqual(DarlingCollectorRunner.RdsLogEventsEmptyNote, DarlingCollectorRunner.RdsLogEventsNotReachedNote);
+ Assert.Contains("did not look", DarlingCollectorRunner.RdsLogEventsNotReachedNote, StringComparison.Ordinal);
+ }
+
+ /* ---- the severity ranking agrees across C# and SQL ----------------------------------------------- */
+
+ [Fact]
+ public void TheSeverityRank_IsOneOrdering_InCSharpAndInTheReadersSql()
+ {
+ foreach (var (label, rank) in new[] { ("PANIC", 6), ("FATAL", 5), ("ERROR", 4), ("WARNING", 3), ("NOTICE", 2), ("INFO", 1), ("LOG", 1) })
+ {
+ Assert.Equal(rank, PgLogEntry.RankOf(label));
+ Assert.Matches($@"WHEN '{label}'\s+THEN {rank}\b", DarlingPgLogEventReader.SeverityRankSql);
+ }
+
+ Assert.Equal(0, PgLogEntry.RankOf("DEBUG1"));
+ Assert.Equal(0, PgLogEntry.RankOf(null));
+ Assert.Contains("ELSE 0", DarlingPgLogEventReader.SeverityRankSql, StringComparison.Ordinal);
+
+ /* The read's page contract (#3594, #3613): the cap is a parameter, the window total is on the same
+ statement, the dedupe is on the identity column, and the chunk-excluding bound is there. */
+ Assert.EndsWith("LIMIT $6", DarlingPgLogEventReader.EventsSql.TrimEnd(), StringComparison.Ordinal);
+ Assert.DoesNotMatch(@"LIMIT\s+\d", DarlingPgLogEventReader.EventsSql);
+ Assert.Contains("COUNT(*) OVER ()::int AS window_total", DarlingPgLogEventReader.EventsSql, StringComparison.Ordinal);
+ Assert.Contains("DISTINCT ON (e.raw_line_hash)", DarlingPgLogEventReader.EventsSql, StringComparison.Ordinal);
+ Assert.Contains("e.collection_time >= $2", DarlingPgLogEventReader.EventsSql, StringComparison.Ordinal);
+ }
+
+ /* ---- the tool ------------------------------------------------------------------------------------ */
+
+ [Fact]
+ public void TheTool_IsRegistered_Dispatched_Described_AndCounted()
+ {
+ var method = typeof(DarlingMcpPgLogEventTools).GetMethods().Single(m => m.GetCustomAttribute()?.Name == "get_pg_log_events");
+ var description = method.GetCustomAttribute()!.Description;
+ Assert.Contains("truncated", description, StringComparison.Ordinal);
+ Assert.Contains("limit", description, StringComparison.Ordinal);
+ Assert.Contains("REDACTED", description, StringComparison.Ordinal);
+ Assert.Contains("log_lock_waits", description, StringComparison.Ordinal);
+ Assert.Contains("truncated", method.GetParameters().Single(p => p.Name == "limit").GetCustomAttribute()!.Description, StringComparison.Ordinal);
+
+ var host = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpHostService.cs");
+ Assert.Contains(".WithGeminiCompatibleTools()", host, StringComparison.Ordinal);
+
+ var dispatch = DarlingWebEndpoints.BuildReadDispatch();
+ Assert.True(dispatch.ContainsKey("get_pg_log_events"));
+
+ var body = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpPgLogEventTools.cs");
+ Assert.Contains("limit + 1", body, StringComparison.Ordinal);
+ Assert.Contains("page.Rows.Count > limit", body, StringComparison.Ordinal);
+ Assert.Contains("total_events = page.WindowTotal", body, StringComparison.Ordinal);
+ Assert.DoesNotContain(">= limit", body, StringComparison.Ordinal);
+
+ /* The instructions' census moved with the tool. */
+ var instructions = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpInstructions.cs");
+ Assert.Contains("thirty-four are the PostgreSQL reads", instructions, StringComparison.Ordinal);
+ }
+
+ /* ---- the RDS transport --------------------------------------------------------------------------- */
+
+ private const string DeadStore = "Host=127.0.0.1;Port=1;Username=none;Password=none;Database=none;Timeout=1";
+ private const string RdsHost = "solo.abc123.us-east-1.rds.amazonaws.com";
+
+ private sealed class FakeRds : AmazonRDSClient
+ {
+ public FakeRds() : base(new Amazon.Runtime.BasicAWSCredentials("a", "b"), Amazon.RegionEndpoint.USEast1) { }
+
+ public List Downloads { get; } = new();
+
+ public string FirstBody { get; init; } = SelfHostedLog;
+
+ public override Task DescribeDBLogFilesAsync(DescribeDBLogFilesRequest request, CancellationToken cancellationToken = default)
+ => Task.FromResult(new DescribeDBLogFilesResponse
+ {
+ DescribeDBLogFiles = new List { new() { LogFileName = "error/postgresql.log.2026-09-18-03", LastWritten = 9999 } },
+ });
+
+ public override Task DownloadDBLogFilePortionAsync(DownloadDBLogFilePortionRequest request, CancellationToken cancellationToken = default)
+ {
+ Downloads.Add(request);
+ var resumed = request.Marker == "MARKER-1";
+ return Task.FromResult(new DownloadDBLogFilePortionResponse
+ {
+ LogFileData = resumed ? P + "[1200] LOG: database system is ready to accept connections\n" : FirstBody,
+ Marker = resumed ? "MARKER-2" : "MARKER-1",
+ AdditionalDataPending = false,
+ });
+ }
+ }
+
+ [Fact]
+ public async Task TheRdsIngestor_DoesNotAdvanceTheMarker_WhenTheStoreWriteFails_OrTheZoneIsRefused()
+ {
+ await using var store = NpgsqlDataSource.Create(DeadStore);
+
+ var client = new FakeRds();
+ var ingestor = new RdsLogEventIngestor(store, new RdsLogSource(_ => client));
+
+ var failure = await Assert.ThrowsAnyAsync(() => ingestor.IngestAsync(1, "target-a", RdsHost));
+ Assert.IsNotType(failure);
+ Assert.IsNotType(failure);
+ await Assert.ThrowsAnyAsync(() => ingestor.IngestAsync(1, "target-a", RdsHost));
+ Assert.Equal(2, client.Downloads.Count);
+ Assert.Null(client.Downloads[1].Marker);
+
+ var local = new FakeRds { FirstBody = SelfHostedLog.Replace(" UTC ", " EST ", StringComparison.Ordinal) };
+ var refusing = new RdsLogEventIngestor(store, new RdsLogSource(_ => local));
+ await Assert.ThrowsAsync(() => refusing.IngestAsync(1, "target-a", RdsHost));
+ await Assert.ThrowsAsync(() => refusing.IngestAsync(1, "target-a", RdsHost));
+ Assert.Null(local.Downloads[1].Marker);
+
+ /* A non-RDS host is NOT_REACHED, not zero rows (#3017), and no AWS call is made. */
+ var quiet = new FakeRds();
+ var notReached = await new RdsLogEventIngestor(store, new RdsLogSource(_ => quiet)).IngestAsync(1, "target-a", "db.internal.example");
+ Assert.False(notReached.SourceReached);
+ Assert.Empty(quiet.Downloads);
+ }
+
+ /* ---- helpers ------------------------------------------------------------------------------------- */
+
+ private static CollectorContext TestContext() => new()
+ {
+ ServerId = 1,
+ ServerName = "target-a",
+ CollectionTime = new DateTime(2026, 9, 18, 3, 10, 0, DateTimeKind.Unspecified),
+ Deltas = new CollectorDeltaCalculator(),
+ Target = new CollectorTargetInfo { Engine = CollectorTargetEngine.PostgreSql },
+ };
+
+ private sealed class FakeReader : System.Data.Common.DbDataReader
+ {
+ private readonly object?[][] _rows;
+ private int _index = -1;
+
+ public FakeReader(object?[][] rows) => _rows = rows;
+
+ public override bool Read() => ++_index < _rows.Length;
+ public override Task ReadAsync(CancellationToken cancellationToken) => Task.FromResult(Read());
+ public override bool IsDBNull(int ordinal) => _rows[_index][ordinal] is null;
+ public override string GetString(int ordinal) => (string)_rows[_index][ordinal]!;
+ public override object GetValue(int ordinal) => _rows[_index][ordinal]!;
+ public override int FieldCount => _rows.Length == 0 ? 0 : _rows[0].Length;
+ public override bool HasRows => _rows.Length > 0;
+ public override bool IsClosed => false;
+ public override int RecordsAffected => 0;
+ public override int Depth => 0;
+ public override object this[int ordinal] => _rows[_index][ordinal]!;
+ public override object this[string name] => throw new NotSupportedException();
+ public override bool GetBoolean(int ordinal) => (bool)_rows[_index][ordinal]!;
+ public override byte GetByte(int ordinal) => throw new NotSupportedException();
+ public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => throw new NotSupportedException();
+ public override char GetChar(int ordinal) => throw new NotSupportedException();
+ public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => throw new NotSupportedException();
+ public override string GetDataTypeName(int ordinal) => "text";
+ public override DateTime GetDateTime(int ordinal) => (DateTime)_rows[_index][ordinal]!;
+ public override decimal GetDecimal(int ordinal) => (decimal)_rows[_index][ordinal]!;
+ public override double GetDouble(int ordinal) => (double)_rows[_index][ordinal]!;
+ public override System.Collections.IEnumerator GetEnumerator() => _rows.GetEnumerator();
+ public override Type GetFieldType(int ordinal) => typeof(string);
+ public override float GetFloat(int ordinal) => (float)_rows[_index][ordinal]!;
+ public override Guid GetGuid(int ordinal) => (Guid)_rows[_index][ordinal]!;
+ public override short GetInt16(int ordinal) => (short)_rows[_index][ordinal]!;
+ public override int GetInt32(int ordinal) => (int)_rows[_index][ordinal]!;
+ public override long GetInt64(int ordinal) => (long)_rows[_index][ordinal]!;
+ public override string GetName(int ordinal) => "log_body";
+ public override int GetOrdinal(string name) => 0;
+ public override int GetValues(object[] values) => throw new NotSupportedException();
+ public override bool NextResult() => false;
+ }
+
+ private sealed class RecordingWriter : ICollectorRowWriter
+ {
+ public List
+/// tuple and relation (while updating tuple (0,7) in relation "orders"). The message, detail and
+/// context are stored redacted, and survive it because they are pids, modes and identifiers after a noun;
+/// the statement is fingerprinted and never stored. (The first draft claimed the context survived while
+/// the row had no column for it — review caught the gap, and the column exists because of it.)
///
/// What this is beside pg_blocking. That collector SAMPLES pg_blocking_pids() on
/// a cadence and can miss a wait that starts and ends between samples; this is the server's own record of
diff --git a/PerformanceMonitor.Collectors/PgLogTextRedactor.cs b/PerformanceMonitor.Collectors/PgLogTextRedactor.cs
index 8a6ca22dcf..d29057a4f7 100644
--- a/PerformanceMonitor.Collectors/PgLogTextRedactor.cs
+++ b/PerformanceMonitor.Collectors/PgLogTextRedactor.cs
@@ -109,10 +109,15 @@ sentence PostgreSQL writes after the tuple (`already exists.`, `is duplicated.`,
@"\bFailing row contains \(.*\)(?=[^)]*$)",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline);
- /* Every remaining double-quoted run, with what precedes it captured so the allowlist can be asked. */
+ /* Every remaining double-quoted run, with what precedes it captured so the allowlist can be asked. The
+ word-and-space lead is tried first so `relation "x"` reaches the allowlist with its noun; the fallback
+ is ANY single character — a bare space, a newline at the head of a tab-continuation, punctuation — so
+ every double-quoted run is evaluated and one preceded by nothing recognisable is redacted rather than
+ skipped. Review found the first draft's fallback matched non-space only, which left a quote after two
+ spaces or after a newline neither redacted nor allowlisted. */
private static readonly Regex s_doubleQuoted = new(
- @"(?(?:[A-Za-z_]+=|\b[A-Za-z_]+\s|^|\S))(?""[^""]*"")",
- RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ @"(?(?:[A-Za-z_]+=|\b[A-Za-z_]+\s|^|.))(?""[^""]*"")",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline);
/* The words PostgreSQL puts before a double-quoted IDENTIFIER. A run preceded by one of these is a
name and stays; a run preceded by anything else is treated as a value. Enumerated rather than