@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.
Files changed (37) hide show
  1. package/README.md +28 -0
  2. package/diagnostics/debug-db-snapshot.d.ts +19 -0
  3. package/diagnostics/debug-db-snapshot.js +196 -0
  4. package/diagnostics/debug-memory-snapshot.d.ts +53 -0
  5. package/diagnostics/debug-memory-snapshot.js +77 -0
  6. package/diagnostics/debug-sampler.d.ts +5 -0
  7. package/diagnostics/debug-sampler.js +195 -0
  8. package/diagnostics/observability.d.ts +6 -0
  9. package/diagnostics/observability.js +62 -0
  10. package/esm/diagnostics/debug-db-snapshot.js +191 -0
  11. package/esm/diagnostics/debug-memory-snapshot.js +70 -0
  12. package/esm/diagnostics/debug-sampler.js +188 -0
  13. package/esm/diagnostics/observability.js +53 -0
  14. package/esm/middleware/graphile.js +8 -1
  15. package/esm/middleware/observability/debug-db.js +23 -0
  16. package/esm/middleware/observability/debug-memory.js +8 -0
  17. package/esm/middleware/observability/graphile-build-stats.js +165 -0
  18. package/esm/middleware/observability/guard.js +14 -0
  19. package/esm/middleware/observability/request-logger.js +42 -0
  20. package/esm/server.js +37 -25
  21. package/middleware/graphile.js +8 -1
  22. package/middleware/observability/debug-db.d.ts +3 -0
  23. package/middleware/observability/debug-db.js +27 -0
  24. package/middleware/observability/debug-memory.d.ts +2 -0
  25. package/middleware/observability/debug-memory.js +12 -0
  26. package/middleware/observability/graphile-build-stats.d.ts +45 -0
  27. package/middleware/observability/graphile-build-stats.js +171 -0
  28. package/middleware/observability/guard.d.ts +2 -0
  29. package/middleware/observability/guard.js +18 -0
  30. package/middleware/observability/request-logger.d.ts +6 -0
  31. package/middleware/observability/request-logger.js +46 -0
  32. package/package.json +35 -33
  33. package/server.d.ts +1 -0
  34. package/server.js +37 -25
  35. package/esm/middleware/debug-memory.js +0 -54
  36. package/middleware/debug-memory.d.ts +0 -15
  37. package/middleware/debug-memory.js +0 -58
package/README.md CHANGED
@@ -66,6 +66,28 @@ Runs an Express server that wires CORS, uploads, domain parsing, auth, and PostG
66
66
  - File uploads via `graphql-upload`
67
67
  - GraphiQL and health check endpoints
68
68
  - Schema cache flush via `/flush` or database notifications
69
+ - Opt-in observability for memory, DB activity, and Graphile build debugging
70
+
71
+ ## Observability
72
+
73
+ `@constructive-io/graphql-server` includes an opt-in observability mode for local debugging.
74
+
75
+ - Master switch: `GRAPHQL_OBSERVABILITY_ENABLED=true`
76
+ - Debug routes: `GET /debug/memory`, `GET /debug/db`
77
+ - Background sampler: periodic NDJSON snapshots under `graphql/server/logs/`
78
+ - CLI helpers:
79
+ - `pnpm debug:memory:analyze`
80
+ - `pnpm debug:heap:capture`
81
+
82
+ Observability only activates when all of the following are true:
83
+
84
+ - `GRAPHQL_OBSERVABILITY_ENABLED=true`
85
+ - `NODE_ENV=development`
86
+ - the server is bound to a loopback host such as `localhost`, `127.0.0.1`, or `::1`
87
+
88
+ When those conditions are not met, the debug routes are not mounted and the sampler does not start. This keeps the default runtime surface minimal and prevents the observability layer from being exposed remotely.
89
+
90
+ For the operational workflow, sampler output, and heap snapshot usage, see [docs/memory-debugging.md](./docs/memory-debugging.md).
69
91
 
70
92
  ## Routes
71
93
 
@@ -74,6 +96,8 @@ Runs an Express server that wires CORS, uploads, domain parsing, auth, and PostG
74
96
  - `GET /graphql` / `POST /graphql` -> GraphQL endpoint
