@jacebenson/jsn 4.3.7-dev.15 → 4.3.7-dev.16

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 CHANGED
@@ -121,6 +121,8 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
121
121
 
122
122
  **Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `diff record` (cross-profile read-only comparison), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
123
123
 
124
+ **Platform** — `platform health` (cluster records) and `platform stats` (bounded, read-only `/xmlstats.do` summary; allowlisted metrics only)
125
+
124
126
  Every command supports `--json`, `--query`, and `--help`.
125
127
 
126
128
  ## Instances
@@ -175,6 +177,8 @@ For the credential identity, auth-method, diagnostics, migration, and concurrenc
175
177
 
176
178
  GraphQL uses ServiceNow's `/api/now/graphql` endpoint and sends `{query, variables}` through the authenticated SDK transport. The command preserves GraphQL `data` and `errors`; introspection and available namespaces depend on the target instance.
177
179
 
180
+ `jsn platform stats` uses a fixed `GET /xmlstats.do` request. It never accepts a processor path, query parameters, or request body, and it does not emit raw XML, cookies, credentials, or full node identifiers. Oversized, HTML/login, permission, unsupported, timeout, and malformed responses are reported with bounded collector statuses; missing metrics remain unavailable rather than being reported as zero.
181
+
178
182
  ## Local data
179
183
 
180
184
  JSN stores durable local data under `~/.jsn/`. Documentation lives in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.15",
