@gigzen/populace 0.1.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 (38) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +258 -0
  3. package/adapters/buzzbuzz.mjs +247 -0
  4. package/adapters/contract.md +164 -0
  5. package/adapters/template-rest.mjs +192 -0
  6. package/adapters/template.mjs +80 -0
  7. package/examples/buzzbuzz/populace-report.html +245 -0
  8. package/examples/buzzbuzz/populace-report.json +280 -0
  9. package/examples/buzzbuzz/populace.config.mjs +51 -0
  10. package/examples/buzzbuzz/run-test.ps1 +61 -0
  11. package/examples/demo/adapters/demo.mjs +90 -0
  12. package/examples/demo/populace-report.html +230 -0
  13. package/examples/demo/populace-report.json +219 -0
  14. package/examples/demo/populace.config.mjs +22 -0
  15. package/examples/rest-api/README.md +85 -0
  16. package/examples/rest-api/adapter.mjs +166 -0
  17. package/examples/rest-api/populace.config.mjs +40 -0
  18. package/examples/rest-api/server.mjs +247 -0
  19. package/examples/token-expiry/expiry-demo.mjs +119 -0
  20. package/package.json +56 -0
  21. package/populace.config.example.mjs +65 -0
  22. package/src/cli.mjs +591 -0
  23. package/src/config.mjs +186 -0
  24. package/src/contract.mjs +130 -0
  25. package/src/diagnose.mjs +40 -0
  26. package/src/engine/agent.mjs +264 -0
  27. package/src/engine/geo.mjs +59 -0
  28. package/src/engine/index.mjs +4 -0
  29. package/src/engine/personas.mjs +115 -0
  30. package/src/engine/world.mjs +120 -0
  31. package/src/html-report.mjs +218 -0
  32. package/src/index.mjs +38 -0
  33. package/src/instrument.mjs +299 -0
  34. package/src/net.mjs +175 -0
  35. package/src/report.mjs +251 -0
  36. package/src/selftest.mjs +1369 -0
  37. package/src/smoke.mjs +274 -0
  38. package/src/version.mjs +24 -0
