@fedify/cli 2.3.0-dev.1219 → 2.3.0-dev.1273
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/dist/bench/action.js +309 -0
- package/dist/bench/actor/documents.js +39 -0
- package/dist/bench/actor/fleet.js +39 -0
- package/dist/bench/actor/keys.js +35 -0
- package/dist/bench/command.js +42 -0
- package/dist/bench/discovery/discover.js +67 -0
- package/dist/bench/discovery/probe.js +50 -0
- package/dist/bench/load/arrival.js +27 -0
- package/dist/bench/load/clock.js +15 -0
- package/dist/bench/load/generator.js +112 -0
- package/dist/bench/metrics/aggregate.js +64 -0
- package/dist/bench/metrics/histogram.js +141 -0
- package/dist/bench/metrics/stats-client.js +154 -0
- package/dist/bench/mod.js +4 -0
- package/dist/bench/render/format.js +46 -0
- package/dist/bench/render/index.js +20 -0
- package/dist/bench/render/json.js +12 -0
- package/dist/bench/render/markdown.js +62 -0
- package/dist/bench/render/text.js +74 -0
- package/dist/bench/result/build.js +129 -0
- package/dist/bench/result/expect/assert.js +74 -0
- package/dist/bench/result/expect/evaluate.js +128 -0
- package/dist/bench/result/expect/metrics.js +34 -0
- package/dist/bench/result/schema.js +15 -0
- package/dist/bench/safety/gate.js +60 -0
- package/dist/bench/safety/tiers.js +97 -0
- package/dist/bench/scenario/coerce.js +24 -0
- package/dist/bench/scenario/errors.js +36 -0
- package/dist/bench/scenario/load.js +69 -0
- package/dist/bench/scenario/normalize.js +126 -0
- package/dist/bench/scenario/schema.js +358 -0
- package/dist/bench/scenario/units.js +56 -0
- package/dist/bench/scenario/validate.js +29 -0
- package/dist/bench/scenarios/inbox.js +155 -0
- package/dist/bench/scenarios/registry.js +21 -0
- package/dist/bench/scenarios/runner.js +76 -0
- package/dist/bench/scenarios/webfinger.js +44 -0
- package/dist/bench/server/synthetic.js +118 -0
- package/dist/bench/signing/activity-id.js +18 -0
- package/dist/bench/signing/pipeline.js +134 -0
- package/dist/bench/signing/signer.js +39 -0
- package/dist/bench/template/generate.js +90 -0
- package/dist/bench/template/helpers.js +19 -0
- package/dist/bench/template/template.js +132 -0
- package/dist/cache.js +1 -1
- package/dist/config.js +14 -2
- package/dist/deno.js +1 -1
- package/dist/generate-vocab/action.js +3 -3
- package/dist/generate-vocab/command.js +1 -1
- package/dist/imagerenderer.js +1 -1
- package/dist/inbox/view.js +1 -1
- package/dist/inbox.js +2 -2
- package/dist/lookup.js +37 -37
- package/dist/mod.js +3 -0
- package/dist/nodeinfo.js +5 -5
- package/dist/options.js +1 -1
- package/dist/relay.js +1 -1
- package/dist/runner.js +10 -8
- package/dist/tempserver.js +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/utils.js +8 -4
- package/dist/webfinger/action.js +1 -1
- package/package.json +12 -10
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { scheduleArrivals } from "./arrival.js";
|
|
3
|
+
import { systemClock } from "./clock.js";
|
|
4
|
+
//#region src/bench/load/generator.ts
|
|
5
|
+
/**
|
|
6
|
+
* Runs a load plan against a send function.
|
|
7
|
+
* @param plan The load plan.
|
|
8
|
+
* @param send The function that performs one send.
|
|
9
|
+
* @param clock The clock (overridable for tests); defaults to the system clock.
|
|
10
|
+
* @returns The recorded samples and run metadata.
|
|
11
|
+
*/
|
|
12
|
+
function runLoad(plan, send, clock = systemClock()) {
|
|
13
|
+
return plan.load.kind === "open" ? runOpenLoop(plan, plan.load, send, clock) : runClosedLoop(plan, plan.load, send, clock);
|
|
14
|
+
}
|
|
15
|
+
async function runOpenLoop(plan, load, send, clock) {
|
|
16
|
+
const arrivals = scheduleArrivals({
|
|
17
|
+
ratePerSec: load.ratePerSec,
|
|
18
|
+
durationMs: plan.durationMs,
|
|
19
|
+
arrival: load.arrival,
|
|
20
|
+
rng: plan.rng
|
|
21
|
+
});
|
|
22
|
+
const samples = [];
|
|
23
|
+
const slots = createSemaphore(load.maxInFlight);
|
|
24
|
+
let saturated = false;
|
|
25
|
+
const start = clock.now();
|
|
26
|
+
const active = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const offset of arrivals) {
|
|
28
|
+
await clock.sleepUntil(start + offset);
|
|
29
|
+
if (await slots.acquire()) saturated = true;
|
|
30
|
+
const dispatched = dispatch(send, offset, start, plan.warmupMs, clock, samples).finally(() => {
|
|
31
|
+
slots.release();
|
|
32
|
+
active.delete(dispatched);
|
|
33
|
+
});
|
|
34
|
+
active.add(dispatched);
|
|
35
|
+
}
|
|
36
|
+
await Promise.all(active);
|
|
37
|
+
return {
|
|
38
|
+
samples,
|
|
39
|
+
saturated,
|
|
40
|
+
wallDurationMs: clock.now() - start
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function runClosedLoop(plan, load, send, clock) {
|
|
44
|
+
const samples = [];
|
|
45
|
+
const slots = createSemaphore(load.maxInFlight);
|
|
46
|
+
let saturated = false;
|
|
47
|
+
const start = clock.now();
|
|
48
|
+
const deadline = start + plan.durationMs;
|
|
49
|
+
async function worker() {
|
|
50
|
+
while (clock.now() < deadline) {
|
|
51
|
+
if (await slots.acquire()) saturated = true;
|
|
52
|
+
if (clock.now() >= deadline) {
|
|
53
|
+
slots.release();
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const offset = clock.now() - start;
|
|
57
|
+
try {
|
|
58
|
+
await dispatch(send, offset, start, plan.warmupMs, clock, samples);
|
|
59
|
+
} finally {
|
|
60
|
+
slots.release();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await Promise.all(Array.from({ length: load.concurrency }, () => worker()));
|
|
65
|
+
return {
|
|
66
|
+
samples,
|
|
67
|
+
saturated,
|
|
68
|
+
wallDurationMs: clock.now() - start
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function dispatch(send, offset, start, warmupMs, clock, samples) {
|
|
72
|
+
let outcome;
|
|
73
|
+
try {
|
|
74
|
+
outcome = await send(offset);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
outcome = {
|
|
77
|
+
ok: false,
|
|
78
|
+
errorKind: "exception",
|
|
79
|
+
reason: String(error)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
samples.push({
|
|
83
|
+
scheduledAtMs: offset,
|
|
84
|
+
latencyMs: clock.now() - (start + offset),
|
|
85
|
+
warmup: offset < warmupMs,
|
|
86
|
+
outcome
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function createSemaphore(max) {
|
|
90
|
+
if (max == null) return {
|
|
91
|
+
acquire: () => Promise.resolve(false),
|
|
92
|
+
release: () => {}
|
|
93
|
+
};
|
|
94
|
+
let count = 0;
|
|
95
|
+
const queue = [];
|
|
96
|
+
return {
|
|
97
|
+
acquire() {
|
|
98
|
+
if (count < max) {
|
|
99
|
+
count++;
|
|
100
|
+
return Promise.resolve(false);
|
|
101
|
+
}
|
|
102
|
+
return new Promise((resolve) => queue.push(() => resolve(true)));
|
|
103
|
+
},
|
|
104
|
+
release() {
|
|
105
|
+
const next = queue.shift();
|
|
106
|
+
if (next != null) next();
|
|
107
|
+
else count--;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { runLoad };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { LogLinearHistogram } from "./histogram.js";
|
|
3
|
+
//#region src/bench/metrics/aggregate.ts
|
|
4
|
+
/**
|
|
5
|
+
* Aggregates samples into the client side of a scenario measurement (the
|
|
6
|
+
* `server` field is left `null` for the runner to fill from the stats endpoint).
|
|
7
|
+
* @param samples The raw samples from the load generator.
|
|
8
|
+
* @param options Aggregation options.
|
|
9
|
+
* @returns The client-side scenario measurement.
|
|
10
|
+
*/
|
|
11
|
+
function aggregateSamples(samples, options) {
|
|
12
|
+
const measured = samples.filter((s) => !s.warmup);
|
|
13
|
+
const histogram = new LogLinearHistogram();
|
|
14
|
+
const errorCounts = /* @__PURE__ */ new Map();
|
|
15
|
+
let ok = 0;
|
|
16
|
+
for (const sample of measured) {
|
|
17
|
+
histogram.record(sample.latencyMs);
|
|
18
|
+
if (sample.outcome.ok) ok++;
|
|
19
|
+
else bucketError(errorCounts, sample);
|
|
20
|
+
}
|
|
21
|
+
const total = measured.length;
|
|
22
|
+
const requests = {
|
|
23
|
+
total,
|
|
24
|
+
ok,
|
|
25
|
+
failed: total - ok,
|
|
26
|
+
successRate: total === 0 ? 1 : ok / total
|
|
27
|
+
};
|
|
28
|
+
const windowSec = Math.max(options.measuredWindowMs, 1) / 1e3;
|
|
29
|
+
const client = { latencyMs: {
|
|
30
|
+
p50: histogram.percentile(50),
|
|
31
|
+
p95: histogram.percentile(95),
|
|
32
|
+
p99: histogram.percentile(99),
|
|
33
|
+
mean: histogram.mean,
|
|
34
|
+
max: histogram.max
|
|
35
|
+
} };
|
|
36
|
+
const errors = [...errorCounts.values()].sort((a, b) => b.count - a.count);
|
|
37
|
+
return {
|
|
38
|
+
requests,
|
|
39
|
+
throughputPerSec: total / windowSec,
|
|
40
|
+
client,
|
|
41
|
+
server: null,
|
|
42
|
+
errors,
|
|
43
|
+
...options.includeHistogram ? { histogram: histogram.toJSON() } : {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function bucketError(buckets, sample) {
|
|
47
|
+
const { status, errorKind, reason } = sample.outcome;
|
|
48
|
+
const kind = errorKind ?? (status != null ? "http" : "error");
|
|
49
|
+
const reasonText = reason ?? (status != null ? `status_${status}` : "error");
|
|
50
|
+
const key = `${kind}|${status ?? ""}|${reasonText}`;
|
|
51
|
+
const existing = buckets.get(key);
|
|
52
|
+
if (existing != null) buckets.set(key, {
|
|
53
|
+
...existing,
|
|
54
|
+
count: existing.count + 1
|
|
55
|
+
});
|
|
56
|
+
else buckets.set(key, {
|
|
57
|
+
kind,
|
|
58
|
+
...status != null ? { status } : {},
|
|
59
|
+
reason: reasonText,
|
|
60
|
+
count: 1
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { aggregateSamples };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
/**
|
|
3
|
+
* A sparse log-linear histogram.
|
|
4
|
+
* @since 2.3.0
|
|
5
|
+
*/
|
|
6
|
+
var LogLinearHistogram = class LogLinearHistogram {
|
|
7
|
+
subBucketCount;
|
|
8
|
+
#buckets = /* @__PURE__ */ new Map();
|
|
9
|
+
#count = 0;
|
|
10
|
+
#zeroCount = 0;
|
|
11
|
+
#sum = 0;
|
|
12
|
+
#min = Number.POSITIVE_INFINITY;
|
|
13
|
+
#max = Number.NEGATIVE_INFINITY;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
const subBucketCount = options.subBucketCount ?? 128;
|
|
16
|
+
if (!Number.isInteger(subBucketCount) || subBucketCount < 1) throw new RangeError(`subBucketCount must be a positive integer; got ${subBucketCount}.`);
|
|
17
|
+
this.subBucketCount = subBucketCount;
|
|
18
|
+
}
|
|
19
|
+
/** The total number of recorded samples, including zeros. */
|
|
20
|
+
get count() {
|
|
21
|
+
return this.#count;
|
|
22
|
+
}
|
|
23
|
+
/** The smallest recorded value, or `0` when the histogram is empty. */
|
|
24
|
+
get min() {
|
|
25
|
+
return this.#count === 0 ? 0 : this.#min;
|
|
26
|
+
}
|
|
27
|
+
/** The largest recorded value, or `0` when the histogram is empty. */
|
|
28
|
+
get max() {
|
|
29
|
+
return this.#count === 0 ? 0 : this.#max;
|
|
30
|
+
}
|
|
31
|
+
/** The arithmetic mean of all recorded values, or `0` when empty. */
|
|
32
|
+
get mean() {
|
|
33
|
+
return this.#count === 0 ? 0 : this.#sum / this.#count;
|
|
34
|
+
}
|
|
35
|
+
/** The exact sum of all recorded values. */
|
|
36
|
+
get sum() {
|
|
37
|
+
return this.#sum;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Records a single sample.
|
|
41
|
+
* @param value The value to record. Non-finite values are ignored; any
|
|
42
|
+
* non-positive value (negatives, `0`, and `-0`) is normalized to
|
|
43
|
+
* `0` and recorded in the zero bucket, since latency samples are
|
|
44
|
+
* never negative.
|
|
45
|
+
*/
|
|
46
|
+
record(value) {
|
|
47
|
+
if (!Number.isFinite(value)) return;
|
|
48
|
+
const v = value <= 0 ? 0 : value;
|
|
49
|
+
this.#count++;
|
|
50
|
+
this.#sum += v;
|
|
51
|
+
if (v < this.#min) this.#min = v;
|
|
52
|
+
if (v > this.#max) this.#max = v;
|
|
53
|
+
if (v === 0) {
|
|
54
|
+
this.#zeroCount++;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const index = this.#indexOf(v);
|
|
58
|
+
this.#buckets.set(index, (this.#buckets.get(index) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Computes an estimated percentile.
|
|
62
|
+
* @param p The percentile to compute, between 0 and 100 inclusive.
|
|
63
|
+
* @returns The estimated value at the given percentile, or `0` when the
|
|
64
|
+
* histogram is empty.
|
|
65
|
+
*/
|
|
66
|
+
percentile(p) {
|
|
67
|
+
if (this.#count === 0) return 0;
|
|
68
|
+
if (p <= 0) return this.#min;
|
|
69
|
+
if (p >= 100) return this.#max;
|
|
70
|
+
const target = Math.ceil(p / 100 * this.#count);
|
|
71
|
+
let accumulated = this.#zeroCount;
|
|
72
|
+
if (accumulated >= target) return 0;
|
|
73
|
+
const indices = [...this.#buckets.keys()].sort((a, b) => a - b);
|
|
74
|
+
for (const index of indices) {
|
|
75
|
+
accumulated += this.#buckets.get(index);
|
|
76
|
+
if (accumulated >= target) return this.#clamp(this.#representativeValue(index));
|
|
77
|
+
}
|
|
78
|
+
return this.#max;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Merges another histogram into this one. Both histograms must use the same
|
|
82
|
+
* {@link LogLinearHistogram.subBucketCount}.
|
|
83
|
+
* @param other The histogram to merge in.
|
|
84
|
+
*/
|
|
85
|
+
merge(other) {
|
|
86
|
+
if (other.subBucketCount !== this.subBucketCount) throw new TypeError(`Cannot merge histograms with different subBucketCount (${this.subBucketCount} vs ${other.subBucketCount}).`);
|
|
87
|
+
if (other.#count === 0) return;
|
|
88
|
+
for (const [index, count] of other.#buckets) this.#buckets.set(index, (this.#buckets.get(index) ?? 0) + count);
|
|
89
|
+
this.#count += other.#count;
|
|
90
|
+
this.#zeroCount += other.#zeroCount;
|
|
91
|
+
this.#sum += other.#sum;
|
|
92
|
+
if (other.#min < this.#min) this.#min = other.#min;
|
|
93
|
+
if (other.#max > this.#max) this.#max = other.#max;
|
|
94
|
+
}
|
|
95
|
+
/** Serializes the histogram to a plain JSON-compatible object. */
|
|
96
|
+
toJSON() {
|
|
97
|
+
const indices = [...this.#buckets.keys()].sort((a, b) => a - b);
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
subBucketCount: this.subBucketCount,
|
|
101
|
+
count: this.#count,
|
|
102
|
+
zeroCount: this.#zeroCount,
|
|
103
|
+
min: this.min,
|
|
104
|
+
max: this.max,
|
|
105
|
+
sum: this.#sum,
|
|
106
|
+
indices,
|
|
107
|
+
counts: indices.map((index) => this.#buckets.get(index))
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Reconstructs a histogram from its serialized form. */
|
|
111
|
+
static fromJSON(json) {
|
|
112
|
+
if (json.indices.length !== json.counts.length) throw new TypeError("Serialized histogram indices and counts must have equal length.");
|
|
113
|
+
const histogram = new LogLinearHistogram({ subBucketCount: json.subBucketCount });
|
|
114
|
+
for (let i = 0; i < json.indices.length; i++) histogram.#buckets.set(json.indices[i], json.counts[i]);
|
|
115
|
+
histogram.#count = json.count;
|
|
116
|
+
histogram.#zeroCount = json.zeroCount;
|
|
117
|
+
histogram.#sum = json.sum;
|
|
118
|
+
histogram.#min = json.count === 0 ? Number.POSITIVE_INFINITY : json.min;
|
|
119
|
+
histogram.#max = json.count === 0 ? Number.NEGATIVE_INFINITY : json.max;
|
|
120
|
+
return histogram;
|
|
121
|
+
}
|
|
122
|
+
#indexOf(value) {
|
|
123
|
+
const octave = Math.floor(Math.log2(value));
|
|
124
|
+
let sub = Math.floor((value / 2 ** octave - 1) * this.subBucketCount);
|
|
125
|
+
if (sub < 0) sub = 0;
|
|
126
|
+
else if (sub >= this.subBucketCount) sub = this.subBucketCount - 1;
|
|
127
|
+
return octave * this.subBucketCount + sub;
|
|
128
|
+
}
|
|
129
|
+
#representativeValue(index) {
|
|
130
|
+
const octave = Math.floor(index / this.subBucketCount);
|
|
131
|
+
const sub = index - octave * this.subBucketCount;
|
|
132
|
+
return 2 ** octave * (1 + (sub + .5) / this.subBucketCount);
|
|
133
|
+
}
|
|
134
|
+
#clamp(value) {
|
|
135
|
+
if (value < this.#min) return this.#min;
|
|
136
|
+
if (value > this.#max) return this.#max;
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
export { LogLinearHistogram };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { STATS_PATH } from "../discovery/probe.js";
|
|
3
|
+
//#region src/bench/metrics/stats-client.ts
|
|
4
|
+
/**
|
|
5
|
+
* Reading server-side metrics from the cooperative `stats` endpoint.
|
|
6
|
+
*
|
|
7
|
+
* The endpoint returns a JSON projection of the target's OpenTelemetry meters
|
|
8
|
+
* (see *@fedify/fedify*'s benchmark module). This module projects the relevant
|
|
9
|
+
* instruments — signature verification latency and queue depth — into the
|
|
10
|
+
* report's `server` section, marked distinct from client-measured numbers.
|
|
11
|
+
*
|
|
12
|
+
* The server reader is cumulative and has no reset, so a single snapshot covers
|
|
13
|
+
* the target's whole lifetime. To scope server numbers to one scenario's
|
|
14
|
+
* measured window, callers take a {@link ServerSnapshot} baseline at the window
|
|
15
|
+
* start and another at the end, {@link diffSnapshots} the two, and project the
|
|
16
|
+
* difference with {@link snapshotToMetrics}.
|
|
17
|
+
* @since 2.3.0
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Parses a `stats` snapshot into raw server instruments. A successful parse
|
|
22
|
+
* always yields a snapshot, even when it carries no relevant instruments (both
|
|
23
|
+
* fields `null`); `null` is reserved for an unparseable snapshot, so callers can
|
|
24
|
+
* tell "available but empty" apart from "unavailable".
|
|
25
|
+
* @param snapshot The parsed `stats` JSON.
|
|
26
|
+
* @returns The raw server snapshot, or `null` if it could not be parsed.
|
|
27
|
+
*/
|
|
28
|
+
function parseServerSnapshot(snapshot) {
|
|
29
|
+
try {
|
|
30
|
+
const metrics = flattenMetrics(snapshot);
|
|
31
|
+
const sig = metrics.find((m) => m.dataPointType === "histogram" && (m.name ?? "").includes("signature.verification"));
|
|
32
|
+
const signature = sig == null ? null : mergeHistogram(sig.dataPoints);
|
|
33
|
+
let queueDepthMax = null;
|
|
34
|
+
const depth = metrics.find((m) => m.name === "fedify.queue.depth");
|
|
35
|
+
if (depth != null && Array.isArray(depth.dataPoints)) {
|
|
36
|
+
const values = depth.dataPoints.map((p) => p.value).filter(isFiniteNumber);
|
|
37
|
+
if (values.length > 0) queueDepthMax = Math.max(...values);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
signature,
|
|
41
|
+
queueDepthMax
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Subtracts a baseline snapshot from an end snapshot, yielding the instruments
|
|
49
|
+
* accumulated between the two (the measured window). Signature histogram
|
|
50
|
+
* counts are diffed bucket by bucket; the queue depth is a gauge, not a
|
|
51
|
+
* cumulative count, so the end value is kept as-is. Callers that cannot obtain
|
|
52
|
+
* both snapshots should not call this (and should report no server metrics)
|
|
53
|
+
* rather than passing a stand-in, since a missing baseline cannot be diffed.
|
|
54
|
+
* @param baseline The snapshot taken at the measured-window start.
|
|
55
|
+
* @param end The snapshot taken at the measured-window end.
|
|
56
|
+
* @returns The windowed snapshot.
|
|
57
|
+
*/
|
|
58
|
+
function diffSnapshots(baseline, end) {
|
|
59
|
+
return {
|
|
60
|
+
signature: diffHistogram(baseline.signature, end.signature),
|
|
61
|
+
queueDepthMax: end.queueDepthMax
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Projects a raw server snapshot into the report's server metrics, or `null`
|
|
66
|
+
* when it carries no usable measurement.
|
|
67
|
+
* @param snapshot The raw (optionally diffed) server snapshot.
|
|
68
|
+
* @returns The projected server metrics, or `null`.
|
|
69
|
+
*/
|
|
70
|
+
function snapshotToMetrics(snapshot) {
|
|
71
|
+
if (snapshot == null) return null;
|
|
72
|
+
const result = {};
|
|
73
|
+
if (snapshot.signature != null) {
|
|
74
|
+
if (snapshot.signature.counts.reduce((sum, n) => sum + n, 0) > 0) result.signatureVerificationMs = { overall: {
|
|
75
|
+
p50: histogramPercentile(snapshot.signature, 50),
|
|
76
|
+
p95: histogramPercentile(snapshot.signature, 95),
|
|
77
|
+
p99: histogramPercentile(snapshot.signature, 99)
|
|
78
|
+
} };
|
|
79
|
+
}
|
|
80
|
+
if (snapshot.queueDepthMax != null) result.queue = { depthMax: snapshot.queueDepthMax };
|
|
81
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fetches and parses the target's raw server snapshot.
|
|
85
|
+
* @param target The target base URL.
|
|
86
|
+
* @param fetchImpl The fetch implementation (overridable for tests).
|
|
87
|
+
* @returns The raw server snapshot, or `null` if unavailable.
|
|
88
|
+
*/
|
|
89
|
+
async function fetchServerSnapshot(target, fetchImpl = fetch) {
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetchImpl(new URL(STATS_PATH, target), { redirect: "manual" });
|
|
92
|
+
if (!response.ok) return null;
|
|
93
|
+
return parseServerSnapshot(await response.json());
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function isFiniteNumber(value) {
|
|
99
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
100
|
+
}
|
|
101
|
+
function flattenMetrics(snapshot) {
|
|
102
|
+
return (Array.isArray(snapshot?.scopeMetrics) ? snapshot.scopeMetrics : []).flatMap((scope) => {
|
|
103
|
+
const metrics = scope?.metrics;
|
|
104
|
+
return Array.isArray(metrics) ? metrics.filter((m) => m != null) : [];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function mergeHistogram(dataPoints) {
|
|
108
|
+
if (!Array.isArray(dataPoints)) return null;
|
|
109
|
+
let boundaries = null;
|
|
110
|
+
let counts = null;
|
|
111
|
+
for (const point of dataPoints) {
|
|
112
|
+
const value = point?.value;
|
|
113
|
+
if (typeof value !== "object" || value == null) continue;
|
|
114
|
+
const b = value.buckets?.boundaries;
|
|
115
|
+
const c = value.buckets?.counts;
|
|
116
|
+
if (!Array.isArray(b) || !Array.isArray(c)) continue;
|
|
117
|
+
if (!b.every(isFiniteNumber) || !c.every(isFiniteNumber)) continue;
|
|
118
|
+
if (boundaries == null) {
|
|
119
|
+
boundaries = [...b];
|
|
120
|
+
counts = [...c];
|
|
121
|
+
} else if (counts != null && counts.length === c.length && boundaries.length === b.length && boundaries.every((v, i) => v === b[i])) for (let i = 0; i < c.length; i++) counts[i] += c[i];
|
|
122
|
+
}
|
|
123
|
+
return boundaries != null && counts != null ? {
|
|
124
|
+
boundaries,
|
|
125
|
+
counts
|
|
126
|
+
} : null;
|
|
127
|
+
}
|
|
128
|
+
function diffHistogram(baseline, end) {
|
|
129
|
+
if (end == null) return null;
|
|
130
|
+
if (baseline == null) return end;
|
|
131
|
+
if (!histogramsCompatible(baseline, end)) return null;
|
|
132
|
+
const counts = end.counts.map((count, i) => Math.max(0, count - baseline.counts[i]));
|
|
133
|
+
return {
|
|
134
|
+
boundaries: end.boundaries,
|
|
135
|
+
counts
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function histogramsCompatible(a, b) {
|
|
139
|
+
return a.boundaries.length === b.boundaries.length && a.counts.length === b.counts.length && a.boundaries.every((boundary, i) => boundary === b.boundaries[i]);
|
|
140
|
+
}
|
|
141
|
+
function histogramPercentile(histogram, p) {
|
|
142
|
+
const { boundaries, counts } = histogram;
|
|
143
|
+
const total = counts.reduce((sum, n) => sum + n, 0);
|
|
144
|
+
if (total === 0) return 0;
|
|
145
|
+
const target = Math.ceil(p / 100 * total);
|
|
146
|
+
let accumulated = 0;
|
|
147
|
+
for (let i = 0; i < counts.length; i++) {
|
|
148
|
+
accumulated += counts[i];
|
|
149
|
+
if (accumulated >= target) return i < boundaries.length ? boundaries[i] : boundaries[boundaries.length - 1] ?? 0;
|
|
150
|
+
}
|
|
151
|
+
return boundaries[boundaries.length - 1] ?? 0;
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { diffSnapshots, fetchServerSnapshot, snapshotToMetrics };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/render/format.ts
|
|
3
|
+
const OP_SYMBOLS = {
|
|
4
|
+
lt: "<",
|
|
5
|
+
lte: "<=",
|
|
6
|
+
gt: ">",
|
|
7
|
+
gte: ">=",
|
|
8
|
+
eq: "=="
|
|
9
|
+
};
|
|
10
|
+
/** Returns the symbolic form of a comparison operator. */
|
|
11
|
+
function opSymbol(op) {
|
|
12
|
+
return OP_SYMBOLS[op];
|
|
13
|
+
}
|
|
14
|
+
/** Formats a number with grouping and at most three fractional digits. */
|
|
15
|
+
function formatNumber(value) {
|
|
16
|
+
if (!Number.isFinite(value)) return String(value);
|
|
17
|
+
return (Math.round(value * 1e3) / 1e3).toLocaleString("en-US", { maximumFractionDigits: 3 });
|
|
18
|
+
}
|
|
19
|
+
/** Formats a ratio (0..1) as a percentage with at most two fractional digits. */
|
|
20
|
+
function formatPercent(ratio) {
|
|
21
|
+
return `${(Math.round(ratio * 1e6) / 1e4).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Formats a normalized threshold back into its human-friendly unit.
|
|
25
|
+
* @param threshold The normalized numeric threshold.
|
|
26
|
+
* @param unit The threshold's unit (`"ms"`, `"%"`, `"/s"`, or `null`).
|
|
27
|
+
*/
|
|
28
|
+
function formatThreshold(threshold, unit) {
|
|
29
|
+
switch (unit) {
|
|
30
|
+
case "%": return formatPercent(threshold);
|
|
31
|
+
case "ms": return `${formatNumber(threshold)}ms`;
|
|
32
|
+
case "/s": return `${formatNumber(threshold)}/s`;
|
|
33
|
+
default: return formatNumber(threshold);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Formats a measured value using the unit of the assertion it is compared to.
|
|
38
|
+
* @param actual The measured value, or `null` if unmeasured.
|
|
39
|
+
* @param unit The assertion's unit.
|
|
40
|
+
*/
|
|
41
|
+
function formatActual(actual, unit) {
|
|
42
|
+
if (actual == null) return "n/a";
|
|
43
|
+
return formatThreshold(actual, unit);
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { formatActual, formatNumber, formatPercent, formatThreshold, opSymbol };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { renderJson } from "./json.js";
|
|
3
|
+
import { renderMarkdown } from "./markdown.js";
|
|
4
|
+
import { renderText } from "./text.js";
|
|
5
|
+
//#region src/bench/render/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* Renders a report in the requested format.
|
|
8
|
+
* @param report The report to render.
|
|
9
|
+
* @param format The output format.
|
|
10
|
+
* @returns The rendered text.
|
|
11
|
+
*/
|
|
12
|
+
function renderReport(report, format) {
|
|
13
|
+
switch (format) {
|
|
14
|
+
case "json": return renderJson(report);
|
|
15
|
+
case "markdown": return renderMarkdown(report);
|
|
16
|
+
case "text": return renderText(report);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { renderReport };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/render/json.ts
|
|
3
|
+
/**
|
|
4
|
+
* Renders a report as pretty-printed canonical JSON.
|
|
5
|
+
* @param report The report to render.
|
|
6
|
+
* @returns The JSON text, with a trailing newline.
|
|
7
|
+
*/
|
|
8
|
+
function renderJson(report) {
|
|
9
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { renderJson };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { metricDisplayUnit } from "../result/expect/metrics.js";
|
|
3
|
+
import { formatActual, formatNumber, formatPercent, formatThreshold, opSymbol } from "./format.js";
|
|
4
|
+
//#region src/bench/render/markdown.ts
|
|
5
|
+
/**
|
|
6
|
+
* The Markdown renderer, suited to a GitHub Actions job summary or a PR
|
|
7
|
+
* comment. It is derived from the same report model as the text and JSON
|
|
8
|
+
* forms.
|
|
9
|
+
* @since 2.3.0
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Renders a report as Markdown.
|
|
14
|
+
* @param report The report to render.
|
|
15
|
+
* @returns The Markdown text.
|
|
16
|
+
*/
|
|
17
|
+
function renderMarkdown(report) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push("# Fedify benchmark report", "");
|
|
20
|
+
lines.push(`**Result:** ${report.passed ? "✅ PASS" : "❌ FAIL"}`, "");
|
|
21
|
+
lines.push(`- **Target:** \`${report.target.url}\` (${report.target.statsAvailable ? "stats available" : "no stats"})`);
|
|
22
|
+
lines.push(`- **Environment:** ${report.environment.runtime} ${report.environment.runtimeVersion}, ${report.environment.os}, ${report.environment.cpuCount} CPUs`);
|
|
23
|
+
lines.push(`- **Config:** \`${report.suite.configHash}\``, "");
|
|
24
|
+
for (const scenario of report.scenarios) lines.push(...renderScenario(scenario), "");
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
function renderScenario(scenario) {
|
|
28
|
+
const lines = [];
|
|
29
|
+
lines.push(`## ${scenario.name} (${scenario.type}) ${scenario.passed ? "✅" : "❌"}`, "");
|
|
30
|
+
lines.push("| Metric | Value |", "| --- | --- |");
|
|
31
|
+
const r = scenario.requests;
|
|
32
|
+
lines.push(`| Requests | ${formatNumber(r.total)} |`);
|
|
33
|
+
lines.push(`| Success rate | ${formatPercent(r.successRate)} |`);
|
|
34
|
+
lines.push(`| Throughput | ${formatNumber(scenario.throughputPerSec)}/s |`);
|
|
35
|
+
const l = scenario.client.latencyMs;
|
|
36
|
+
lines.push(`| Latency p50 | ${formatNumber(l.p50)}ms |`);
|
|
37
|
+
lines.push(`| Latency p95 | ${formatNumber(l.p95)}ms |`);
|
|
38
|
+
lines.push(`| Latency p99 | ${formatNumber(l.p99)}ms |`);
|
|
39
|
+
const sig = scenario.server?.signatureVerificationMs?.overall;
|
|
40
|
+
if (sig?.p95 != null) lines.push(`| Signature verification p95 (server) | ${formatNumber(sig.p95)}ms |`);
|
|
41
|
+
const queue = scenario.server?.queue;
|
|
42
|
+
if (queue?.drainMs?.p95 != null) lines.push(`| Queue drain p95 (server) | ${formatNumber(queue.drainMs.p95)}ms |`);
|
|
43
|
+
if (queue?.depthMax != null) lines.push(`| Queue depth max (server) | ${formatNumber(queue.depthMax)} |`);
|
|
44
|
+
if (scenario.errors.length > 0) {
|
|
45
|
+
lines.push("", "| Error | Count |", "| --- | --- |");
|
|
46
|
+
for (const error of scenario.errors) {
|
|
47
|
+
const code = error.status == null ? error.kind : String(error.status);
|
|
48
|
+
lines.push(`| ${code} ${error.reason} | ${formatNumber(error.count)} |`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (scenario.expectations.length > 0) {
|
|
52
|
+
lines.push("", "| Expectation | Actual | Result |", "| --- | --- | --- |");
|
|
53
|
+
for (const e of scenario.expectations) {
|
|
54
|
+
const tag = e.pass ? "✅" : e.severity === "warn" ? "⚠️" : "❌";
|
|
55
|
+
const unit = metricDisplayUnit(e.metric);
|
|
56
|
+
lines.push(`| \`${e.metric} ${opSymbol(e.op)} ${formatThreshold(e.threshold, e.unit ?? unit)}\` | ${formatActual(e.actual, unit)} | ${tag} |`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { renderMarkdown };
|