Skip to content

Commit d7296c6

Browse files
daltonpearsonDalton Pearson
andauthored
feat: added monitoring for postgres query result (#6736)
Co-authored-by: Dalton Pearson <dalton.pearson@praemo.com>
1 parent e022b5f commit d7296c6

File tree

2 files changed

+308
-6
lines changed

2 files changed

+308
-6
lines changed

server/monitor-types/postgres.js

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util");
33
const dayjs = require("dayjs");
44
const postgresConParse = require("pg-connection-string").parse;
55
const { Client } = require("pg");
6+
const { ConditionVariable } = require("../monitor-conditions/variables");
7+
const { defaultStringOperators } = require("../monitor-conditions/operators");
8+
const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
9+
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
610

711
class PostgresMonitorType extends MonitorType {
812
name = "postgres";
913

14+
supportsConditions = true;
15+
conditionVariables = [new ConditionVariable("result", defaultStringOperators)];
16+
1017
/**
1118
* @inheritdoc
1219
*/
1320
async check(monitor, heartbeat, _server) {
14-
let startTime = dayjs().valueOf();
15-
1621
let query = monitor.databaseQuery;
1722
// No query provided by user, use SELECT 1
1823
if (!query || (typeof query === "string" && query.trim() === "")) {
1924
query = "SELECT 1";
2025
}
21-
await this.postgresQuery(monitor.databaseConnectionString, query);
2226

23-
heartbeat.msg = "";
24-
heartbeat.status = UP;
25-
heartbeat.ping = dayjs().valueOf() - startTime;
27+
const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
28+
const hasConditions = conditions && conditions.children && conditions.children.length > 0;
29+
30+
const startTime = dayjs().valueOf();
31+
32+
try {
33+
if (hasConditions) {
34+
// When conditions are enabled, expect a single value result
35+
const result = await this.postgresQuerySingleValue(monitor.databaseConnectionString, query);
36+
heartbeat.ping = dayjs().valueOf() - startTime;
37+
38+
const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) });
39+
40+
if (!conditionsResult) {
41+
throw new Error(`Query result did not meet the specified conditions (${result})`);
42+
}
43+
44+
heartbeat.status = UP;
45+
heartbeat.msg = "Query did meet specified conditions";
46+
} else {
47+
// Backwards compatible: just check connection and return row count
48+
const result = await this.postgresQuery(monitor.databaseConnectionString, query);
49+
heartbeat.ping = dayjs().valueOf() - startTime;
50+
heartbeat.status = UP;
51+
heartbeat.msg = result;
52+
}
53+
} catch (error) {
54+
heartbeat.ping = dayjs().valueOf() - startTime;
55+
// Re-throw condition errors as-is, wrap database errors
56+
if (error.message.includes("did not meet the specified conditions")) {
57+
throw error;
58+
}
59+
throw new Error(`Database connection/query failed: ${error.message}`);
60+
}
2661
}
2762

2863
/**
@@ -76,6 +111,75 @@ class PostgresMonitorType extends MonitorType {
76111
});
77112
});
78113
}
114+
115+
/**
116+
* Run a query on Postgres
117+
* @param {string} connectionString The database connection string
118+
* @param {string} query The query to validate the database with
119+
* @returns {Promise<(string[] | object[] | object)>} Response from
120+
* server
121+
*/
122+
async postgresQuerySingleValue(connectionString, query) {
123+
return new Promise((resolve, reject) => {
124+
const config = postgresConParse(connectionString);
125+
126+
// Fix #3868, which true/false is not parsed to boolean
127+
if (typeof config.ssl === "string") {
128+
config.ssl = config.ssl === "true";
129+
}
130+
131+
if (config.password === "") {
132+
// See https://github.com/brianc/node-postgres/issues/1927
133+
reject(new Error("Password is undefined."));
134+
return;
135+
}
136+
const client = new Client(config);
137+
138+
client.on("error", (error) => {
139+
log.debug(this.name, "Error caught in the error event handler.");
140+
reject(error);
141+
});
142+
143+
client.connect((err) => {
144+
if (err) {
145+
reject(err);
146+
client.end();
147+
} else {
148+
// Connected here
149+
try {
150+
client.query(query, (err, res) => {
151+
if (err) {
152+
reject(err);
153+
} else {
154+
// Check if we have results
155+
if (!res.rows || res.rows.length === 0) {
156+
reject(new Error("Query returned no results"));
157+
return;
158+
}
159+
// Check if we have multiple rows
160+
if (res.rows.length > 1) {
161+
reject(new Error("Multiple values were found, expected only one value"));
162+
return;
163+
}
164+
const firstRow = res.rows[0];
165+
const columnNames = Object.keys(firstRow);
166+
// Check if we have multiple columns
167+
if (columnNames.length > 1) {
168+
reject(new Error("Multiple columns were found, expected only one value"));
169+
return;
170+
}
171+
resolve(firstRow[columnNames[0]]);
172+
}
173+
client.end();
174+
});
175+
} catch (e) {
176+
reject(e);
177+
client.end();
178+
}
179+
}
180+
});
181+
});
182+
}
79183
}
80184

