latency-test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. // REQUIREMENTS:
  2. // - requires the env vars HTTP_PORT and AUTH_TOKENS (at least 1 token to bypass rate limiting) to be set
  3. // - requires geniURL to run in a different process (or using the command pnpm run latency-test)
  4. //
  5. // NOTES:
  6. // - requests are sent sequentially on purpose to avoid rate limiting on genius.com's side
  7. // - change settings in the `settings` object
  8. // - view previous latency test reports in the `reports` directory (
  9. import { mkdir, stat, writeFile } from "node:fs/promises";
  10. import { dirname, join } from "node:path";
  11. import _axios from "axios";
  12. import percentile from "percentile";
  13. import k from "kleur";
  14. import { type Stringifiable } from "svcorelib";
  15. import "dotenv/config";
  16. import queries from "./latency-test-queries.json" with { type: "json" };
  17. import { fileURLToPath } from "node:url";
  18. const settings = {
  19. /** Amount of requests to send in total. */
  20. amount: 250,
  21. /** Base URL to send requests to. `{{QUERY}}` will be replaced with a random query from the `latency-test-queries.json` file. */
  22. url: `http://127.0.0.1:${process.env.HTTP_PORT ?? 8074}/v2/search/top?q={{QUERY}}`,
  23. /** Whether to log all requests to the console (true) or just in increments of `infoLogFrequency` (false). */
  24. logAllRequests: true,
  25. /** Amount of requests to send before logging an info message. */
  26. infoLogFrequency: 10,
  27. /** Maximum timeout for each request in milliseconds. */
  28. maxTimeout: 20_000,
  29. } as const;
  30. const reportsDirPath = join(dirname(fileURLToPath(import.meta.url)), "latency-test-reports");
  31. const axios = _axios.create({ timeout: settings.maxTimeout ?? 20_000 });
  32. type LatencyTestReport = {
  33. /** Local date and time string when the latency test was started. */
  34. localStartDateTime: string;
  35. /** Local date and time string when the latency test finished. */
  36. localFinishDateTime: string;
  37. /** Total time the latency test took in seconds. */
  38. totalTime: number;
  39. /** Settings used for the latency test. */
  40. settings: typeof settings;
  41. /** Calculated times in milliseconds. */
  42. times: Record<
  43. "min" | "avg" | "max" | "5th%" | "10th%" | "25th%" | "80th%" | "90th%" | "95th%" | "97th%" | "98th%" | "99th%",
  44. number
  45. >;
  46. };
  47. async function run() {
  48. console.log(k.green(`\n>>> Starting latency test on ${settings.amount} sequential requests${settings.amount >= 50 ? k.yellow(" - this could take a while!") : ""}\n`));
  49. const testStartTs = Date.now();
  50. const times = [] as number[];
  51. let successRequests = 0;
  52. for(let i = 0; i < settings.amount; i++) {
  53. !settings.logAllRequests && i === 0 && console.log(`> Sent 0 of ${settings.amount} requests`);
  54. const reqStartTs = Date.now();
  55. try {
  56. const url = encodeURI(settings.url.replace("{{QUERY}}", queries[Math.floor(Math.random() * queries.length)]));
  57. settings.logAllRequests && console.log(` ${String(i + 1).padStart(digitCount(settings.amount))}.`, url);
  58. await axios.get(url, {
  59. headers: {
  60. "Cache-Control": "no-cache",
  61. Authorization: `Bearer ${process.env.AUTH_TOKENS!.split(",")[0]}`,
  62. },
  63. });
  64. successRequests++;
  65. }
  66. catch(e) {
  67. console.error(k.red("\n>> Failed to send request:"), e);
  68. console.error();
  69. }
  70. finally {
  71. times.push(Date.now() - reqStartTs);
  72. const elapsedStr = `${((Date.now() - testStartTs) / 1000).toFixed(1)}s elapsed`;
  73. if(settings.logAllRequests && i % settings.infoLogFrequency === settings.infoLogFrequency - 1 && i > 0 && i !== settings.amount - 1) {
  74. const spc = `${" ".repeat(digitCount(settings.amount))} `,
  75. perc = mapRange(i + 1, 0, settings.amount, 0, 100).toFixed(0);
  76. console.log(`${spc}> ${elapsedStr}, sent ${i + 1} of ${settings.amount} requests (${perc}%)`);
  77. }
  78. else if(i % settings.infoLogFrequency === settings.infoLogFrequency - 1 && i > 0 && i !== settings.amount - 1)
  79. console.log(`> Sent ${i + 1} of ${settings.amount} requests (${elapsedStr})`);
  80. }
  81. }
  82. const min = times.reduce((a, c) => Math.min(a, c), Infinity).toFixed(0);
  83. const avg = (times.reduce((a, c) => a + c, 0) / times.length).toFixed(0);
  84. const max = times.reduce((a, c) => Math.max(a, c), 0).toFixed(0);
  85. const getPerc = (perc: number, times: number[]) => {
  86. const res = percentile(perc, times);
  87. if(Array.isArray(res)) return res[0];
  88. return res;
  89. };
  90. const reportTimes = {} as Partial<LatencyTestReport["times"]>;
  91. const logVal = (label: string, value: Stringifiable, kleurFunc?: (str: string) => void) => {
  92. const valStr = `${label}:\t${String(value).padStart(4, " ")} ms`;
  93. reportTimes[label as keyof LatencyTestReport["times"]] = Number(value);
  94. console.log(kleurFunc ? kleurFunc(valStr) : valStr);
  95. }
  96. const testFinishTs = Date.now();
  97. const totalTime = Number(((testFinishTs - testStartTs) / 1000).toFixed(2));
  98. console.log(`\n>>> Latency test finished sending ${successRequests} successful requests after ${totalTime}s - Results:`);
  99. console.log();
  100. logVal("5th%", getPerc(5, times), k.gray);
  101. logVal("10th%", getPerc(10, times), k.gray);
  102. logVal("25th%", getPerc(25, times), k.gray);
  103. logVal("80th%", getPerc(80, times), k.gray);
  104. logVal("90th%", getPerc(90, times));
  105. logVal("95th%", getPerc(95, times));
  106. logVal("97th%", getPerc(97, times), k.bold);
  107. logVal("98th%", getPerc(98, times));
  108. logVal("99th%", getPerc(99, times));
  109. console.log();
  110. logVal("min", min);
  111. logVal("avg", avg, k.bold);
  112. logVal("max", max);
  113. console.log();
  114. const getFormattedDate = (timestamp: number) => Intl.DateTimeFormat(Intl.DateTimeFormat().resolvedOptions().locale, {
  115. dateStyle: "short",
  116. timeStyle: "long",
  117. }).format(new Date(timestamp));
  118. const localStartDateTime = getFormattedDate(testStartTs);
  119. const localFinishDateTime = getFormattedDate(testFinishTs);
  120. const reportData: LatencyTestReport = {
  121. localStartDateTime,
  122. localFinishDateTime,
  123. totalTime,
  124. settings,
  125. times: reportTimes as LatencyTestReport["times"],
  126. };
  127. const reportPath = join(reportsDirPath, `report_${new Date(testFinishTs).toISOString().replace(/[:/.]/g, "-").replace(/T/g, "_").replace(/-\d+Z/, "")}.json`);
  128. try {
  129. try {
  130. await stat(reportsDirPath);
  131. }
  132. catch {
  133. await mkdir(reportsDirPath);
  134. }
  135. await writeFile(reportPath, JSON.stringify(reportData, null, 2));
  136. console.log(k.gray(`Wrote report to file at '${reportPath}'\n`));
  137. }
  138. catch(e) {
  139. console.error(k.red(`Failed to write latency test report to file at '${reportPath}':`), e);
  140. }
  141. return setImmediate(() => process.exit(0));
  142. }
  143. /** Returns the amount of digits in a number. */
  144. function digitCount(num: number): number {
  145. if(num === 0) return 1;
  146. return Math.floor(Math.log10(Math.abs(num)) + 1);
  147. }
  148. /**
  149. * Transforms the value parameter from the numerical range `range1min` to `range1max` to the numerical range `range2min` to `range2max`
  150. * For example, you can map the value 2 in the range of 0-5 to the range of 0-10 and you'd get a 4 as a result.
  151. */
  152. function mapRange(value: number, range1min: number, range1max: number, range2min: number, range2max: number): number;
  153. /**
  154. * Transforms the value parameter from the numerical range `0` to `range1max` to the numerical range `0` to `range2max`
  155. * For example, you can map the value 2 in the range of 0-5 to the range of 0-10 and you'd get a 4 as a result.
  156. */
  157. function mapRange(value: number, range1max: number, range2max: number): number;
  158. function mapRange(value: number, range1min: number, range1max: number, range2min?: number, range2max?: number): number {
  159. // overload
  160. if(typeof range2min === "undefined" || range2max === undefined) {
  161. range2max = range1max;
  162. range2min = 0;
  163. range1max = range1min;
  164. range1min = 0;
  165. }
  166. if(Number(range1min) === 0.0 && Number(range2min) === 0.0)
  167. return value * (range2max / range1max);
  168. return (value - range1min) * ((range2max - range2min) / (range1max - range1min)) + range2min;
  169. }
  170. run();