75
97
  - `POST /graphql` (multipart) -> file uploads
76
98
  - `POST /flush` -> clears cached Graphile schema for the current API
99
+ - `GET /debug/memory` -> memory/process/Graphile debug snapshot when observability is enabled
100
+ - `GET /debug/db` -> PostgreSQL activity/locks/pool debug snapshot when observability is enabled
77
101
 
78
102
  ## Meta API routing
79
103
 
@@ -113,6 +137,10 @@ Configuration is merged from defaults, config files, and env vars via `@construc
113
137
  | `API_ANON_ROLE` | Anonymous role name | `administrator` |
114
138
  | `API_ROLE_NAME` | Authenticated role name | `administrator` |
115
139
  | `API_DEFAULT_DATABASE_ID` | Default database ID | `hard-coded` |
140
+ | `GRAPHQL_OBSERVABILITY_ENABLED` | Master switch for debug routes and sampler | `false` |
141
+ | `GRAPHQL_DEBUG_SAMPLER_ENABLED` | Enables periodic NDJSON sampling when observability is on | `true` |
142
+ | `GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS` | Sampler interval in milliseconds | `10000` |
143
+ | `GRAPHQL_DEBUG_SAMPLER_DIR` | Override output directory for sampler logs | `graphql/server/logs` |
116
144
 
117
145
  ## Testing
118
146
 
@@ -0,0 +1,19 @@
1
+ import type { ConstructiveOptions } from '@constructive-io/graphql-types';
2
+ export declare const closeDebugDatabasePools: () => Promise<void>;
3
+ export interface DebugDatabaseSnapshot {
4
+ database: string | null | undefined;
5
+ pool: {
6
+ max: number | null;
7
+ totalCount: number;
8
+ idleCount: number;
9
+ waitingCount: number;
10
+ };
11
+ activeActivity: unknown[];
12
+ blockedActivity: unknown[];
13
+ lockSummary: unknown[];
14
+ databaseStats: Record<string, unknown> | null;
15
+ settings: unknown[];
16
+ notificationQueueUsage: number | null;
17
+ timestamp: string;
18
+ }
19
+ export declare const getDebugDatabaseSnapshot: (opts: ConstructiveOptions) => Promise<DebugDatabaseSnapshot>;
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDebugDatabaseSnapshot = exports.closeDebugDatabasePools = void 0;
4
+ const pg_cache_1 = require("pg-cache");
5
+ const pg_env_1 = require("pg-env");
6
+ const pg_1 = require("pg");
7
+ const ACTIVE_ACTIVITY_SQL = `
8
+ select
9
+ pid,
10
+ usename,
11
+ application_name,
12
+ state,
13
+ wait_event_type,
14
+ wait_event,
15
+ age(now(), xact_start) as xact_age,
16
+ age(now(), query_start) as query_age,
17
+ left(query, 500) as query
18
+ from pg_stat_activity
19
+ where datname = current_database()
20
+ and pid <> pg_backend_pid()
21
+ and (
22
+ xact_start is not null
23
+ or wait_event_type is not null
24
+ or state <> 'idle'
25
+ )
26
+ order by xact_start asc nulls last, query_start asc nulls last
27
+ limit 50
28
+ `;
29
+ const BLOCKED_ACTIVITY_SQL = `
30
+ with blocked as (
31
+ select
32
+ a.pid as blocked_pid,
33
+ a.usename as blocked_user,
34
+ a.application_name as blocked_application,
35
+ a.state as blocked_state,
36
+ a.wait_event_type,
37
+ a.wait_event,
38
+ age(now(), a.query_start) as blocked_for,
39
+ left(a.query, 500) as blocked_query,
40
+ pg_blocking_pids(a.pid) as blocker_pids
41
+ from pg_stat_activity a
42
+ where a.datname = current_database()
43
+ and cardinality(pg_blocking_pids(a.pid)) > 0
44
+ )
45
+ select
46
+ b.blocked_pid,
47
+ b.blocked_user,
48
+ b.blocked_application,
49
+ b.blocked_state,
50
+ b.wait_event_type,
51
+ b.wait_event,
52
+ b.blocked_for,
53
+ b.blocked_query,
54
+ blocker.pid as blocker_pid,
55
+ blocker.usename as blocker_user,
56
+ blocker.application_name as blocker_application,
57
+ blocker.state as blocker_state,
58
+ left(blocker.query, 500) as blocker_query
59
+ from blocked b
60
+ left join lateral unnest(b.blocker_pids) blocker_pid on true
61
+ left join pg_stat_activity blocker on blocker.pid = blocker_pid
62
+ order by b.blocked_for desc
63
+ `;
64
+ const LOCK_SUMMARY_SQL = `
65
+ select
66
+ locktype,
67
+ mode,
68
+ granted,
69
+ count(*)::int as count
70
+ from pg_locks
71
+ group by locktype, mode, granted
72
+ order by granted asc, count desc, locktype asc, mode asc
73
+ `;
74
+ const DATABASE_STATS_SQL = `
75
+ select
76
+ numbackends,
77
+ xact_commit,
78
+ xact_rollback,
79
+ blks_read,
80
+ blks_hit,
81
+ tup_returned,
82
+ tup_fetched,
83
+ tup_inserted,
84
+ tup_updated,
85
+ tup_deleted,
86
+ temp_files,
87
+ temp_bytes,
88
+ deadlocks,
89
+ checksum_failures,
90
+ stats_reset
91
+ from pg_stat_database
92
+ where datname = current_database()
93
+ `;
94
+ const SETTINGS_SQL = `
95
+ select
96
+ name,
97
+ setting,
98
+ unit
99
+ from pg_settings
100
+ where name = any(array[
101
+ 'max_connections',
102
+ 'shared_buffers',
103
+ 'work_mem',
104
+ 'maintenance_work_mem',
105
+ 'effective_cache_size',
106
+ 'statement_timeout',
107
+ 'lock_timeout',
108
+ 'idle_in_transaction_session_timeout'
109
+ ])
110
+ order by name asc
111
+ `;
112
+ const NOTIFY_QUEUE_SQL = `
113
+ select pg_notification_queue_usage() as queue_usage
114
+ `;
115
+ const DIAGNOSTICS_STATEMENT_TIMEOUT_MS = 3_000;
116
+ const DIAGNOSTICS_LOCK_TIMEOUT_MS = 500;
117
+ const diagnosticsPools = new Map();
118
+ const buildDiagnosticsConnectionString = (opts) => {
119
+ const pgConfig = (0, pg_env_1.getPgEnvOptions)(opts.pg);
120
+ return (0, pg_cache_1.buildConnectionString)(pgConfig.user, pgConfig.password, pgConfig.host, pgConfig.port, pgConfig.database);
121
+ };
122
+ const getDiagnosticsPool = (opts) => {
123
+ const connectionString = buildDiagnosticsConnectionString(opts);
124
+ const existing = diagnosticsPools.get(connectionString);
125
+ if (existing) {
126
+ return existing;
127
+ }
128
+ const pool = new pg_1.Pool({
129
+ connectionString,
130
+ max: 1,
131
+ idleTimeoutMillis: 10_000,
132
+ connectionTimeoutMillis: 1_500,
133
+ allowExitOnIdle: true,
134
+ application_name: 'constructive-debug-snapshot',
135
+ });
136
+ diagnosticsPools.set(connectionString, pool);
137
+ return pool;
138
+ };
139
+ const withDiagnosticsClient = async (opts, fn) => {
140
+ const diagnosticsPool = getDiagnosticsPool(opts);
141
+ const client = await diagnosticsPool.connect();
142
+ try {
143
+ await client.query('BEGIN');
144
+ await client.query(`SET LOCAL statement_timeout = '${DIAGNOSTICS_STATEMENT_TIMEOUT_MS}ms'`);
145
+ await client.query(`SET LOCAL lock_timeout = '${DIAGNOSTICS_LOCK_TIMEOUT_MS}ms'`);
146
+ const result = await fn(client);
147
+ await client.query('COMMIT');
148
+ return result;
149
+ }
150
+ catch (error) {
151
+ try {
152
+ await client.query('ROLLBACK');
153
+ }
154
+ catch {
155
+ // Best-effort rollback for diagnostics-only transactions.
156
+ }
157
+ throw error;
158
+ }
159
+ finally {
160
+ client.release();
161
+ }
162
+ };
163
+ const closeDebugDatabasePools = async () => {
164
+ const pools = [...diagnosticsPools.values()];
165
+ diagnosticsPools.clear();
166
+ await Promise.allSettled(pools.map((pool) => pool.end()));
167
+ };
168
+ exports.closeDebugDatabasePools = closeDebugDatabasePools;
169
+ const getDebugDatabaseSnapshot = async (opts) => {
170
+ const appPool = (0, pg_cache_1.getPgPool)(opts.pg);
171
+ const { activity, blocked, lockSummary, databaseStats, settings, notifyQueue, } = await withDiagnosticsClient(opts, async (client) => ({
172
+ activity: await client.query(ACTIVE_ACTIVITY_SQL),
173
+ blocked: await client.query(BLOCKED_ACTIVITY_SQL),
174
+ lockSummary: await client.query(LOCK_SUMMARY_SQL),
175
+ databaseStats: await client.query(DATABASE_STATS_SQL),
176
+ settings: await client.query(SETTINGS_SQL),
177
+ notifyQueue: await client.query(NOTIFY_QUEUE_SQL),
178
+ }));
179
+ return {
180
+ database: opts.pg?.database ?? null,
181
+ pool: {
182
+ max: appPool.options?.max ?? null,
183
+ totalCount: appPool.totalCount,
184
+ idleCount: appPool.idleCount,
185
+ waitingCount: appPool.waitingCount,
186
+ },
187
+ activeActivity: activity.rows,
188
+ blockedActivity: blocked.rows,
189
+ lockSummary: lockSummary.rows,
190
+ databaseStats: databaseStats.rows[0] ?? null,
191
+ settings: settings.rows,
192
+ notificationQueueUsage: notifyQueue.rows[0]?.queue_usage ?? null,
193
+ timestamp: new Date().toISOString(),
194
+ };
195
+ };
196
+ exports.getDebugDatabaseSnapshot = getDebugDatabaseSnapshot;
@@ -0,0 +1,53 @@
1
+ import v8 from 'node:v8';
2
+ import { getCacheStats } from 'graphile-cache';
3
+ import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats';
4
+ export interface DebugMemorySnapshot {
5
+ pid: number;
6
+ nodeEnv: string | undefined;
7
+ memory: {
8
+ heapUsedBytes: number;
9
+ heapTotalBytes: number;
10
+ rssBytes: number;
11
+ externalBytes: number;
12
+ arrayBuffersBytes: number;
13
+ heapUsed: string;
14
+ heapTotal: string;
15
+ rss: string;
16
+ external: string;
17
+ arrayBuffers: string;
18
+ };
19
+ cpuUsageMicros: NodeJS.CpuUsage;
20
+ resourceUsage: NodeJS.ResourceUsage;
21
+ system: {
22
+ loadAverage: number[];
23
+ freeMemoryBytes: number;
24
+ totalMemoryBytes: number;
25
+ uptimeSeconds: number;
26
+ };
27
+ v8: {
28
+ heapStatistics: ReturnType<typeof v8.getHeapStatistics>;
29
+ heapSpaces: Array<{
30
+ spaceName: string;
31
+ spaceSizeBytes: number;
32
+ spaceUsedBytes: number;
33
+ spaceAvailableBytes: number;
34
+ physicalSpaceSizeBytes: number;
35
+ }>;
36
+ };
37
+ graphileCache: ReturnType<typeof getCacheStats>;
38
+ svcCache: {
39
+ size: number;
40
+ max: number;
41
+ ttlMs: number;
42
+ oldestKeyAgeMs: number | null;
43
+ keys: string[];
44
+ };
45
+ inFlight: {
46
+ count: number;
47
+ keys: string[];
48
+ };
49
+ graphileBuilds: ReturnType<typeof getGraphileBuildStats>;
50
+ uptimeMinutes: number;
51
+ timestamp: string;
52
+ }
53
+ export declare const getDebugMemorySnapshot: () => DebugMemorySnapshot;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getDebugMemorySnapshot = void 0;
7
+ const node_os_1 = __importDefault(require("node:os"));
8
+ const node_v8_1 = __importDefault(require("node:v8"));
9
+ const server_utils_1 = require("@pgpmjs/server-utils");
10
+ const graphile_cache_1 = require("graphile-cache");
11
+ const graphile_1 = require("../middleware/graphile");
12
+ const graphile_build_stats_1 = require("../middleware/observability/graphile-build-stats");
13
+ const toMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
14
+ const getDebugMemorySnapshot = () => {
15
+ const mem = process.memoryUsage();
16
+ const heapSpaces = node_v8_1.default.getHeapSpaceStatistics().map((space) => ({
17
+ spaceName: space.space_name,
18
+ spaceSizeBytes: space.space_size,
19
+ spaceUsedBytes: space.space_used_size,
20
+ spaceAvailableBytes: space.space_available_size,
21
+ physicalSpaceSizeBytes: space.physical_space_size,
22
+ }));
23
+ return {
24
+ pid: process.pid,
25
+ nodeEnv: process.env.NODE_ENV,
26
+ memory: {
27
+ heapUsedBytes: mem.heapUsed,
28
+ heapTotalBytes: mem.heapTotal,
29
+ rssBytes: mem.rss,
30
+ externalBytes: mem.external,
31
+ arrayBuffersBytes: mem.arrayBuffers,
32
+ heapUsed: toMB(mem.heapUsed),
33
+ heapTotal: toMB(mem.heapTotal),
34
+ rss: toMB(mem.rss),
35
+ external: toMB(mem.external),
36
+ arrayBuffers: toMB(mem.arrayBuffers),
37
+ },
38
+ cpuUsageMicros: process.cpuUsage(),
39
+ resourceUsage: process.resourceUsage(),
40
+ system: {
41
+ loadAverage: node_os_1.default.loadavg(),
42
+ freeMemoryBytes: node_os_1.default.freemem(),
43
+ totalMemoryBytes: node_os_1.default.totalmem(),
44
+ uptimeSeconds: node_os_1.default.uptime(),
45
+ },
46
+ v8: {
47
+ heapStatistics: node_v8_1.default.getHeapStatistics(),
48
+ heapSpaces,
49
+ },
50
+ graphileCache: (0, graphile_cache_1.getCacheStats)(),
51
+ svcCache: {
52
+ size: server_utils_1.svcCache.size,
53
+ max: server_utils_1.svcCache.max,
54
+ ttlMs: server_utils_1.SVC_CACHE_TTL_MS,
55
+ // Note: with updateAgeOnGet: true, this is "time since last access" not "time since creation"
56
+ oldestKeyAgeMs: (() => {
57
+ let minRemaining = Infinity;
58
+ for (const key of server_utils_1.svcCache.keys()) {
59
+ const remaining = server_utils_1.svcCache.getRemainingTTL(key);
60
+ if (remaining < minRemaining) {
61
+ minRemaining = remaining;
62
+ }
63
+ }
64
+ return Number.isFinite(minRemaining) ? server_utils_1.SVC_CACHE_TTL_MS - minRemaining : null;
65
+ })(),
66
+ keys: [...server_utils_1.svcCache.keys()].slice(0, 200),
67
+ },
68
+ inFlight: {
69
+ count: (0, graphile_1.getInFlightCount)(),
70
+ keys: (0, graphile_1.getInFlightKeys)(),
71
+ },
72
+ graphileBuilds: (0, graphile_build_stats_1.getGraphileBuildStats)(),
73
+ uptimeMinutes: process.uptime() / 60,
74
+ timestamp: new Date().toISOString(),
75
+ };
76
+ };
77
+ exports.getDebugMemorySnapshot = getDebugMemorySnapshot;
@@ -0,0 +1,5 @@
1
+ import type { ConstructiveOptions } from '@constructive-io/graphql-types';
2
+ export interface DebugSamplerHandle {
3
+ stop(): Promise<void>;
4
+ }
5
+ export declare const startDebugSampler: (opts: ConstructiveOptions) => DebugSamplerHandle | null;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startDebugSampler = void 0;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const logger_1 = require("@pgpmjs/logger");
10
+ const debug_db_snapshot_1 = require("./debug-db-snapshot");
11
+ const debug_memory_snapshot_1 = require("./debug-memory-snapshot");
12
+ const observability_1 = require("./observability");
13
+ const log = new logger_1.Logger('debug-sampler');
14
+ const MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GB
15
+ const getSamplerIntervalMs = () => {
16
+ const raw = process.env.GRAPHQL_DEBUG_SAMPLER_INTERVAL_MS;
17
+ const parsed = raw ? Number.parseInt(raw, 10) : 10_000;
18
+ return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : 10_000;
19
+ };
20
+ const getSamplerRootDir = () => {
21
+ if (process.env.GRAPHQL_DEBUG_SAMPLER_DIR) {
22
+ return node_path_1.default.resolve(process.env.GRAPHQL_DEBUG_SAMPLER_DIR);
23
+ }
24
+ return node_path_1.default.resolve(__dirname, '../..', 'logs');
25
+ };
26
+ const createSessionLogDir = () => {
27
+ const rootDir = getSamplerRootDir();
28
+ const sessionName = `run-${new Date().toISOString().replace(/[:.]/g, '-')}-pid${process.pid}`;
29
+ return process.env.GRAPHQL_DEBUG_SAMPLER_DIR
30
+ ? rootDir
31
+ : node_path_1.default.join(rootDir, sessionName);
32
+ };
33
+ const appendJsonLine = async (filePath, payload) => {
34
+ await promises_1.default.mkdir(node_path_1.default.dirname(filePath), { recursive: true });
35
+ await promises_1.default.appendFile(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
36
+ };
37
+ const getDirSize = async (dirPath) => {
38
+ let total = 0;
39
+ const entries = await promises_1.default.readdir(dirPath, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ const fullPath = node_path_1.default.join(dirPath, entry.name);
42
+ if (entry.isDirectory()) {
43
+ total += await getDirSize(fullPath);
44
+ }
45
+ else {
46
+ const stat = await promises_1.default.stat(fullPath);
47
+ total += stat.size;
48
+ }
49
+ }
50
+ return total;
51
+ };
52
+ const enforceMaxSize = async (rootDir, currentSessionDir) => {
53
+ try {
54
+ const totalSize = await getDirSize(rootDir);
55
+ if (totalSize <= MAX_TOTAL_BYTES) {
56
+ return;
57
+ }
58
+ const entries = await promises_1.default.readdir(rootDir, { withFileTypes: true });
59
+ const sessionDirs = await Promise.all(entries
60
+ .filter((e) => e.isDirectory())
61
+ .map(async (e) => {
62
+ const fullPath = node_path_1.default.join(rootDir, e.name);
63
+ const stat = await promises_1.default.stat(fullPath);
64
+ return { path: fullPath, mtimeMs: stat.mtimeMs };
65
+ }));
66
+ sessionDirs.sort((a, b) => a.mtimeMs - b.mtimeMs);
67
+ for (const dir of sessionDirs) {
68
+ if (dir.path === currentSessionDir) {
69
+ continue;
70
+ }
71
+ log.info(`Rolling cleanup: removing old session dir ${dir.path}`);
72
+ await promises_1.default.rm(dir.path, { recursive: true, force: true });
73
+ const newSize = await getDirSize(rootDir);
74
+ if (newSize <= MAX_TOTAL_BYTES) {
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ catch (error) {
80
+ log.error('Failed to enforce max log size', error);
81
+ }
82
+ };
83
+ const startDebugSampler = (opts) => {
84
+ if (!(0, observability_1.isGraphqlDebugSamplerEnabled)(opts.server?.host)) {
85
+ return null;
86
+ }
87
+ const intervalMs = getSamplerIntervalMs();
88
+ const logDir = createSessionLogDir();
89
+ const memoryLogPath = node_path_1.default.join(logDir, 'debug-memory.ndjson');
90
+ const dbLogPath = node_path_1.default.join(logDir, 'debug-db.ndjson');
91
+ const errorLogPath = node_path_1.default.join(logDir, 'debug-sampler-errors.ndjson');
92
+ let timer = null;
93
+ let stopped = false;
94
+ let inFlight = null;
95
+ let writeFailureLogged = false;
96
+ const runBackgroundWrite = (promise, scope) => {
97
+ promise.catch((error) => {
98
+ // Avoid recursive attempts to write additional error files when the
99
+ // underlying storage path is broken or unavailable.
100
+ if (!writeFailureLogged) {
101
+ writeFailureLogged = true;
102
+ log.error(`Debug sampler background write failed (${scope})`, error);
103
+ }
104
+ });
105
+ };
106
+ const recordError = async (scope, error) => {
107
+ const payload = {
108
+ scope,
109
+ timestamp: new Date().toISOString(),
110
+ pid: process.pid,
111
+ error: error instanceof Error ? {
112
+ name: error.name,
113
+ message: error.message,
114
+ stack: error.stack,
115
+ } : {
116
+ message: String(error),
117
+ },
118
+ };
119
+ await appendJsonLine(errorLogPath, payload);
120
+ };
121
+ const sampleOnce = async () => {
122
+ if (stopped) {
123
+ return;
124
+ }
125
+ try {
126
+ await appendJsonLine(memoryLogPath, (0, debug_memory_snapshot_1.getDebugMemorySnapshot)());
127
+ }
128
+ catch (error) {
129
+ log.error('Failed to capture debug memory snapshot', error);
130
+ await recordError('memory', error);
131
+ }
132
+ try {
133
+ await appendJsonLine(dbLogPath, await (0, debug_db_snapshot_1.getDebugDatabaseSnapshot)(opts));
134
+ }
135
+ catch (error) {
136
+ log.error('Failed to capture debug DB snapshot', error);
137
+ await recordError('db', error);
138
+ }
139
+ await enforceMaxSize(getSamplerRootDir(), logDir);
140
+ };
141
+ const tick = () => {
142
+ if (stopped) {
143
+ return;
144
+ }
145
+ if (inFlight) {
146
+ runBackgroundWrite(recordError('sampler', new Error('Skipped debug sample because previous sample is still running')), 'record-skip');
147
+ return;
148
+ }
149
+ inFlight = sampleOnce()
150
+ .catch(async (error) => {
151
+ log.error('Debug sampler tick failed', error);
152
+ await recordError('sampler', error);
153
+ })
154
+ .finally(() => {
155
+ inFlight = null;
156
+ });
157
+ };
158
+ const lifecyclePayload = {
159
+ event: 'sampler_started',
160
+ intervalMs,
161
+ logDir,
162
+ pid: process.pid,
163
+ timestamp: new Date().toISOString(),
164
+ };
165
+ runBackgroundWrite(appendJsonLine(memoryLogPath, lifecyclePayload), 'lifecycle-memory-start');
166
+ runBackgroundWrite(appendJsonLine(dbLogPath, lifecyclePayload), 'lifecycle-db-start');
167
+ log.info(`Debug sampler writing snapshots every ${intervalMs}ms to ${logDir}`);
168
+ tick();
169
+ timer = setInterval(tick, intervalMs);
170
+ timer.unref();
171
+ return {
172
+ async stop() {
173
+ stopped = true;
174
+ if (timer) {
175
+ clearInterval(timer);
176
+ timer = null;
177
+ }
178
+ if (inFlight) {
179
+ await inFlight;
180
+ }
181
+ const payload = {
182
+ event: 'sampler_stopped',
183
+ intervalMs,
184
+ logDir,
185
+ pid: process.pid,
186
+ timestamp: new Date().toISOString(),
187
+ };
188
+ await Promise.allSettled([
189
+ appendJsonLine(memoryLogPath, payload),
190
+ appendJsonLine(dbLogPath, payload),
191
+ ]);
192
+ },
193
+ };
194
+ };
195
+ exports.startDebugSampler = startDebugSampler;
@@ -0,0 +1,6 @@
1
+ export declare const isDevelopmentObservabilityMode: () => boolean;
2
+ export declare const isLoopbackHost: (value: string | null | undefined) => boolean;
3
+ export declare const isLoopbackAddress: (value: string | null | undefined) => boolean;
4
+ export declare const isGraphqlObservabilityRequested: () => boolean;
5
+ export declare const isGraphqlObservabilityEnabled: (serverHost?: string | null) => boolean;
6
+ export declare const isGraphqlDebugSamplerEnabled: (serverHost?: string | null) => boolean;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isGraphqlDebugSamplerEnabled = exports.isGraphqlObservabilityEnabled = exports.isGraphqlObservabilityRequested = exports.isLoopbackAddress = exports.isLoopbackHost = exports.isDevelopmentObservabilityMode = void 0;
4
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
5
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1']);
6
+ const parseBooleanEnv = (value, fallback) => {
7
+ if (value == null) {
8
+ return fallback;
9
+ }
10
+ const normalized = value.trim().toLowerCase();
11
+ if (['1', 'true', 'yes', 'on'].includes(normalized))
12
+ return true;
13
+ if (['0', 'false', 'no', 'off'].includes(normalized))
14
+ return false;
15
+ return fallback;
16
+ };
17
+ const normalizeHost = (value) => {
18
+ if (!value) {
19
+ return null;
20
+ }
21
+ const trimmed = value.trim().toLowerCase();
22
+ if (!trimmed) {
23
+ return null;
24
+ }
25
+ if (trimmed.startsWith('[')) {
26
+ const closingIndex = trimmed.indexOf(']');
27
+ return closingIndex === -1 ? trimmed : trimmed.slice(0, closingIndex + 1);
28
+ }
29
+ const colonCount = trimmed.split(':').length - 1;
30
+ if (colonCount === 1) {
31
+ return trimmed.split(':')[0] || null;
32
+ }
33
+ return trimmed;
34
+ };
35
+ const normalizeAddress = (value) => {
36
+ const normalized = normalizeHost(value);
37
+ if (!normalized) {
38
+ return null;
39
+ }
40
+ return normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized;
41
+ };
42
+ const isDevelopmentObservabilityMode = () => process.env.NODE_ENV === 'development';
43
+ exports.isDevelopmentObservabilityMode = isDevelopmentObservabilityMode;
44
+ const isLoopbackHost = (value) => {
45
+ const normalized = normalizeHost(value);
46
+ return normalized != null && LOOPBACK_HOSTS.has(normalized);
47
+ };
48
+ exports.isLoopbackHost = isLoopbackHost;
49
+ const isLoopbackAddress = (value) => {
50
+ const normalized = normalizeAddress(value);
51
+ return normalized != null && LOOPBACK_ADDRESSES.has(normalized);
52
+ };
53
+ exports.isLoopbackAddress = isLoopbackAddress;
54
+ const isGraphqlObservabilityRequested = () => parseBooleanEnv(process.env.GRAPHQL_OBSERVABILITY_ENABLED, false);
55
+ exports.isGraphqlObservabilityRequested = isGraphqlObservabilityRequested;
56
+ const isGraphqlObservabilityEnabled = (serverHost) => (0, exports.isDevelopmentObservabilityMode)() &&
57
+ (0, exports.isGraphqlObservabilityRequested)() &&
58
+ (0, exports.isLoopbackHost)(serverHost);
59
+ exports.isGraphqlObservabilityEnabled = isGraphqlObservabilityEnabled;
60
+ const isGraphqlDebugSamplerEnabled = (serverHost) => (0, exports.isGraphqlObservabilityEnabled)(serverHost) &&
61
+ parseBooleanEnv(process.env.GRAPHQL_DEBUG_SAMPLER_ENABLED, true);
62
+ exports.isGraphqlDebugSamplerEnabled = isGraphqlDebugSamplerEnabled;