fhirsmith 0.18.2 → 1.0.0

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 (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The tx-workers' request counts, for the master's statistics (cluster.md, "Statistics").
3
+ *
4
+ * A tx-worker has no statistics database and sends no daily report (library/role.js). Under
5
+ * the launcher (cluster.js), each worker sends the requests it counted since the last time
6
+ * (module, endpoint, operation, count, time) over its IPC channel, every minute and when it
7
+ * stops. The launcher sums what it has not yet delivered and passes it on to the master,
8
+ * which adds it to its own counters (ServerStats.mergeCounts). The master's statistics
9
+ * database, and the daily report built from it, then count every process, each request once:
10
+ * a worker's counts are taken when sent, and only the master writes them.
11
+ *
12
+ * Without the launcher (processes started by hand behind nginx) there is no IPC channel, and
13
+ * a worker's requests are not in the master's statistics.
14
+ */
15
+ const COUNTS = 'fhirsmith:counts';
16
+ // how often a worker sends its request counts
17
+ const COUNTS_INTERVAL_MS = 60 * 1000;
18
+ // how long a stopping worker waits for its last counts to be sent
19
+ const COUNTS_FLUSH_MS = 2000;
20
+
21
+ /**
22
+ * Attach a process with a role to the launcher's IPC channel. A process started without the
23
+ * launcher has none, and this does nothing.
24
+ * @param {Object} stats - the ServerStats
25
+ * @param {Object} [channel] - process, or a stand-in (tests)
26
+ * @param {Object} [options]
27
+ * @param {string} [options.role] - 'master' takes the workers' counts; 'tx-worker' sends its own
28
+ * @param {number} [options.intervalMs]
29
+ * @returns {Function} disconnect; its flush() sends a worker's last counts (on shutdown)
30
+ */
31
+ function connectCounts(stats, channel = process, { role = null, intervalMs = COUNTS_INTERVAL_MS } = {}) {
32
+ if (typeof channel.send !== 'function') {
33
+ const none = () => {};
34
+ none.flush = async () => {};
35
+ return none;
36
+ }
37
+ // A worker's counts are taken only when sent, one lot at a time; a lot that can't be sent
38
+ // is put back, to go with the next
39
+ let inFlight = null;
40
+ const send = () => {
41
+ if (inFlight) return inFlight;
42
+ if (role !== 'tx-worker' || channel.connected === false) return Promise.resolve();
43
+ const rows = stats.takeCounts();
44
+ if (rows.length === 0) return Promise.resolve();
45
+ inFlight = new Promise(resolve => {
46
+ const done = error => {
47
+ if (error) stats.restoreCounts(rows);
48
+ inFlight = null;
49
+ resolve();
50
+ };
51
+ try {
52
+ channel.send({ type: COUNTS, rows }, done);
53
+ } catch (error) {
54
+ done(error);
55
+ }
56
+ });
57
+ return inFlight;
58
+ };
59
+ const timer = role === 'tx-worker' ? setInterval(send, intervalMs) : null;
60
+ if (timer && typeof timer.unref === 'function') timer.unref();
61
+
62
+ const receive = message => {
63
+ if (role === 'master' && message && message.type === COUNTS) {
64
+ stats.mergeCounts(message.rows);
65
+ }
66
+ };
67
+ channel.on('message', receive);
68
+
69
+ const disconnect = () => {
70
+ if (timer) clearInterval(timer);
71
+ channel.removeListener('message', receive);
72
+ };
73
+ disconnect.flush = async () => {
74
+ let wait;
75
+ const timeout = new Promise(resolve => { wait = setTimeout(resolve, COUNTS_FLUSH_MS); });
76
+ try {
77
+ // the lot in flight, then whatever was counted since
78
+ await Promise.race([(async () => { await inFlight; await send(); })(), timeout]);
79
+ } finally {
80
+ clearTimeout(wait);
81
+ }
82
+ };
83
+ return disconnect;
84
+ }
85
+
86
+ /**
87
+ * The launcher's side: what the workers sent and the master hasn't had yet, summed by
88
+ * (module, endpoint, operation).
89
+ */
90
+ class PendingCounts {
91
+ constructor() {
92
+ this.rows = new Map();
93
+ }
94
+
95
+ add(rows) {
96
+ for (const row of Array.isArray(rows) ? rows : []) {
97
+ if (!row || !Number.isInteger(row.count) || row.count <= 0 || !Number.isFinite(row.time)) continue;
98
+ const key = `${row.module}\u001f${row.endpoint}\u001f${row.operation}`;
99
+ const sum = this.rows.get(key);
100
+ if (sum) {
101
+ sum.count += row.count;
102
+ sum.time += row.time;
103
+ } else {
104
+ this.rows.set(key, { module: row.module, endpoint: row.endpoint, operation: row.operation, count: row.count, time: row.time });
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Everything pending, now no longer pending (add it back if it can't be delivered). */
110
+ take() {
111
+ const rows = [...this.rows.values()];
112
+ this.rows.clear();
113
+ return rows;
114
+ }
115
+
116
+ get size() {
117
+ return this.rows.size;
118
+ }
119
+ }
120
+
121
+ module.exports = { connectCounts, PendingCounts, COUNTS, COUNTS_INTERVAL_MS };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * What the launcher (cluster.js) saw, for the master's daily report (cluster.md, "The daily
3
+ * report").
4
+ *
5
+ * Only the launcher knows when a process exited, was recycled or came back, and what it
6
+ * refused or abandoned itself. It keeps those as rows: an event (a process exited, was
7
+ * recycled, was ready again after a time unavailable; the launcher started) is a row of its
8
+ * own; what the launcher answered itself (503, 504, 502, a cache-id lost to a restart, a
9
+ * client that took none of a response) is counted, one row per kind and process per lot. It
10
+ * passes them to the master over its IPC channel, once the master is ready, as it passes the
11
+ * workers' request counts, and the master writes them to its statistics database
12
+ * (ServerStats.recordClusterEvents), where the daily report reads them.
13
+ *
14
+ * A row is {time, kind, process, detail, count, ms}:
15
+ * - time: when it happened (a counted row: the last time in the lot)
16
+ * - kind: one of KINDS
17
+ * - process: the process's name ('master', 'worker b'), or null for the launcher itself
18
+ * - detail: the exit code or signal, or why a process was recycled; otherwise null
19
+ * - count: 1 for an event; how many for a counted row
20
+ * - ms: for 'ready', how long the process had been unavailable; otherwise 0
21
+ */
22
+ const CLUSTER_EVENTS = 'fhirsmith:cluster-events';
23
+
24
+ const KINDS = {
25
+ // events
26
+ launcherStarted: 'launcher-started',
27
+ exited: 'exited',
28
+ recycled: 'recycled',
29
+ ready: 'ready',
30
+ // counted: what the launcher answered itself
31
+ busy: 'refused-busy',
32
+ stopping: 'refused-stopping',
33
+ notReady: 'refused-not-ready',
34
+ lostToRestart: 'lost-to-restart',
35
+ timeout: 'upstream-timeout',
36
+ failed: 'upstream-failed',
37
+ writeTimeout: 'write-timeout'
38
+ };
39
+ const KNOWN = new Set(Object.values(KINDS));
40
+ // kept one by one; the rest are counted
41
+ const EVENT_KINDS = new Set([KINDS.launcherStarted, KINDS.exited, KINDS.recycled, KINDS.ready]);
42
+ // events kept while the master can't take them (it is down, or not ready yet); the oldest go
43
+ // first. Counted rows are one per kind and process, so they don't grow
44
+ const MAX_PENDING = 5000;
45
+ const MAX_DETAIL = 500;
46
+
47
+ /**
48
+ * A row as the master takes it, or null if it isn't one.
49
+ */
50
+ function validRow(row) {
51
+ if (!row || !KNOWN.has(row.kind) || !Number.isFinite(row.time) || !Number.isInteger(row.count) || row.count <= 0) {
52
+ return null;
53
+ }
54
+ const text = (v) => (typeof v === 'string' && v ? v.slice(0, MAX_DETAIL) : null);
55
+ return {
56
+ time: Math.round(row.time),
57
+ kind: row.kind,
58
+ process: text(row.process),
59
+ detail: text(row.detail),
60
+ count: row.count,
61
+ ms: Number.isFinite(row.ms) && row.ms > 0 ? Math.round(row.ms) : 0
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The launcher's side: what hasn't reached the master yet. Shaped like PendingCounts
67
+ * (library/cluster-counts.js), so the launcher delivers both the same way.
68
+ */
69
+ class PendingClusterEvents {
70
+ constructor(now = Date.now) {
71
+ this.now = now;
72
+ this.events = [];
73
+ this.counted = new Map();
74
+ }
75
+
76
+ /** Something that happened once: a process exited, was recycled, was ready again. */
77
+ event(kind, process = null, { detail = null, ms = 0 } = {}) {
78
+ this.events.push({ time: this.now(), kind, process, detail: detail == null ? null : String(detail), count: 1, ms });
79
+ if (this.events.length > MAX_PENDING) {
80
+ this.events.splice(0, this.events.length - MAX_PENDING);
81
+ }
82
+ }
83
+
84
+ /** Something the launcher answered itself: counted, not kept one by one. */
85
+ count(kind, process = null) {
86
+ const key = `${kind}\u001f${process || ''}`;
87
+ let row = this.counted.get(key);
88
+ if (!row) {
89
+ row = { time: 0, kind, process, detail: null, count: 0, ms: 0 };
90
+ this.counted.set(key, row);
91
+ }
92
+ row.count++;
93
+ row.time = this.now();
94
+ }
95
+
96
+ /** Put back a lot that couldn't be delivered. */
97
+ add(rows) {
98
+ for (const row of Array.isArray(rows) ? rows : []) {
99
+ if (!EVENT_KINDS.has(row.kind)) {
100
+ const key = `${row.kind}\u001f${row.process || ''}`;
101
+ const sum = this.counted.get(key);
102
+ if (sum) {
103
+ sum.count += row.count;
104
+ sum.time = Math.max(sum.time, row.time);
105
+ } else {
106
+ this.counted.set(key, { ...row });
107
+ }
108
+ } else {
109
+ this.events.push(row);
110
+ }
111
+ }
112
+ this.events.sort((a, b) => a.time - b.time);
113
+ if (this.events.length > MAX_PENDING) {
114
+ this.events.splice(0, this.events.length - MAX_PENDING);
115
+ }
116
+ }
117
+
118
+ /** Everything pending, now no longer pending (add it back if it can't be delivered). */
119
+ take() {
120
+ const rows = [...this.events, ...this.counted.values()];
121
+ this.events = [];
122
+ this.counted.clear();
123
+ return rows;
124
+ }
125
+
126
+ get size() {
127
+ return this.events.length + this.counted.size;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The master's side: take what the launcher sends, into the statistics database. A process
133
+ * without the launcher, or that isn't the master, does nothing.
134
+ * @param {Object} stats - the ServerStats
135
+ * @param {Object} [channel] - process, or a stand-in (tests)
136
+ * @param {Object} [options]
137
+ * @param {string} [options.role]
138
+ * @returns {Function} disconnect
139
+ */
140
+ function connectClusterEvents(stats, channel = process, { role = null } = {}) {
141
+ if (role !== 'master' || typeof channel.send !== 'function') {
142
+ return () => {};
143
+ }
144
+ const receive = message => {
145
+ if (message && message.type === CLUSTER_EVENTS) {
146
+ stats.recordClusterEvents(message.rows);
147
+ }
148
+ };
149
+ channel.on('message', receive);
150
+ return () => channel.removeListener('message', receive);
151
+ }
152
+
153
+ module.exports = { CLUSTER_EVENTS, KINDS, EVENT_KINDS, PendingClusterEvents, connectClusterEvents, validRow };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The launcher's small, in-memory view of each server's existing graph samples.
3
+ * No request counters are collected by the proxy and no averages are combined.
4
+ * Histories live until the launcher stops, so a child restart leaves a visible
5
+ * gap instead of erasing the worker's earlier samples. The one figure the launcher
6
+ * adds is the memory graph's Total: the cgroup's private memory (or, without a cgroup
7
+ * of the service's own, the processes' summed), read when the master's sample arrives.
8
+ */
9
+ const METRIC = 'fhirsmith:metric';
10
+ const SNAPSHOT = 'fhirsmith:metrics';
11
+ const HISTORY_MS = 24 * 60 * 60 * 1000;
12
+ const MAX_POINTS = 1440;
13
+ const FIELDS = ['mem', 'anon', 'rpm', 'tat', 'block', 'blockMax', 'clientConcepts', 'refused', 'intervalStart', 'ext', 'extTat'];
14
+
15
+ class ClusterMetrics {
16
+ /**
17
+ * @param {Object[]} procs - code, role, servesTx
18
+ * @param {Object} [options] - rejectAt (the memory guard's refusal threshold, bytes), group
19
+ * (whether the guard measures the cgroup, which the Total then is), sharedCache
20
+ */
21
+ constructor(procs, { rejectAt = null, group = false, sharedCache = false } = {}) {
22
+ this.rejectAt = rejectAt;
23
+ this.group = group;
24
+ this.sharedCache = sharedCache;
25
+ this.total = [];
26
+ this.series = new Map(procs.map(p => [p.code, {
27
+ id: p.code, label: `${p.role === 'master' ? 'Master' : 'Worker'} ${p.code}`,
28
+ role: p.role, servesTx: p.servesTx, status: 'starting', restarts: 0,
29
+ startedAt: null, intervalMs: null, history: []
30
+ }]));
31
+ }
32
+
33
+ /** The memory graph's Total at a time (null: not known, a gap). */
34
+ recordTotal(time, anon, now = Date.now()) {
35
+ const last = this.total[this.total.length - 1];
36
+ if (!Number.isFinite(time) || (last && time <= last.time)) {
37
+ return;
38
+ }
39
+ this.total.push({ time, anon: Number.isFinite(anon) ? anon : null });
40
+ this.prune(this, now, 'total');
41
+ }
42
+
43
+ prune(series, now, field = 'history') {
44
+ const cutoff = now - HISTORY_MS;
45
+ series[field] = series[field].filter(point => point.time > cutoff).slice(-MAX_POINTS);
46
+ }
47
+
48
+ state(code, status, now = Date.now()) {
49
+ const series = this.series.get(code);
50
+ if (!series) return;
51
+ if (status === 'restarting' && series.status !== 'restarting') {
52
+ series.restarts++;
53
+ if (series.history.length) {
54
+ // Chart.js must not connect the last sample to the replacement process.
55
+ series.history.push({ time: now, ...Object.fromEntries(FIELDS.map(field => [field, null])) });
56
+ }
57
+ }
58
+ series.status = status;
59
+ this.prune(series, now);
60
+ }
61
+
62
+ record(code, message, now = Date.now()) {
63
+ const series = this.series.get(code);
64
+ const point = message && message.point;
65
+ if (!series || !point || !Number.isFinite(point.time) ||
66
+ !Number.isFinite(message.startedAt) || !Number.isFinite(message.intervalMs) || message.intervalMs <= 0) {
67
+ return false;
68
+ }
69
+ // Reject malformed or out-of-order samples; a delayed old child is also
70
+ // rejected by the launcher before it reaches this store.
71
+ const last = series.history[series.history.length - 1];
72
+ if (point.time < now - HISTORY_MS || point.time > now + 60000 || (last && point.time <= last.time)) {
73
+ return false;
74
+ }
75
+ const clean = { time: point.time };
76
+ for (const field of FIELDS) {
77
+ clean[field] = Number.isFinite(point[field]) ? point[field] : null;
78
+ }
79
+ series.startedAt = message.startedAt;
80
+ series.intervalMs = message.intervalMs;
81
+ if (last && point.time - last.time > 1.5 * message.intervalMs && FIELDS.some(field => last[field] !== null)) {
82
+ series.history.push({ time: last.time + message.intervalMs, ...Object.fromEntries(FIELDS.map(field => [field, null])) });
83
+ }
84
+ series.history.push(clean);
85
+ this.prune(series, now);
86
+ return true;
87
+ }
88
+
89
+ snapshot(now = Date.now()) {
90
+ this.prune(this, now, 'total');
91
+ return {
92
+ cluster: true, sampledAt: now, sharedCache: this.sharedCache,
93
+ total: { group: this.group, rejectAt: this.rejectAt, history: this.total.map(point => ({ ...point })) },
94
+ series: [...this.series.values()].map(series => {
95
+ this.prune(series, now);
96
+ const last = series.history[series.history.length - 1];
97
+ const stale = series.status === 'ready' && (!last || now - last.time > Math.max(60000, 2 * series.intervalMs));
98
+ return { ...series, status: stale ? 'no recent sample' : series.status,
99
+ history: series.history.map(point => ({ ...point })) };
100
+ })
101
+ };
102
+ }
103
+ }
104
+
105
+ /** Attach only to a launcher-created IPC channel; standalone servers stay local. */
106
+ function connectMetrics(stats, channel = process) {
107
+ if (typeof channel.send !== 'function') return () => {};
108
+ let sending = false;
109
+ const publish = point => {
110
+ // Statistics must never block request work or build an IPC backlog.
111
+ if (channel.connected === false || sending) return;
112
+ sending = true;
113
+ try {
114
+ channel.send({ type: METRIC, point, startedAt: stats.startTime, intervalMs: stats.intervalMs }, () => {
115
+ sending = false;
116
+ });
117
+ } catch {
118
+ sending = false;
119
+ }
120
+ };
121
+ const receive = message => {
122
+ if (message && message.type === SNAPSHOT && Array.isArray(message.series)) {
123
+ stats.clusterMetrics = { cluster: true, sampledAt: message.sampledAt, sharedCache: message.sharedCache === true,
124
+ total: message.total || null, series: message.series };
125
+ stats.clusterMetricsUnavailable = false;
126
+ }
127
+ };
128
+ const disconnected = () => { stats.clusterMetricsUnavailable = true; };
129
+ stats.onMetric = publish;
130
+ channel.on('message', receive);
131
+ channel.on('disconnect', disconnected);
132
+ return () => {
133
+ if (stats.onMetric === publish) stats.onMetric = null;
134
+ channel.removeListener('message', receive);
135
+ channel.removeListener('disconnect', disconnected);
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Serialize only graph data, safely inside an inline script in either page. Without the
141
+ * launcher's snapshot (no role, a worker's own page, a master before its first), this process's
142
+ * own samples as one series, with its memory guard's refusal threshold (guard: rejectAt, group:
143
+ * whether the guard measures the cgroup) and no Total.
144
+ */
145
+ function graphData(stats, label = 'Server', now = Date.now(), guard = {}) {
146
+ return JSON.stringify(graphSeries(stats, label, now, guard)).replace(/</g, '\\u003c');
147
+ }
148
+
149
+ /**
150
+ * The graphs' data (as graphData, not serialized): the launcher's snapshot, or this process's
151
+ * own samples as one series
152
+ */
153
+ function graphSeries(stats, label = 'Server', now = Date.now(), guard = {}) {
154
+ const data = stats.clusterMetrics || {
155
+ cluster: false, sampledAt: now,
156
+ total: Number.isFinite(guard.rejectAt) && guard.rejectAt > 0
157
+ ? { group: Boolean(guard.group), rejectAt: guard.rejectAt, history: [], local: true } : null,
158
+ series: [{ id: 'local', label, status: 'ready', restarts: 0, startedAt: stats.startTime,
159
+ intervalMs: stats.intervalMs, history: stats.history }]
160
+ };
161
+ const series = data.series.map(item => {
162
+ const last = item.history[item.history.length - 1];
163
+ const stale = item.status === 'ready' && (!last || now - last.time > Math.max(60000, 2 * item.intervalMs));
164
+ return { ...item, status: data.cluster && stats.clusterMetricsUnavailable ? 'unavailable' : stale ? 'no recent sample' : item.status };
165
+ });
166
+ return { ...data, series };
167
+ }
168
+
169
+ /**
170
+ * The external requests (those that had to ask another server: ServerStats.countExternalRequest)
171
+ * in the graphs' samples - every process's, on the master under the launcher: {count, average
172
+ * (ms), hours (how far back the samples go)}, or null if there were none
173
+ */
174
+ function externalSummary(stats, now = Date.now()) {
175
+ let count = 0;
176
+ let time = 0;
177
+ let earliest = now;
178
+ for (const series of graphSeries(stats, 'external', now).series || []) {
179
+ for (const point of series.history || []) {
180
+ const from = Number.isFinite(point.intervalStart) ? point.intervalStart : point.time;
181
+ if (Number.isFinite(from)) {
182
+ earliest = Math.min(earliest, from);
183
+ }
184
+ if (point.ext > 0 && Number.isFinite(point.extTat)) {
185
+ count += point.ext;
186
+ time += point.ext * point.extTat;
187
+ }
188
+ }
189
+ }
190
+ return count > 0 ? { count, average: time / count, hours: (now - earliest) / 3600000 } : null;
191
+ }
192
+
193
+ module.exports = { ClusterMetrics, connectMetrics, graphData, graphSeries, externalSummary, METRIC, SNAPSHOT, HISTORY_MS, MAX_POINTS };