@constructive-io/graphql-server 4.10.0 → 4.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/diagnostics/debug-db-snapshot.d.ts +19 -0
- package/diagnostics/debug-db-snapshot.js +196 -0
- package/diagnostics/debug-memory-snapshot.d.ts +53 -0
- package/diagnostics/debug-memory-snapshot.js +77 -0
- package/diagnostics/debug-sampler.d.ts +5 -0
- package/diagnostics/debug-sampler.js +195 -0
- package/diagnostics/observability.d.ts +6 -0
- package/diagnostics/observability.js +62 -0
- package/esm/diagnostics/debug-db-snapshot.js +191 -0
- package/esm/diagnostics/debug-memory-snapshot.js +70 -0
- package/esm/diagnostics/debug-sampler.js +188 -0
- package/esm/diagnostics/observability.js +53 -0
- package/esm/middleware/graphile.js +8 -1
- package/esm/middleware/observability/debug-db.js +23 -0
- package/esm/middleware/observability/debug-memory.js +8 -0
- package/esm/middleware/observability/graphile-build-stats.js +165 -0
- package/esm/middleware/observability/guard.js +14 -0
- package/esm/middleware/observability/request-logger.js +42 -0
- package/esm/server.js +37 -25
- package/middleware/graphile.js +8 -1
- package/middleware/observability/debug-db.d.ts +3 -0
- package/middleware/observability/debug-db.js +27 -0
- package/middleware/observability/debug-memory.d.ts +2 -0
- package/middleware/observability/debug-memory.js +12 -0
- package/middleware/observability/graphile-build-stats.d.ts +45 -0
- package/middleware/observability/graphile-build-stats.js +171 -0
- package/middleware/observability/guard.d.ts +2 -0
- package/middleware/observability/guard.js +18 -0
- package/middleware/observability/request-logger.d.ts +6 -0
- package/middleware/observability/request-logger.js +46 -0
- package/package.json +35 -33
- package/server.d.ts +1 -0
- package/server.js +37 -25
- package/esm/middleware/debug-memory.js +0 -54
- package/middleware/debug-memory.d.ts +0 -15
- package/middleware/debug-memory.js +0 -58
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { buildConnectionString, getPgPool } from 'pg-cache';
|
|
2
|
+
import { getPgEnvOptions } from 'pg-env';
|
|
3
|
+
import { Pool } from 'pg';
|
|
4
|
+
const ACTIVE_ACTIVITY_SQL = `
|
|
5
|
+
select
|
|
6
|
+
pid,
|
|
7
|
+
usename,
|
|
8
|
+
application_name,
|
|
9
|
+
state,
|
|
10
|
+
wait_event_type,
|
|
11
|
+
wait_event,
|
|
12
|
+
age(now(), xact_start) as xact_age,
|
|
13
|
+
age(now(), query_start) as query_age,
|
|
14
|
+
left(query, 500) as query
|
|
15
|
+
from pg_stat_activity
|
|
16
|
+
where datname = current_database()
|
|
17
|
+
and pid <> pg_backend_pid()
|
|
18
|
+
and (
|
|
19
|
+
xact_start is not null
|
|
20
|
+
or wait_event_type is not null
|
|
21
|
+
or state <> 'idle'
|
|
22
|
+
)
|
|
23
|
+
order by xact_start asc nulls last, query_start asc nulls last
|
|
24
|
+
limit 50
|
|
25
|
+
`;
|
|
26
|
+
const BLOCKED_ACTIVITY_SQL = `
|
|
27
|
+
with blocked as (
|
|
28
|
+
select
|
|
29
|
+
a.pid as blocked_pid,
|
|
30
|
+
a.usename as blocked_user,
|
|
31
|
+
a.application_name as blocked_application,
|
|
32
|
+
a.state as blocked_state,
|
|
33
|
+
a.wait_event_type,
|
|
34
|
+
a.wait_event,
|
|
35
|
+
age(now(), a.query_start) as blocked_for,
|
|
36
|
+
left(a.query, 500) as blocked_query,
|
|
37
|
+
pg_blocking_pids(a.pid) as blocker_pids
|
|
38
|
+
from pg_stat_activity a
|
|
39
|
+
where a.datname = current_database()
|
|
40
|
+
and cardinality(pg_blocking_pids(a.pid)) > 0
|
|
41
|
+
)
|
|
42
|
+
select
|
|
43
|
+
b.blocked_pid,
|
|
44
|
+
b.blocked_user,
|
|
45
|
+
b.blocked_application,
|
|
46
|
+
b.blocked_state,
|
|
47
|
+
b.wait_event_type,
|
|
48
|
+
b.wait_event,
|
|
49
|
+
b.blocked_for,
|
|
50
|
+
b.blocked_query,
|
|
51
|
+
blocker.pid as blocker_pid,
|
|
52
|
+
blocker.usename as blocker_user,
|
|
53
|
+
blocker.application_name as blocker_application,
|
|
54
|
+
blocker.state as blocker_state,
|
|
55
|
+
left(blocker.query, 500) as blocker_query
|
|
56
|
+
from blocked b
|
|
57
|
+
left join lateral unnest(b.blocker_pids) blocker_pid on true
|
|
58
|
+
left join pg_stat_activity blocker on blocker.pid = blocker_pid
|
|
59
|
+
order by b.blocked_for desc
|
|
60
|
+
`;
|
|
61
|
+
const LOCK_SUMMARY_SQL = `
|
|
62
|
+
select
|
|
63
|
+
locktype,
|
|
64
|
+
mode,
|
|
65
|
+
granted,
|
|
66
|
+
count(*)::int as count
|
|
67
|
+
from pg_locks
|
|
68
|
+
group by locktype, mode, granted
|
|
69
|
+
order by granted asc, count desc, locktype asc, mode asc
|
|
70
|
+
`;
|
|
71
|
+
const DATABASE_STATS_SQL = `
|
|
72
|
+
select
|
|
73
|
+
numbackends,
|
|
74
|
+
xact_commit,
|
|
75
|
+
xact_rollback,
|
|
76
|
+
blks_read,
|
|
77
|
+
blks_hit,
|
|
78
|
+
tup_returned,
|
|
79
|
+
tup_fetched,
|
|
80
|
+
tup_inserted,
|
|
81
|
+
tup_updated,
|
|
82
|
+
tup_deleted,
|
|
83
|
+
temp_files,
|
|
84
|
+
temp_bytes,
|
|
85
|
+
deadlocks,
|
|
86
|
+
checksum_failures,
|
|
87
|
+
stats_reset
|
|
88
|
+
from pg_stat_database
|
|
89
|
+
where datname = current_database()
|
|
90
|
+
`;
|
|
91
|
+
const SETTINGS_SQL = `
|
|
92
|
+
select
|
|
93
|
+
name,
|
|
94
|
+
setting,
|
|
95
|
+
unit
|
|
96
|
+
from pg_settings
|
|
97
|
+
where name = any(array[
|
|
98
|
+
'max_connections',
|
|
99
|
+
'shared_buffers',
|
|
100
|
+
'work_mem',
|
|
101
|
+
'maintenance_work_mem',
|
|
102
|
+
'effective_cache_size',
|
|
103
|
+
'statement_timeout',
|
|
104
|
+
'lock_timeout',
|
|
105
|
+
'idle_in_transaction_session_timeout'
|
|
106
|
+
])
|
|
107
|
+
order by name asc
|
|
108
|
+
`;
|
|
109
|
+
const NOTIFY_QUEUE_SQL = `
|
|
110
|
+
select pg_notification_queue_usage() as queue_usage
|
|
111
|
+
`;
|
|
112
|
+
const DIAGNOSTICS_STATEMENT_TIMEOUT_MS = 3_000;
|
|
113
|
+
const DIAGNOSTICS_LOCK_TIMEOUT_MS = 500;
|
|
114
|
+
const diagnosticsPools = new Map();
|
|
115
|
+
const buildDiagnosticsConnectionString = (opts) => {
|
|
116
|
+
const pgConfig = getPgEnvOptions(opts.pg);
|
|
117
|
+
return buildConnectionString(pgConfig.user, pgConfig.password, pgConfig.host, pgConfig.port, pgConfig.database);
|
|
118
|
+
};
|
|
119
|
+
const getDiagnosticsPool = (opts) => {
|
|
120
|
+
const connectionString = buildDiagnosticsConnectionString(opts);
|
|
121
|
+
const existing = diagnosticsPools.get(connectionString);
|
|
122
|
+
if (existing) {
|
|
123
|
+
return existing;
|
|
124
|
+
}
|
|
125
|
+
const pool = new Pool({
|
|
126
|
+
connectionString,
|
|
127
|
+
max: 1,
|
|
128
|
+
idleTimeoutMillis: 10_000,
|
|
129
|
+
connectionTimeoutMillis: 1_500,
|
|
130
|
+
allowExitOnIdle: true,
|
|
131
|
+
application_name: 'constructive-debug-snapshot',
|
|
132
|
+
});
|
|
133
|
+
diagnosticsPools.set(connectionString, pool);
|
|
134
|
+
return pool;
|
|
135
|
+
};
|
|
136
|
+
const withDiagnosticsClient = async (opts, fn) => {
|
|
137
|
+
const diagnosticsPool = getDiagnosticsPool(opts);
|
|
138
|
+
const client = await diagnosticsPool.connect();
|
|
139
|
+
try {
|
|
140
|
+
await client.query('BEGIN');
|
|
141
|
+
await client.query(`SET LOCAL statement_timeout = '${DIAGNOSTICS_STATEMENT_TIMEOUT_MS}ms'`);
|
|
142
|
+
await client.query(`SET LOCAL lock_timeout = '${DIAGNOSTICS_LOCK_TIMEOUT_MS}ms'`);
|
|
143
|
+
const result = await fn(client);
|
|
144
|
+
await client.query('COMMIT');
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
try {
|
|
149
|
+
await client.query('ROLLBACK');
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Best-effort rollback for diagnostics-only transactions.
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
client.release();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
export const closeDebugDatabasePools = async () => {
|
|
161
|
+
const pools = [...diagnosticsPools.values()];
|
|
162
|
+
diagnosticsPools.clear();
|
|
163
|
+
await Promise.allSettled(pools.map((pool) => pool.end()));
|
|
164
|
+
};
|
|
165
|
+
export const getDebugDatabaseSnapshot = async (opts) => {
|
|
166
|
+
const appPool = getPgPool(opts.pg);
|
|
167
|
+
const { activity, blocked, lockSummary, databaseStats, settings, notifyQueue, } = await withDiagnosticsClient(opts, async (client) => ({
|
|
168
|
+
activity: await client.query(ACTIVE_ACTIVITY_SQL),
|
|
169
|
+
blocked: await client.query(BLOCKED_ACTIVITY_SQL),
|
|
170
|
+
lockSummary: await client.query(LOCK_SUMMARY_SQL),
|
|
171
|
+
databaseStats: await client.query(DATABASE_STATS_SQL),
|
|
172
|
+
settings: await client.query(SETTINGS_SQL),
|
|
173
|
+
notifyQueue: await client.query(NOTIFY_QUEUE_SQL),
|
|
174
|
+
}));
|
|
175
|
+
return {
|
|
176
|
+
database: opts.pg?.database ?? null,
|
|
177
|
+
pool: {
|
|
178
|
+
max: appPool.options?.max ?? null,
|
|
179
|
+
totalCount: appPool.totalCount,
|
|
180
|
+
idleCount: appPool.idleCount,
|
|
181
|
+
waitingCount: appPool.waitingCount,
|
|
182
|
+
},
|
|
183
|
+
activeActivity: activity.rows,
|
|
184
|
+
blockedActivity: blocked.rows,
|
|
185
|
+
lockSummary: lockSummary.rows,
|
|
186
|
+
databaseStats: databaseStats.rows[0] ?? null,
|
|
187
|
+
settings: settings.rows,
|
|
188
|
+
notificationQueueUsage: notifyQueue.rows[0]?.queue_usage ?? null,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
};
|
|
191
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import v8 from 'node:v8';
|
|
3
|
+
import { svcCache, SVC_CACHE_TTL_MS } from '@pgpmjs/server-utils';
|
|
4
|
+
import { getCacheStats } from 'graphile-cache';
|
|
5
|
+
import { getInFlightCount, getInFlightKeys } from '../middleware/graphile';
|
|
6
|
+
import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats';
|
|
7
|
+
const toMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
8
|
+
export const getDebugMemorySnapshot = () => {
|
|
9
|
+
const mem = process.memoryUsage();
|
|
10
|
+
const heapSpaces = v8.getHeapSpaceStatistics().map((space) => ({
|
|
11
|
+
spaceName: space.space_name,
|
|
12
|
+
spaceSizeBytes: space.space_size,
|
|
13
|
+
spaceUsedBytes: space.space_used_size,
|
|
14
|
+
spaceAvailableBytes: space.space_available_size,
|
|
15
|
+
physicalSpaceSizeBytes: space.physical_space_size,
|
|
16
|
+
}));
|
|
17
|
+
return {
|
|
18
|
+
pid: process.pid,
|
|
19
|
+
nodeEnv: process.env.NODE_ENV,
|
|
20
|
+
memory: {
|
|
21
|
+
heapUsedBytes: mem.heapUsed,
|
|
22
|
+
heapTotalBytes: mem.heapTotal,
|
|
23
|
+
rssBytes: mem.rss,
|
|
24
|
+
externalBytes: mem.external,
|
|
25
|
+
arrayBuffersBytes: mem.arrayBuffers,
|
|
26
|
+
heapUsed: toMB(mem.heapUsed),
|
|
27
|
+
heapTotal: toMB(mem.heapTotal),
|
|
28
|
+
rss: toMB(mem.rss),
|
|
29
|
+
external: toMB(mem.external),
|
|
30
|
+
arrayBuffers: toMB(mem.arrayBuffers),
|
|
31
|
+
},
|
|
32
|
+
cpuUsageMicros: process.cpuUsage(),
|
|
33
|
+
resourceUsage: process.resourceUsage(),
|
|
34
|
+
system: {
|
|
35
|
+
loadAverage: os.loadavg(),
|
|
36
|
+
freeMemoryBytes: os.freemem(),
|
|
37
|
+
totalMemoryBytes: os.totalmem(),
|
|
38
|
+
uptimeSeconds: os.uptime(),
|
|
39
|
+
},
|
|
40
|
+
v8: {
|
|
41
|
+
heapStatistics: v8.getHeapStatistics(),
|
|
42
|
+
heapSpaces,
|
|
43
|
+
},
|
|
44
|
+
graphileCache: getCacheStats(),
|
|
45
|
+
svcCache: {
|
|
46
|
+
size: svcCache.size,
|
|
47
|
+
max: svcCache.max,
|
|
48
|
+
ttlMs: SVC_CACHE_TTL_MS,
|
|
49
|
+
// Note: with updateAgeOnGet: true, this is "time since last access" not "time since creation"
|
|
50
|
+
oldestKeyAgeMs: (() => {
|
|
51
|
+
let minRemaining = Infinity;
|
|
52
|
+
for (const key of svcCache.keys()) {
|
|
53
|
+
const remaining = svcCache.getRemainingTTL(key);
|
|
54
|
+
if (remaining < minRemaining) {
|
|
55
|
+
minRemaining = remaining;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Number.isFinite(minRemaining) ? SVC_CACHE_TTL_MS - minRemaining : null;
|
|
59
|
+
})(),
|
|
60
|
+
keys: [...svcCache.keys()].slice(0, 200),
|
|
61
|
+
},
|
|
62
|
+
inFlight: {
|
|
63
|
+
count: getInFlightCount(),
|
|
64
|
+
keys: getInFlightKeys(),
|
|
65
|
+
},
|
|
66
|
+
graphileBuilds: getGraphileBuildStats(),
|
|
67
|
+
uptimeMinutes: process.uptime() / 60,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Logger } from '@pgpmjs/logger';
|
|
4
|
+
import { getDebugDatabaseSnapshot } from './debug-db-snapshot';
|
|
5
|
+
import { getDebugMemorySnapshot } from './debug-memory-snapshot';
|
|
6
|
+
import { isGraphqlDebugSamplerEnabled } from './observability';
|
|
7
|
+
const log = new Logger('debug-sampler');
|
|
8
|
+
const MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GB
|
|
9
|
+
const getSamplerIntervalMs = () => {
|
|
10
|
+
const raw = process.env.GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS;
|
|
11
|
+
const parsed = raw ? Number.parseInt(raw, 10) : 10_000;
|
|
12
|
+
return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : 10_000;
|
|
13
|
+
};
|
|
14
|
+
const getSamplerRootDir = () => {
|
|
15
|
+
if (process.env.GRAPHQL_DEBUG_SAMPLER_DIR) {
|
|
16
|
+
return path.resolve(process.env.GRAPHQL_DEBUG_SAMPLER_DIR);
|
|
17
|
+
}
|
|
18
|
+
return path.resolve(__dirname, '../..', 'logs');
|
|
19
|
+
};
|
|
20
|
+
const createSessionLogDir = () => {
|
|
21
|
+
const rootDir = getSamplerRootDir();
|
|
22
|
+
const sessionName = `run-${new Date().toISOString().replace(/[:.]/g, '-')}-pid${process.pid}`;
|
|
23
|
+
return process.env.GRAPHQL_DEBUG_SAMPLER_DIR
|
|
24
|
+
? rootDir
|
|
25
|
+
: path.join(rootDir, sessionName);
|
|
26
|
+
};
|
|
27
|
+
const appendJsonLine = async (filePath, payload) => {
|
|
28
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
29
|
+
await fs.appendFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
|
|
30
|
+
};
|
|
31
|
+
const getDirSize = async (dirPath) => {
|
|
32
|
+
let total = 0;
|
|
33
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
total += await getDirSize(fullPath);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const stat = await fs.stat(fullPath);
|
|
41
|
+
total += stat.size;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return total;
|
|
45
|
+
};
|
|
46
|
+
const enforceMaxSize = async (rootDir, currentSessionDir) => {
|
|
47
|
+
try {
|
|
48
|
+
const totalSize = await getDirSize(rootDir);
|
|
49
|
+
if (totalSize <= MAX_TOTAL_BYTES) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
53
|
+
const sessionDirs = await Promise.all(entries
|
|
54
|
+
.filter((e) => e.isDirectory())
|
|
55
|
+
.map(async (e) => {
|
|
56
|
+
const fullPath = path.join(rootDir, e.name);
|
|
57
|
+
const stat = await fs.stat(fullPath);
|
|
58
|
+
return { path: fullPath, mtimeMs: stat.mtimeMs };
|
|
59
|
+
}));
|
|
60
|
+
sessionDirs.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
61
|
+
for (const dir of sessionDirs) {
|
|
62
|
+
if (dir.path === currentSessionDir) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
log.info(`Rolling cleanup: removing old session dir ${dir.path}`);
|
|
66
|
+
await fs.rm(dir.path, { recursive: true, force: true });
|
|
67
|
+
const newSize = await getDirSize(rootDir);
|
|
68
|
+
if (newSize <= MAX_TOTAL_BYTES) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
log.error('Failed to enforce max log size', error);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
export const startDebugSampler = (opts) => {
|
|
78
|
+
if (!isGraphqlDebugSamplerEnabled(opts.server?.host)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const intervalMs = getSamplerIntervalMs();
|
|
82
|
+
const logDir = createSessionLogDir();
|
|
83
|
+
const memoryLogPath = path.join(logDir, 'debug-memory.ndjson');
|
|
84
|
+
const dbLogPath = path.join(logDir, 'debug-db.ndjson');
|
|
85
|
+
const errorLogPath = path.join(logDir, 'debug-sampler-errors.ndjson');
|
|
86
|
+
let timer = null;
|
|
87
|
+
let stopped = false;
|
|
88
|
+
let inFlight = null;
|
|
89
|
+
let writeFailureLogged = false;
|
|
90
|
+
const runBackgroundWrite = (promise, scope) => {
|
|
91
|
+
promise.catch((error) => {
|
|
92
|
+
// Avoid recursive attempts to write additional error files when the
|
|
93
|
+
// underlying storage path is broken or unavailable.
|
|
94
|
+
if (!writeFailureLogged) {
|
|
95
|
+
writeFailureLogged = true;
|
|
96
|
+
log.error(`Debug sampler background write failed (${scope})`, error);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const recordError = async (scope, error) => {
|
|
101
|
+
const payload = {
|
|
102
|
+
scope,
|
|
103
|
+
timestamp: new Date().toISOString(),
|
|
104
|
+
pid: process.pid,
|
|
105
|
+
error: error instanceof Error ? {
|
|
106
|
+
name: error.name,
|
|
107
|
+
message: error.message,
|
|
108
|
+
stack: error.stack,
|
|
109
|
+
} : {
|
|
110
|
+
message: String(error),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
await appendJsonLine(errorLogPath, payload);
|
|
114
|
+
};
|
|
115
|
+
const sampleOnce = async () => {
|
|
116
|
+
if (stopped) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await appendJsonLine(memoryLogPath, getDebugMemorySnapshot());
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
log.error('Failed to capture debug memory snapshot', error);
|
|
124
|
+
await recordError('memory', error);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await appendJsonLine(dbLogPath, await getDebugDatabaseSnapshot(opts));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
log.error('Failed to capture debug DB snapshot', error);
|
|
131
|
+
await recordError('db', error);
|
|
132
|
+
}
|
|
133
|
+
await enforceMaxSize(getSamplerRootDir(), logDir);
|
|
134
|
+
};
|
|
135
|
+
const tick = () => {
|
|
136
|
+
if (stopped) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (inFlight) {
|
|
140
|
+
runBackgroundWrite(recordError('sampler', new Error('Skipped debug sample because previous sample is still running')), 'record-skip');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
inFlight = sampleOnce()
|
|
144
|
+
.catch(async (error) => {
|
|
145
|
+
log.error('Debug sampler tick failed', error);
|
|
146
|
+
await recordError('sampler', error);
|
|
147
|
+
})
|
|
148
|
+
.finally(() => {
|
|
149
|
+
inFlight = null;
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
const lifecyclePayload = {
|
|
153
|
+
event: 'sampler_started',
|
|
154
|
+
intervalMs,
|
|
155
|
+
logDir,
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
timestamp: new Date().toISOString(),
|
|
158
|
+
};
|
|
159
|
+
runBackgroundWrite(appendJsonLine(memoryLogPath, lifecyclePayload), 'lifecycle-memory-start');
|
|
160
|
+
runBackgroundWrite(appendJsonLine(dbLogPath, lifecyclePayload), 'lifecycle-db-start');
|
|
161
|
+
log.info(`Debug sampler writing snapshots every ${intervalMs}ms to ${logDir}`);
|
|
162
|
+
tick();
|
|
163
|
+
timer = setInterval(tick, intervalMs);
|
|
164
|
+
timer.unref();
|
|
165
|
+
return {
|
|
166
|
+
async stop() {
|
|
167
|
+
stopped = true;
|
|
168
|
+
if (timer) {
|
|
169
|
+
clearInterval(timer);
|
|
170
|
+
timer = null;
|
|
171
|
+
}
|
|
172
|
+
if (inFlight) {
|
|
173
|
+
await inFlight;
|
|
174
|
+
}
|
|
175
|
+
const payload = {
|
|
176
|
+
event: 'sampler_stopped',
|
|
177
|
+
intervalMs,
|
|
178
|
+
logDir,
|
|
179
|
+
pid: process.pid,
|
|
180
|
+
timestamp: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
await Promise.allSettled([
|
|
183
|
+
appendJsonLine(memoryLogPath, payload),
|
|
184
|
+
appendJsonLine(dbLogPath, payload),
|
|
185
|
+
]);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
2
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1']);
|
|
3
|
+
const parseBooleanEnv = (value, fallback) => {
|
|
4
|
+
if (value == null) {
|
|
5
|
+
return fallback;
|
|
6
|
+
}
|
|
7
|
+
const normalized = value.trim().toLowerCase();
|
|
8
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized))
|
|
9
|
+
return true;
|
|
10
|
+
if (['0', 'false', 'no', 'off'].includes(normalized))
|
|
11
|
+
return false;
|
|
12
|
+
return fallback;
|
|
13
|
+
};
|
|
14
|
+
const normalizeHost = (value) => {
|
|
15
|
+
if (!value) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const trimmed = value.trim().toLowerCase();
|
|
19
|
+
if (!trimmed) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (trimmed.startsWith('[')) {
|
|
23
|
+
const closingIndex = trimmed.indexOf(']');
|
|
24
|
+
return closingIndex === -1 ? trimmed : trimmed.slice(0, closingIndex + 1);
|
|
25
|
+
}
|
|
26
|
+
const colonCount = trimmed.split(':').length - 1;
|
|
27
|
+
if (colonCount === 1) {
|
|
28
|
+
return trimmed.split(':')[0] || null;
|
|
29
|
+
}
|
|
30
|
+
return trimmed;
|
|
31
|
+
};
|
|
32
|
+
const normalizeAddress = (value) => {
|
|
33
|
+
const normalized = normalizeHost(value);
|
|
34
|
+
if (!normalized) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized;
|
|
38
|
+
};
|
|
39
|
+
export const isDevelopmentObservabilityMode = () => process.env.NODE_ENV === 'development';
|
|
40
|
+
export const isLoopbackHost = (value) => {
|
|
41
|
+
const normalized = normalizeHost(value);
|
|
42
|
+
return normalized != null && LOOPBACK_HOSTS.has(normalized);
|
|
43
|
+
};
|
|
44
|
+
export const isLoopbackAddress = (value) => {
|
|
45
|
+
const normalized = normalizeAddress(value);
|
|
46
|
+
return normalized != null && LOOPBACK_ADDRESSES.has(normalized);
|
|
47
|
+
};
|
|
48
|
+
export const isGraphqlObservabilityRequested = () => parseBooleanEnv(process.env.GRAPHQL_OBSERVABILITY_ENABLED, false);
|
|
49
|
+
export const isGraphqlObservabilityEnabled = (serverHost) => isDevelopmentObservabilityMode() &&
|
|
50
|
+
isGraphqlObservabilityRequested() &&
|
|
51
|
+
isLoopbackHost(serverHost);
|
|
52
|
+
export const isGraphqlDebugSamplerEnabled = (serverHost) => isGraphqlObservabilityEnabled(serverHost) &&
|
|
53
|
+
parseBooleanEnv(process.env.GRAPHQL_DEBUG_SAMPLER_ENABLED, true);
|
|
@@ -6,7 +6,9 @@ import { ConstructivePreset, makePgService } from 'graphile-settings';
|
|
|
6
6
|
import { buildConnectionString } from 'pg-cache';
|
|
7
7
|
import { getPgEnvOptions } from 'pg-env';
|
|
8
8
|
import './types'; // for Request type
|
|
9
|
+
import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
9
10
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
11
|
+
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
10
12
|
const maskErrorLog = new Logger('graphile:maskError');
|
|
11
13
|
const SAFE_ERROR_CODES = new Set([
|
|
12
14
|
// GraphQL standard
|
|
@@ -170,6 +172,7 @@ const buildPreset = (connectionString, schemas, anonRole, roleName) => ({
|
|
|
170
172
|
},
|
|
171
173
|
});
|
|
172
174
|
export const graphile = (opts) => {
|
|
175
|
+
const observabilityEnabled = isGraphqlObservabilityEnabled(opts.server?.host);
|
|
173
176
|
return async (req, res, next) => {
|
|
174
177
|
const label = reqLabel(req);
|
|
175
178
|
try {
|
|
@@ -233,7 +236,11 @@ export const graphile = (opts) => {
|
|
|
233
236
|
const connectionString = buildConnectionString(pgConfig.user, pgConfig.password, pgConfig.host, pgConfig.port, pgConfig.database);
|
|
234
237
|
// Create promise and store in in-flight map BEFORE try block
|
|
235
238
|
const preset = buildPreset(connectionString, schema || [], anonRole, roleName);
|
|
236
|
-
const creationPromise =
|
|
239
|
+
const creationPromise = observeGraphileBuild({
|
|
240
|
+
cacheKey: key,
|
|
241
|
+
serviceKey: key,
|
|
242
|
+
databaseId: api.databaseId ?? null,
|
|
243
|
+
}, () => createGraphileInstance({ preset, cacheKey: key }), { enabled: observabilityEnabled });
|
|
237
244
|
creating.set(key, creationPromise);
|
|
238
245
|
try {
|
|
239
246
|
const instance = await creationPromise;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Logger } from '@pgpmjs/logger';
|
|
2
|
+
import { getDebugDatabaseSnapshot } from '../../diagnostics/debug-db-snapshot';
|
|
3
|
+
const log = new Logger('debug-db');
|
|
4
|
+
export const createDebugDatabaseMiddleware = (opts) => {
|
|
5
|
+
return async (_req, res) => {
|
|
6
|
+
try {
|
|
7
|
+
const response = await getDebugDatabaseSnapshot(opts);
|
|
8
|
+
log.debug('Database debug snapshot', {
|
|
9
|
+
activeActivity: response.activeActivity.length,
|
|
10
|
+
blockedActivity: response.blockedActivity.length,
|
|
11
|
+
lockSummary: response.lockSummary.length,
|
|
12
|
+
});
|
|
13
|
+
res.json(response);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
log.error('Failed to fetch debug DB snapshot', error);
|
|
17
|
+
res.status(500).json({
|
|
18
|
+
error: 'Failed to fetch database debug snapshot',
|
|
19
|
+
message: error instanceof Error ? error.message : String(error),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Logger } from '@pgpmjs/logger';
|
|
2
|
+
import { getDebugMemorySnapshot } from '../../diagnostics/debug-memory-snapshot';
|
|
3
|
+
const log = new Logger('debug-memory');
|
|
4
|
+
export const debugMemory = (_req, res) => {
|
|
5
|
+
const response = getDebugMemorySnapshot();
|
|
6
|
+
log.debug('Memory snapshot:', response);
|
|
7
|
+
res.json(response);
|
|
8
|
+
};
|