81185
module.exports = {

test/backend-test/monitors/test-postgres.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,203 @@ describe(
4949

5050
await assert.rejects(postgresMonitor.check(monitor, heartbeat, {}), regex);
5151
});
52+
53+
test("check() sets status to UP when custom query returns single value", async () => {
54+
// The default timeout of 30 seconds might not be enough for the container to start
55+
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
56+
.withStartupTimeout(60000)
57+
.start();
58+
59+
const postgresMonitor = new PostgresMonitorType();
60+
const monitor = {
61+
databaseConnectionString: postgresContainer.getConnectionUri(),
62+
databaseQuery: "SELECT 42",
63+
conditions: "[]",
64+
};
65+
66+
const heartbeat = {
67+
msg: "",
68+
status: PENDING,
69+
};
70+
71+
try {
72+
await postgresMonitor.check(monitor, heartbeat, {});
73+
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
74+
} finally {
75+
await postgresContainer.stop();
76+
}
77+
});
78+
test("check() sets status to UP when custom query result meets condition", async () => {
79+
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
80+
.withStartupTimeout(60000)
81+
.start();
82+
83+
const postgresMonitor = new PostgresMonitorType();
84+
const monitor = {
85+
databaseConnectionString: postgresContainer.getConnectionUri(),
86+
databaseQuery: "SELECT 42 AS value",
87+
conditions: JSON.stringify([
88+
{
89+
type: "expression",
90+
andOr: "and",
91+
variable: "result",
92+
operator: "equals",
93+
value: "42",
94+
},
95+
]),
96+
};
97+
98+
const heartbeat = {
99+
msg: "",
100+
status: PENDING,
101+
};
102+
103+
try {
104+
await postgresMonitor.check(monitor, heartbeat, {});
105+
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
106+
} finally {
107+
await postgresContainer.stop();
108+
}
109+
});
110+
test("check() rejects when custom query result does not meet condition", async () => {
111+
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
112+
.withStartupTimeout(60000)
113+
.start();
114+
115+
const postgresMonitor = new PostgresMonitorType();
116+
const monitor = {
117+
databaseConnectionString: postgresContainer.getConnectionUri(),
118+
databaseQuery: "SELECT 99 AS value",
119+
conditions: JSON.stringify([
120+
{
121+
type: "expression",
122+
andOr: "and",
123+
variable: "result",
124+
operator: "equals",
125+
value: "42",
126+
},
127+
]),
128+
};
129+
130+
const heartbeat = {
131+
msg: "",
132+
status: PENDING,
133+
};
134+
135+
try {
136+
await assert.rejects(
137+
postgresMonitor.check(monitor, heartbeat, {}),
138+
new Error("Query result did not meet the specified conditions (99)")
139+
);
140+
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
141+
} finally {
142+
await postgresContainer.stop();
143+
}
144+
});
145+
test("check() rejects when query returns no results with conditions", async () => {
146+
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
147+
.withStartupTimeout(60000)
148+
.start();
149+
150+
const postgresMonitor = new PostgresMonitorType();
151+
const monitor = {
152+
databaseConnectionString: postgresContainer.getConnectionUri(),
153+
databaseQuery: "SELECT 1 WHERE 1 = 0",
154+
conditions: JSON.stringify([
155+
{
156+
type: "expression",
157+
andOr: "and",
158+
variable: "result",
159+
operator: "equals",
160+
value: "1",
161+
},
162+
]),
163+
};
164+
165+
const heartbeat = {
166+
msg: "",
167+
status: PENDING,
168+
};
169+
170+
try {
171+
await assert.rejects(
172+
postgresMonitor.check(monitor, heartbeat, {}),
173+
new Error("Database connection/query failed: Query returned no results")
174+
);
175+
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
176+
} finally {
177+
await postgresContainer.stop();
178+
}
179+
});
180+
test("check() rejects when query returns multiple rows with conditions", async () => {
181+
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
182+
.withStartupTimeout(60000)
183+
.start();
184+
185+
const postgresMonitor = new PostgresMonitorType();
186+
const monitor = {
187+
databaseConnectionString: postgresContainer.getConnectionUri(),
188+
databaseQuery: "SELECT 1 UNION ALL SELECT 2",
189+
conditions: JSON.stringify([
190+
{
191+
type: "expression",
192+
andOr: "and",
193+
variable: "result",
194+
operator: "equals",
195+
value: "1",
196+
},
197+
]),
198+
};
199+
200+
const heartbeat = {
201+
msg: "",
202+
status: PENDING,
203+
};
204+
205+
try {
206+
await assert.rejects(
207+
postgresMonitor.check(monitor, heartbeat, {}),
208+
new Error("Database connection/query failed: Multiple values were found, expected only one value")
209+
);
210+
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
211+
} finally {
212+
await postgresContainer.stop();
213+
}
214+
});
215+
test("check() rejects when query returns multiple columns with conditions", async () => {
216+
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
217+
.withStartupTimeout(60000)
218+
.start();
219+
220+
const postgresMonitor = new PostgresMonitorType();
221+
const monitor = {
222+
databaseConnectionString: postgresContainer.getConnectionUri(),
223+
databaseQuery: "SELECT 1 AS col1, 2 AS col2",
224+
conditions: JSON.stringify([
225+
{
226+
type: "expression",
227+
andOr: "and",
228+
variable: "result",
229+
operator: "equals",
230+
value: "1",
231+
},
232+
]),
233+
};
234+
235+
const heartbeat = {
236+
msg: "",
237+
status: PENDING,
238+
};
239+
240+
try {
241+
await assert.rejects(
242+
postgresMonitor.check(monitor, heartbeat, {}),
243+
new Error("Database connection/query failed: Multiple columns were found, expected only one value")
244+
);
245+
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
246+
} finally {
247+
await postgresContainer.stop();
248+
}
249+
});
52250
}
53251
);

0 commit comments

Comments
 (0)