@@ -0,0 +1,299 @@
1
+ // Wraps an adapter so every call through it is timed, counted, and its failures
2
+ // grouped by message.
3
+ //
4
+ // This is what a customer actually buys. The simulation being interesting to
5
+ // watch is not the product — the product is the evidence it leaves behind:
6
+ // which of your endpoints broke, how often, under how many concurrent users,
7
+ // and how slow they got while it was happening.
8
+ //
9
+ // Neither the engine nor the adapter knows this exists. The engine calls
10
+ // `adapter.post(...)`; the adapter does its thing; the numbers accumulate in
11
+ // between.
12
+
13
+ import {
14
+ backoffMs,
15
+ CircuitBreaker,
16
+ isTransportError,
17
+ sleep,
18
+ TargetUnreachableError,
19
+ } from "./net.mjs";
20
+
21
+ const PERCENTILES = [50, 95, 99];
22
+
23
+ // A call that never comes back is the worst failure mode this tool has, because
24
+ // it does not look like a failure — it looks like nothing. A real run against a
25
+ // flaky network froze at tick 24 of 60 and sat there silently until an external
26
+ // timeout killed it 9 minutes later, producing no report at all. The customer's
27
+ // API was fine; one socket died and the whole run went with it.
28
+ //
29
+ // So every adapter call gets a deadline. Past it we stop waiting, record a
30
+ // normal failure, and let the other agents carry on. A slow API then shows up
31
+ // as a timeout in the report — which is a finding — instead of a hung process,
32
+ // which is nothing.
33
+ export const DEFAULT_TIMEOUT_MS = 20_000;
34
+
35
+ // Transport failures get retried; application failures never do. Three extra
36
+ // attempts absorbs the ordinary blips of a real network — a CI runner losing a
37
+ // socket, a staging box behind a flaky VPN — without papering over an endpoint
38
+ // that is genuinely down, which still fails after the last attempt.
39
+ export const DEFAULT_RETRIES = 3;
40
+
41
+ // Consecutive transport failures before Populace concludes the target is gone
42
+ // and stops the run. Twelve is roughly two full ticks' worth of calls for a
43
+ // small population — long enough that a brief outage does not abort a good run,
44
+ // short enough that a dead host is called in under a minute instead of grinding
45
+ // out the full duration and producing nothing.
46
+ export const DEFAULT_GIVE_UP_AFTER = 12;
47
+
48
+ export class TimeoutError extends Error {
49
+ constructor(method, ms) {
50
+ super(`${method} timed out after ${ms}ms`);
51
+ this.name = "TimeoutError";
52
+ this.isTimeout = true;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Reject once `ms` has passed. Resolves to a cancel() so the timer is always
58
+ * cleared — an uncleared timer keeps the process alive past the end of a run,
59
+ * which would make `populace run` hang on exit for every fast call.
60
+ */
61
+ function deadline(method, ms) {
62
+ let timer;
63
+ const promise = new Promise((_, reject) => {
64
+ timer = setTimeout(() => reject(new TimeoutError(method, ms)), ms);
65
+ });
66
+ return { promise, cancel: () => clearTimeout(timer) };
67
+ }
68
+
69
+ export function createMetrics() {
70
+ return { methods: new Map(), startedAt: Date.now(), endedAt: null };
71
+ }
72
+
73
+ function bucket(metrics, method) {
74
+ if (!metrics.methods.has(method)) {
75
+ metrics.methods.set(method, {
76
+ method,
77
+ calls: 0,
78
+ failures: 0,
79
+ // Failures split by whose problem they are. `apiFailures` are findings
80
+ // about the customer's code; `transportFailures` are the network between
81
+ // us and them, and must never be presented as the same thing.
82
+ apiFailures: 0,
83
+ transportFailures: 0,
84
+ // Attempts that failed at transport level and were retried. Kept and
85
+ // reported: a run that only survived on its fifth try is not the same as
86
+ // one that worked first time, and hiding that would inflate reliability.
87
+ retries: 0,
88
+ durations: [],
89
+ errors: new Map(),
90
+ firstErrorAt: null,
91
+ });
92
+ }
93
+ return metrics.methods.get(method);
94
+ }
95
+
96
+ /**
97
+ * Group errors by SHAPE, not by exact text. Two failures that differ only by a
98
+ * uuid or a row count are the same bug, and a report that lists them separately
99
+ * buries the signal it was written to surface.
100
+ */
101
+ export function normaliseError(error) {
102
+ return String(error?.message || error || "unknown error")
103
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
104
+ .replace(/\b\d+\b/g, "<n>")
105
+ .replace(/\s+/g, " ")
106
+ .trim()
107
+ .slice(0, 200);
108
+ }
109
+
110
+ export function instrument(
111
+ adapter,
112
+ metrics,
113
+ {
114
+ timeoutMs = DEFAULT_TIMEOUT_MS,
115
+ retries = DEFAULT_RETRIES,
116
+ giveUpAfter = DEFAULT_GIVE_UP_AFTER,
117
+ breaker = new CircuitBreaker({ threshold: giveUpAfter }),
118
+ } = {},
119
+ ) {
120
+ const wrapped = { name: adapter.name };
121
+ metrics.breaker = breaker;
122
+ // 0 or Infinity disables the deadline, for adapters whose work is legitimately
123
+ // long (a batch import, a deliberate slow-endpoint probe).
124
+ const limited = Number.isFinite(timeoutMs) && timeoutMs > 0;
125
+ const maxAttempts = Math.max(1, Number(retries) + 1);
126
+
127
+ for (const key of Object.keys(adapter)) {
128
+ const value = adapter[key];
129
+ if (typeof value !== "function") {
130
+ wrapped[key] = value;
131
+ continue;
132
+ }
133
+
134
+ /** One attempt, with the deadline applied. Timing is per-attempt. */
135
+ const attemptOnce = async (args) => {
136
+ const clock = limited ? deadline(key, timeoutMs) : null;
137
+ const started = performance.now();
138
+ try {
139
+ // We stop WAITING at the deadline; we cannot cancel the adapter's own
140
+ // work, so a late reply may still land and is simply ignored. Recording
141
+ // the timeout as the outcome is honest — the run did not get an answer
142
+ // in time, which is exactly what a user of the API would experience.
143
+ const call = value.apply(adapter, args);
144
+ // Never leave a late rejection unhandled: once we have raced past it,
145
+ // nothing is awaiting `call`, and an unhandled rejection would crash the
146
+ // run with a stack trace that has nothing to do with the real problem.
147
+ if (clock) call.then?.(undefined, () => {});
148
+ const result = clock ? await Promise.race([call, clock.promise]) : await call;
149
+ return { ok: true, result, ms: performance.now() - started };
150
+ } catch (error) {
151
+ return { ok: false, error, ms: performance.now() - started };
152
+ } finally {
153
+ clock?.cancel();
154
+ }
155
+ };
156
+
157
+ wrapped[key] = async (...args) => {
158
+ const entry = bucket(metrics, key);
159
+ entry.calls += 1;
160
+
161
+ // Open and still inside its cooldown: fail instantly rather than spend
162
+ // the full deadline rediscovering the same thing. allows() lets a single
163
+ // probe through once the cooldown expires, so a passing outage recovers
164
+ // instead of ending the run.
165
+ if (!breaker.allows()) {
166
+ const error = new TargetUnreachableError(breaker.openedAfter);
167
+ error.fromAdapter = true;
168
+ entry.failures += 1;
169
+ entry.transportFailures += 1;
170
+ entry.durations.push(0);
171
+ if (entry.firstErrorAt === null) entry.firstErrorAt = Date.now();
172
+ const shape = normaliseError(error);
173
+ entry.errors.set(shape, (entry.errors.get(shape) || 0) + 1);
174
+ throw error;
175
+ }
176
+
177
+ for (let attempt = 1; ; attempt++) {
178
+ const outcome = await attemptOnce(args);
179
+
180
+ if (outcome.ok) {
181
+ breaker.recordSuccess();
182
+ // Only the SUCCESSFUL attempt's duration is recorded. Including the
183
+ // failed attempts before it would fold network problems into the
184
+ // customer's latency figures and make p50/p95 unpublishable — which
185
+ // is exactly what went wrong in an earlier run of this tool.
186
+ entry.durations.push(outcome.ms);
187
+ return outcome.result;
188
+ }
189
+
190
+ const error = outcome.error;
191
+ const transport = isTransportError(error);
192
+
193
+ // An error the server RETURNED proves the link is alive, whatever it
194
+ // says about their code. That must reset the breaker, or a genuinely
195
+ // broken endpoint would look like a dead network and abort the run.
196
+ if (transport) breaker.recordTransportFailure();
197
+ else breaker.recordApiFailure();
198
+
199
+ // Retry ONLY when the server never answered. Any response the server
200
+ // actually produced — including a 500 — is a finding about their code,
201
+ // and retrying it would quietly turn a real bug into a green tick.
202
+ if (transport && attempt < maxAttempts && !breaker.open) {
203
+ entry.retries += 1;
204
+ await sleep(backoffMs(attempt));
205
+ continue;
206
+ }
207
+
208
+ // Mark it as the adapter's, so the engine can tell a customer's API
209
+ // failing apart from a bug of our own. Untagged failures reaching the
210
+ // agent loop are Populace's fault and must not be reported as theirs.
211
+ if (error && typeof error === "object") error.fromAdapter = true;
212
+ entry.durations.push(outcome.ms);
213
+ entry.failures += 1;
214
+ if (transport) entry.transportFailures += 1;
215
+ else entry.apiFailures += 1;
216
+ if (entry.firstErrorAt === null) entry.firstErrorAt = Date.now();
217
+ const shape = normaliseError(error);
218
+ entry.errors.set(shape, (entry.errors.get(shape) || 0) + 1);
219
+ throw error;
220
+ }
221
+ };
222
+ }
223
+ return wrapped;
224
+ }
225
+
226
+ function percentile(sorted, p) {
227
+ if (!sorted.length) return 0;
228
+ const i = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
229
+ return sorted[Math.max(0, i)];
230
+ }
231
+
232
+ export function summarise(metrics) {
233
+ const methods = [...metrics.methods.values()].map((entry) => {
234
+ const sorted = [...entry.durations].sort((a, b) => a - b);
235
+ const latency = Object.fromEntries(
236
+ PERCENTILES.map((p) => [`p${p}`, Math.round(percentile(sorted, p))]),
237
+ );
238
+ return {
239
+ method: entry.method,
240
+ calls: entry.calls,
241
+ failures: entry.failures,
242
+ apiFailures: entry.apiFailures,
243
+ transportFailures: entry.transportFailures,
244
+ retries: entry.retries,
245
+ failureRate: entry.calls ? entry.failures / entry.calls : 0,
246
+ latencyMs: { ...latency, max: Math.round(sorted[sorted.length - 1] || 0) },
247
+ errors: [...entry.errors.entries()]
248
+ .map(([message, count]) => ({ message, count }))
249
+ .sort((a, b) => b.count - a.count),
250
+ };
251
+ });
252
+
253
+ // Sort by the customer's own failures first. On a bad link transport noise
254
+ // can dwarf a single real bug, and the bug is what they need to see at the
255
+ // top of the table.
256
+ methods.sort(
257
+ (a, b) => b.apiFailures - a.apiFailures || b.failures - a.failures || b.calls - a.calls,
258
+ );
259
+
260
+ const sum = (f) => methods.reduce((n, m) => n + f(m), 0);
261
+ const calls = sum((m) => m.calls);
262
+ const failures = sum((m) => m.failures);
263
+ const apiFailures = sum((m) => m.apiFailures);
264
+ const transportFailures = sum((m) => m.transportFailures);
265
+ const retries = sum((m) => m.retries);
266
+
267
+ return {
268
+ calls,
269
+ failures,
270
+ apiFailures,
271
+ transportFailures,
272
+ retries,
273
+ failureRate: calls ? failures / calls : 0,
274
+ // The rate that actually says something about their software. Reported
275
+ // separately so a bad link cannot inflate the number they are judged on.
276
+ apiFailureRate: calls ? apiFailures / calls : 0,
277
+ network: {
278
+ retries,
279
+ transportFailures,
280
+ // Attempts that had to be repeated, as a share of all attempts made.
281
+ // A high number here means the link was bad, NOT that the API was.
282
+ retryRate: calls + retries ? retries / (calls + retries) : 0,
283
+ healthy: retries === 0 && transportFailures === 0,
284
+ // Set when Populace concluded the target was gone and stopped early.
285
+ // Without this the report would show a short run full of failures and
286
+ // give no clue that it was cut short deliberately.
287
+ // gaveUp means ABANDONED, not merely open. A breaker that opened and
288
+ // then recovered is a run that survived an outage — reporting that as
289
+ // "gave up" would call a completed run incomplete.
290
+ gaveUp: Boolean(metrics.breaker?.abandoned),
291
+ gaveUpAfter: metrics.breaker?.openedAfter ?? null,
292
+ // Outages ridden out and recovered from. Worth reporting: it is the
293
+ // difference between a clean network and one that merely held together.
294
+ outages: metrics.breaker?.trips ?? 0,
295
+ },
296
+ durationMs: (metrics.endedAt || Date.now()) - metrics.startedAt,
297
+ methods,
298
+ };
299
+ }
package/src/net.mjs ADDED
@@ -0,0 +1,175 @@
1
+ // Telling "your API said no" apart from "we never reached your API".
2
+ //
3
+ // This distinction is the difference between a report a customer trusts and one
4
+ // they argue with. A dropped socket is not a bug in their code, and a tool that
5
+ // reports it as one will be dismissed the first time it cries wolf. Equally, a
6
+ // 500 from their server IS their bug and must never be quietly retried away.
7
+ //
8
+ // So the rule is narrow on purpose: only failures that happened BELOW the HTTP
9
+ // layer count as transport. Anything the server actually answered — any status
10
+ // code at all — is the application's, and is reported exactly once.
11
+
12
+ const TRANSPORT = [
13
+ "fetch failed", // undici's blanket wrapper; cause carries the detail
14
+ "ENOTFOUND", // DNS did not resolve
15
+ "EAI_AGAIN", // DNS temporary failure
16
+ "ECONNREFUSED",
17
+ "ECONNRESET",
18
+ "EPIPE",
19
+ "ETIMEDOUT",
20
+ "EHOSTUNREACH",
21
+ "ENETUNREACH",
22
+ "ENETDOWN",
23
+ "UND_ERR", // undici's own timeouts (connect, headers, body)
24
+ "socket hang up",
25
+ "network",
26
+ "Client network socket disconnected",
27
+ ];
28
+
29
+ /**
30
+ * True when a call failed without the server ever answering.
31
+ *
32
+ * Matches against `cause.code` first — undici hides the real reason there and
33
+ * flattens everything to the message "fetch failed", which on its own tells you
34
+ * nothing.
35
+ */
36
+ export function isTransportError(error) {
37
+ if (!error) return false;
38
+ if (error.isTimeout) return true; // our own deadline: no answer arrived
39
+ const text = String(
40
+ error.cause?.code || error.code || error.cause?.message || error.message || error,
41
+ );
42
+ return TRANSPORT.some((t) => text.toLowerCase().includes(t.toLowerCase()));
43
+ }
44
+
45
+ /**
46
+ * Exponential backoff with jitter.
47
+ *
48
+ * Jitter matters here more than usual: Populace runs N agents in lockstep on a
49
+ * tick, so a blip tends to hit all of them at once. Without jitter they would
50
+ * retry in unison and hammer an already-struggling API in synchronised waves —
51
+ * turning a small outage into a self-inflicted load test.
52
+ */
53
+ export function backoffMs(attempt, base = 250, cap = 4000) {
54
+ const exponential = Math.min(cap, base * 2 ** (attempt - 1));
55
+ return Math.round(exponential / 2 + Math.random() * (exponential / 2));
56
+ }
57
+
58
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
+
60
+ /**
61
+ * Stops retrying once the target is clearly gone rather than merely flaky.
62
+ *
63
+ * Retries fix a flaky link and make a dead one agonising: every call costs the
64
+ * full deadline times every attempt, so a run against an unreachable host
65
+ * crawls for twenty minutes and then produces nothing. Observed exactly that —
66
+ * the retry fix removed the hang and replaced it with a grind.
67
+ *
68
+ * So: after `threshold` transport failures in a row with no success in between,
69
+ * the breaker opens. Calls then fail immediately, the run ends early, and the
70
+ * report says the target became unreachable — in seconds instead of the best
71
+ * part of an hour.
72
+ *
73
+ * Any single success closes it again. A brief outage in the middle of an
74
+ * otherwise fine run must not abort that run; only a sustained one should.
75
+ */
76
+ export class CircuitBreaker {
77
+ constructor({ threshold = 12, cooldownMs = 15_000, giveUpAfterProbes = 4 } = {}) {
78
+ this.threshold = threshold;
79
+ this.cooldownMs = cooldownMs;
80
+ // How many cooldowns may expire with the target still dead before we stop
81
+ // trying. Four at 15s means a run survives roughly a minute of outage and
82
+ // still abandons a genuinely dead host promptly.
83
+ this.giveUpAfterProbes = giveUpAfterProbes;
84
+
85
+ this.consecutive = 0;
86
+ this.open = false;
87
+ this.openedAfter = null;
88
+ this.openedAt = 0;
89
+ this.probes = 0; // cooldowns elapsed without a success
90
+ this.trips = 0; // how many times it opened during the run
91
+ this.abandoned = false; // permanently given up
92
+ }
93
+
94
+ /**
95
+ * Whether a call may go through right now.
96
+ *
97
+ * This is the half-open state, and leaving it out was a real bug: the breaker
98
+ * opened on the first burst of a flaky link and stayed open for the rest of
99
+ * the run, so a twenty-second blip killed a five-minute run that could have
100
+ * carried on. Network loss arrives in bursts, not as independent coin flips,
101
+ * so "N consecutive failures" is reached by any ordinary outage — the breaker
102
+ * has to be able to come back.
103
+ */
104
+ allows(now = Date.now()) {
105
+ if (this.abandoned) return false;
106
+ if (!this.open) return true;
107
+ if (now - this.openedAt < this.cooldownMs) return false;
108
+ // Cooldown elapsed: let exactly one call through to test the water.
109
+ this.openedAt = now;
110
+ this.probes += 1;
111
+ if (this.probes > this.giveUpAfterProbes) {
112
+ this.abandoned = true;
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ recordSuccess() {
119
+ this.consecutive = 0;
120
+ this.open = false;
121
+ // A probe that got through means the target is back. Clear the probe budget
122
+ // so a later, unrelated outage gets the full allowance again rather than
123
+ // inheriting the last one's.
124
+ this.probes = 0;
125
+ }
126
+
127
+ /**
128
+ * Force it shut for a new phase of work.
129
+ *
130
+ * Cleanup needs this. An open breaker made teardown fail all five deletions
131
+ * instantly and left five invented accounts live in a customer's project —
132
+ * the safety mechanism causing the exact harm the product promises to avoid.
133
+ * Cleanup is when you most need to keep trying, so it starts with a fresh
134
+ * budget rather than inheriting the run's verdict on the network.
135
+ */
136
+ reset() {
137
+ this.consecutive = 0;
138
+ this.open = false;
139
+ this.openedAfter = null;
140
+ this.openedAt = 0;
141
+ this.probes = 0;
142
+ this.abandoned = false;
143
+ }
144
+
145
+ /** @returns {boolean} true when this failure was the one that opened it. */
146
+ recordTransportFailure(now = Date.now()) {
147
+ if (this.threshold <= 0) return false; // disabled
148
+ this.consecutive += 1;
149
+ if (!this.open && this.consecutive >= this.threshold) {
150
+ this.open = true;
151
+ this.openedAfter = this.consecutive;
152
+ this.openedAt = now;
153
+ this.trips += 1;
154
+ return true;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ /** An application error says the server IS answering, so the link is alive. */
160
+ recordApiFailure() {
161
+ this.recordSuccess();
162
+ }
163
+ }
164
+
165
+ export class TargetUnreachableError extends Error {
166
+ constructor(consecutive) {
167
+ super(
168
+ `Target became unreachable — ${consecutive} consecutive calls failed at the network ` +
169
+ `level with no response. Stopping rather than retrying for the rest of the run.`,
170
+ );
171
+ this.name = "TargetUnreachableError";
172
+ this.isTransport = true;
173
+ this.circuitOpen = true;
174
+ }
175
+ }