3
+ "version": "4.3.7-dev.16",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,5 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+
1
3
  function numberOrString(value) {
2
4
  const n = Number(value);
3
5
  return value !== '' && Number.isFinite(n) ? n : value;
@@ -24,6 +26,179 @@ function statsForWindow(xml, parent, window) {
24
26
  return tagAttributes(parentMatch[1], window);
25
27
  }
26
28
 
29
+ export const XMLSTATS_MAX_BYTES = 4 * 1024 * 1024;
30
+
31
+ function metric(value) {
32
+ if (value === undefined || value === null || String(value).trim() === '') {
33
+ return { value: null, availability: 'unavailable' };
34
+ }
35
+ return { value: numberOrString(String(value).trim()), availability: 'available' };
36
+ }
37
+
38
+ function firstNestedText(xml, parent, child) {
39
+ const parentMatch = xml.match(new RegExp(`<${parent}\\b[^>]*>([\\s\\S]*?)</${parent}>`));
40
+ return parentMatch ? tagText(parentMatch[1], child) : undefined;
41
+ }
42
+
43
+ function allowlistedStats(xml) {
44
+ const unavailable = [];
45
+ const add = (path, value) => {
46
+ const result = metric(value);
47
+ if (result.availability === 'unavailable') unavailable.push(path);
48
+ return result;
49
+ };
50
+
51
+ const cluster = xml.match(/<cluster\.status\b[^>]*>([\s\S]*?)<\/cluster\.status>/);
52
+ const nodeXml = cluster?.[1]?.match(/<node\b[^>]*>([\s\S]*?)<\/node>/)?.[1] || '';
53
+ const daily = statsForWindow(xml, 'all_transactions', 'daily') || {};
54
+ const semaphores = [];
55
+ for (const match of xml.matchAll(/<semaphores([^>]*)\/>/g)) {
56
+ const attrs = {};
57
+ for (const [, key, value] of match[1].matchAll(/([\w.-]+)="([^"]*)"/g)) attrs[key] = value;
58
+ if (!attrs.name && !attrs.available && !attrs.borrowed && !attrs.maximum_concurrency && !attrs.queue_depth) continue;
59
+ semaphores.push({
60
+ name: attrs.name || 'unnamed',
61
+ available: add(`semaphores[${attrs.name || 'unnamed'}].available`, attrs.available),
62
+ borrowed: add(`semaphores[${attrs.name || 'unnamed'}].borrowed`, attrs.borrowed),
63
+ maximum: add(`semaphores[${attrs.name || 'unnamed'}].maximum`, attrs.maximum_concurrency),
64
+ queue_depth: add(`semaphores[${attrs.name || 'unnamed'}].queue_depth`, attrs.queue_depth),
65
+ });
66
+ }
67
+ if (semaphores.length === 0) unavailable.push('semaphores');
68
+
69
+ return {
70
+ node: {
71
+ status: add('node.status', tagText(nodeXml, 'status')),
72
+ type: add('node.type', firstNestedText(nodeXml, 'node_type', 'name')),
73
+ },
74
+ queue: {
75
+ length: add('queue.length', tagText(xml, 'queue.length')),
76
+ age: add('queue.age', tagText(xml, 'queue.age')),
77
+ },
78
+ active_sessions: add('active_sessions', tagText(xml, 'servlet.active.sessions')),
79
+ memory_pressure: add('memory_pressure', tagText(xml, 'memory_pressure')),
80
+ semaphores,
81
+ transactions: {
82
+ daily: {
83
+ count: add('transactions.daily.count', daily.count),
84
+ mean: add('transactions.daily.mean', daily.mean),
85
+ median: add('transactions.daily.median', daily.median),
86
+ ninetypercent: add('transactions.daily.ninetypercent', daily.ninetypercent),
87
+ max: add('transactions.daily.max', daily.max),
88
+ },
89
+ },
90
+ unavailable,
91
+ };
92
+ }
93
+
94
+ export function parsePlatformStatsXml(xml) {
95
+ const source = String(xml || '');
96
+ if (Buffer.byteLength(source) > XMLSTATS_MAX_BYTES) throw new Error('xmlstats response exceeds the bounded size limit');
97
+ if (!/^\s*(?:<\?xml[\s\S]*?\?>\s*)?<xmlstats\b[^>]*>/i.test(source)
98
+ || !/<\/xmlstats>\s*$/i.test(source)) {
99
+ throw new Error('Malformed XML: expected an xmlstats document');
100
+ }
101
+ const stack = [];
102
+ for (const match of source.matchAll(/<\/?([A-Za-z][\w.:-]*)(?:\s[^>]*)?>/g)) {
103
+ const token = match[0];
104
+ const name = match[1];
105
+ if (/^<\//.test(token)) {
106
+ if (stack.pop() !== name) throw new Error('Malformed XML: mismatched tags');
107
+ } else if (!/\/\s*>$/.test(token)) {
108
+ stack.push(name);
109
+ }
110
+ }
111
+ if (stack.length !== 0) throw new Error('Malformed XML: unclosed tags');
112
+ return allowlistedStats(source);
113
+ }
114
+
115
+ async function readBoundedResponse(response, maxBytes = XMLSTATS_MAX_BYTES) {
116
+ if (!response.body?.getReader) {
117
+ const text = await response.text();
118
+ return Buffer.byteLength(text) > maxBytes ? { tooLarge: true, bytes: Buffer.byteLength(text) } : { text, bytes: Buffer.byteLength(text) };
119
+ }
120
+ const reader = response.body.getReader();
121
+ const chunks = [];
122
+ let bytes = 0;
123
+ while (true) {
124
+ const { done, value } = await reader.read();
125
+ if (done) break;
126
+ bytes += value.byteLength;
127
+ if (bytes > maxBytes) {
128
+ await reader.cancel();
129
+ return { tooLarge: true, bytes };
130
+ }
131
+ chunks.push(value);
132
+ }
133
+ const body = new Uint8Array(bytes);
134
+ let offset = 0;
135
+ for (const chunk of chunks) {
136
+ body.set(chunk, offset);
137
+ offset += chunk.byteLength;
138
+ }
139
+ return { text: new TextDecoder().decode(body), bytes };
140
+ }
141
+
142
+ function looksLikeLoginHtml(response, body) {
143
+ const contentType = response.headers?.get?.('content-type') || '';
144
+ return /text\/html/i.test(contentType) || /^\s*<(?:!doctype\s+html|html\b)/i.test(body);
145
+ }
146
+
147
+ export async function collectPlatformStats(sdk, instance) {
148
+ let response;
149
+ try {
150
+ response = await sdk.fetchResponse(`${instance}/xmlstats.do`, {
151
+ method: 'GET',
152
+ headers: { Accept: 'application/xml,text/xml' },
153
+ });
154
+ } catch (error) {
155
+ const message = String(error?.message || '').toLowerCase();
156
+ return { status: /timeout|timed out/.test(message) ? 'timeout' : 'unavailable', reason: /timeout|timed out/.test(message) ? 'request timed out' : 'request failed' };
157
+ }
158
+
159
+ if (response.status === 401 || response.status === 403) return { status: 'permission_denied', reason: `HTTP ${response.status}` };
160
+ if (response.status === 404 || response.status === 405) return { status: 'unsupported', reason: `HTTP ${response.status}` };
161
+ if (!response.ok) return { status: 'unavailable', reason: `HTTP ${response.status}` };
162
+
163
+ const body = await readBoundedResponse(response);
164
+ if (body.tooLarge) return { status: 'too_large', reason: `response exceeded ${XMLSTATS_MAX_BYTES} bytes`, bytes: body.bytes };
165
+ if (looksLikeLoginHtml(response, body.text)) return { status: 'login_redirect', reason: 'received HTML instead of xmlstats XML', bytes: body.bytes };
166
+
167
+ try {
168
+ const metrics = parsePlatformStatsXml(body.text);
169
+ const status = metrics.unavailable.length > 0 ? 'partial' : 'success';
170
+ return {
171
+ status,
172
+ reason: status === 'partial' ? 'some allowlisted metrics were unavailable' : null,
173
+ bytes: body.bytes,
174
+ metrics,
175
+ };
176
+ } catch {
177
+ return { status: 'malformed_xml', reason: 'response was not a valid xmlstats document', bytes: body.bytes };
178
+ }
179
+ }
180
+
181
+ function metricText(item) {
182
+ return item?.availability === 'available' ? String(item.value) : 'unavailable';
183
+ }
184
+
185
+ export function formatPlatformStats(result) {
186
+ const lines = [`STATUS: ${result.status}`];
187
+ if (result.reason) lines.push(`REASON: ${result.reason}`);
188
+ const m = result.metrics;
189
+ if (!m) return `${lines.join('\\n')}\\n`;
190
+ lines.push(`NODE: ${metricText(m.node.status)} / ${metricText(m.node.type)}`);
191
+ lines.push(`QUEUE: ${metricText(m.queue.length)} length, ${metricText(m.queue.age)} age`);
192
+ lines.push(`SESSIONS: ${metricText(m.active_sessions)}`);
193
+ lines.push(`MEMORY PRESSURE: ${metricText(m.memory_pressure)}`);
194
+ lines.push(`TRANSACTIONS DAILY: ${metricText(m.transactions.daily.count)} count, ${metricText(m.transactions.daily.mean)} mean, ${metricText(m.transactions.daily.ninetypercent)} p90, ${metricText(m.transactions.daily.max)} max`);
195
+ if (m.semaphores.length === 0) lines.push('SEMAPHORES: unavailable');
196
+ else for (const semaphore of m.semaphores) {
197
+ lines.push(`SEMAPHORE ${semaphore.name}: ${metricText(semaphore.available)} available, ${metricText(semaphore.borrowed)} borrowed, ${metricText(semaphore.maximum)} maximum, ${metricText(semaphore.queue_depth)} queue`);
198
+ }
199
+ return `${lines.join('\\n')}\\n`;
200
+ }
201
+
27
202
  export function parseNodeStatsXml(xml) {
28
203
  const source = String(xml || '');
29
204
  const result = {};
@@ -91,6 +266,7 @@ function formatHealth(nodes) {
91
266
  }
92
267
 
93
268
  export function platformCmd(wrap) {
269
+ declareCapabilities('platform', { mutationSubcommands: [] });
94
270
  return {
95
271
  command: 'platform',
96
272
  describe: 'Inspect read-only platform health and node statistics',
@@ -122,6 +298,15 @@ export function platformCmd(wrap) {
122
298
  }
123
299
  app.ok({ table: 'sys_cluster_state', node_count: nodes.length, nodes, _formatted: formatHealth(nodes), security: { raw_xml: 'excluded', sensitive_fields: 'excluded' }, context: { instance_url: app.getEffectiveInstance() } }, { summary: `Platform health: ${nodes.length} node(s)` });
124
300
  }),
301
+ }).command({
302
+ command: 'stats',
303
+ describe: 'Show bounded, read-only platform statistics from xmlstats.do',
304
+ handler: wrap(async (argv, app) => {
305
+ app.requireInstance();
306
+ const result = await collectPlatformStats(app.sdk, app.getEffectiveInstance());
307
+ result._formatted = formatPlatformStats(result);
308
+ app.ok(result, { summary: `Platform stats: ${result.status}` });
309
+ }),
125
310
  }),
126
311
  };
127
312
  }