409 lines
14 KiB
C
409 lines
14 KiB
C
// retoor <retoor@molodetz.nl>
|
|||
|
|
|
||
|
|
#include "graphs.h"
|
||
|
|
|
||
|
|
#include <math.h>
|
||
|
|
#include <stdio.h>
|
||
|
|
#include <stdlib.h>
|
||
|
|
#include <string.h>
|
||
|
|
#include <sys/ioctl.h>
|
||
|
|
#include <unistd.h>
|
||
|
|
|
||
|
|
/* Layout ------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
#define AXIS_W 7 /* five columns of y-label, a space, the axis glyph */
|
||
|
|
#define LABEL_MAX 12
|
||
|
|
#define MAX_BARS 48
|
||
|
|
#define PLOT_H 10 /* labelled every other row, so gridlines land round */
|
||
|
|
|
||
|
|
/* Colour language: one accent for data, amber for the tail, red for failure.
|
||
|
|
Nothing else is coloured. */
|
||
|
|
#define C_RESET "\033[0m"
|
||
|
|
#define C_BOLD "\033[1m"
|
||
|
|
#define C_DIM "\033[2m"
|
||
|
|
#define C_ACCENT "\033[36m"
|
||
|
|
#define C_TAIL "\033[33m"
|
||
|
|
#define C_FAIL "\033[31m"
|
||
|
|
|
||
|
|
static int color_enabled = 1;
|
||
|
|
|
||
|
|
void graph_set_color(int enabled) { color_enabled = enabled ? 1 : 0; }
|
||
|
|
|
||
|
|
static const char *sgr(const char *code) { return color_enabled ? code : ""; }
|
||
|
|
|
||
|
|
int graph_width(void) {
|
||
|
|
struct winsize ws;
|
||
|
|
int w = 78;
|
||
|
|
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
|
||
|
|
w = ws.ws_col;
|
||
|
|
}
|
||
|
|
if (w > 96) w = 96;
|
||
|
|
if (w < 56) w = 56;
|
||
|
|
return w;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* How many bars fit while keeping each one at least min_cell columns wide. */
|
||
|
|
static int bar_capacity(int min_cell) {
|
||
|
|
int n = (graph_width() - AXIS_W) / min_cell;
|
||
|
|
if (n > MAX_BARS) n = MAX_BARS;
|
||
|
|
if (n < 1) n = 1;
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Numbers ----------------------------------------------------------------- */
|
||
|
|
|
||
|
|
/* Round up to the next 1/2/2.5/5/10 x 10^n so the y-axis reads cleanly. */
|
||
|
|
static double nice_ceil(double v) {
|
||
|
|
if (!(v > 0.0)) return 1.0;
|
||
|
|
double exp10 = pow(10.0, floor(log10(v)));
|
||
|
|
double m = v / exp10;
|
||
|
|
double nice = (m <= 1.0) ? 1.0 : (m <= 2.0) ? 2.0 : (m <= 2.5) ? 2.5
|
||
|
|
: (m <= 5.0) ? 5.0 : 10.0;
|
||
|
|
return nice * exp10;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Seconds on an x-axis: just enough precision for the length of the run. */
|
||
|
|
static void fmt_seconds(char *buf, size_t size, double v, double total) {
|
||
|
|
if (total < 2.0) snprintf(buf, size, "%.2f", v);
|
||
|
|
else if (total < 20.0) snprintf(buf, size, "%.1f", v);
|
||
|
|
else snprintf(buf, size, "%.0f", v);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void fmt_num(char *buf, size_t size, double v) {
|
||
|
|
double a = fabs(v);
|
||
|
|
if (a >= 1000.0) snprintf(buf, size, "%.0f", v);
|
||
|
|
else if (a >= 100.0) snprintf(buf, size, "%.0f", v);
|
||
|
|
else if (a >= 10.0) snprintf(buf, size, "%.1f", v);
|
||
|
|
else if (a >= 1.0) snprintf(buf, size, "%.2f", v);
|
||
|
|
else snprintf(buf, size, "%.3f", v);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Printed width of a string: skips ANSI escapes and UTF-8 continuation bytes. */
|
||
|
|
static int visible_len(const char *str) {
|
||
|
|
int n = 0;
|
||
|
|
for (const unsigned char *p = (const unsigned char *)str; *p; p++) {
|
||
|
|
if (*p == 0x1b) {
|
||
|
|
while (*p && *p != 'm') p++;
|
||
|
|
if (!*p) break;
|
||
|
|
} else if ((*p & 0xC0) != 0x80) {
|
||
|
|
n++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Axis labels: only as many decimals as the gridline step actually needs. */
|
||
|
|
static void fmt_axis(char *buf, size_t size, double v, double step) {
|
||
|
|
int decimals = 0;
|
||
|
|
if (step < 1.0) {
|
||
|
|
decimals = (step * 10.0 == floor(step * 10.0)) ? 1 : 2;
|
||
|
|
} else if (step < 10.0 && step != floor(step)) {
|
||
|
|
decimals = 1;
|
||
|
|
}
|
||
|
|
snprintf(buf, size, "%.*f", decimals, v);
|
||
|
|
}
|
||
|
|
|
||
|
|
static int cmp_double(const void *a, const void *b) {
|
||
|
|
double x = *(const double *)a, y = *(const double *)b;
|
||
|
|
return (x < y) ? -1 : (x > y) ? 1 : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Renderer ---------------------------------------------------------------- */
|
||
|
|
|
||
|
|
void graph_section(const char *title) {
|
||
|
|
int width = graph_width();
|
||
|
|
int len = (int)strlen(title);
|
||
|
|
printf("\n%s%s%s ", sgr(C_BOLD), title, sgr(C_RESET));
|
||
|
|
printf("%s", sgr(C_DIM));
|
||
|
|
for (int i = len + 1; i < width; i++) printf("─");
|
||
|
|
printf("%s\n", sgr(C_RESET));
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* The single drawing primitive. `base` is the main series; `top` (optional)
|
||
|
|
* stacks on it and is drawn in red; `emph` (optional) flags columns to draw in
|
||
|
|
* the tail colour. Bars use half blocks so a column reads to half a row.
|
||
|
|
*/
|
||
|
|
static void render_bars(const char *title, const char *unit,
|
||
|
|
const double *base, const double *top,
|
||
|
|
const unsigned char *emph,
|
||
|
|
int count, const char labels[][LABEL_MAX],
|
||
|
|
const char *footer) {
|
||
|
|
if (count <= 0) return;
|
||
|
|
|
||
|
|
int width = graph_width();
|
||
|
|
int plot_w = width - AXIS_W;
|
||
|
|
int cell = plot_w / count;
|
||
|
|
if (cell < 1) cell = 1;
|
||
|
|
int bar_w = (cell > 1) ? cell - 1 : 1;
|
||
|
|
plot_w = cell * count;
|
||
|
|
|
||
|
|
double ymax = 0.0;
|
||
|
|
for (int i = 0; i < count; i++) {
|
||
|
|
double v = base[i] + (top ? top[i] : 0.0);
|
||
|
|
if (v > ymax) ymax = v;
|
||
|
|
}
|
||
|
|
ymax = nice_ceil(ymax);
|
||
|
|
|
||
|
|
/* Title left, unit right, on one line. */
|
||
|
|
int tlen = visible_len(title);
|
||
|
|
int ulen = unit ? visible_len(unit) : 0;
|
||
|
|
int pad = width - tlen - ulen;
|
||
|
|
if (pad < 1) pad = 1;
|
||
|
|
printf("\n%s%s%s%*s%s%s%s\n", sgr(C_BOLD), title, sgr(C_RESET),
|
||
|
|
pad, "", sgr(C_DIM), unit ? unit : "", sgr(C_RESET));
|
||
|
|
|
||
|
|
|
||
|
|
for (int row = PLOT_H - 1; row >= 0; row--) {
|
||
|
|
char ylab[LABEL_MAX] = "";
|
||
|
|
if ((PLOT_H - 1 - row) % 2 == 0) {
|
||
|
|
fmt_axis(ylab, sizeof ylab, ymax * (row + 1) / PLOT_H, ymax * 2.0 / PLOT_H);
|
||
|
|
}
|
||
|
|
printf("%s%5s ┤%s", sgr(C_DIM), ylab, sgr(C_RESET));
|
||
|
|
|
||
|
|
/* Stop at the last column that has ink, so rows carry no trailing blanks. */
|
||
|
|
int last = -1;
|
||
|
|
for (int i = 0; i < count; i++) {
|
||
|
|
if ((base[i] + (top ? top[i] : 0.0)) / ymax * PLOT_H - row >= 0.25) last = i;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (int i = 0; i <= last; i++) {
|
||
|
|
double filled = (base[i] + (top ? top[i] : 0.0)) / ymax * PLOT_H - row;
|
||
|
|
double solid = base[i] / ymax * PLOT_H - row;
|
||
|
|
const char *glyph = (filled >= 0.75) ? "█"
|
||
|
|
: (filled >= 0.25) ? "▄" : NULL;
|
||
|
|
if (glyph) {
|
||
|
|
const char *color = (solid > 0.0)
|
||
|
|
? ((emph && emph[i]) ? sgr(C_TAIL) : sgr(C_ACCENT))
|
||
|
|
: sgr(C_FAIL);
|
||
|
|
printf("%s", color);
|
||
|
|
for (int c = 0; c < bar_w; c++) printf("%s", glyph);
|
||
|
|
printf("%s", sgr(C_RESET));
|
||
|
|
} else {
|
||
|
|
for (int c = 0; c < bar_w; c++) putchar(' ');
|
||
|
|
}
|
||
|
|
if (i < last) {
|
||
|
|
for (int c = bar_w; c < cell; c++) putchar(' ');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
putchar('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
printf("%s%5s └", sgr(C_DIM), "0");
|
||
|
|
for (int i = 0; i < plot_w; i++) printf("─");
|
||
|
|
printf("%s\n", sgr(C_RESET));
|
||
|
|
|
||
|
|
/* X labels, centred under their bar and dropped where they would collide. */
|
||
|
|
if (labels) {
|
||
|
|
char line[256];
|
||
|
|
int n = (plot_w < (int)sizeof(line) - 1) ? plot_w : (int)sizeof(line) - 1;
|
||
|
|
memset(line, ' ', (size_t)n);
|
||
|
|
line[n] = '\0';
|
||
|
|
int last_end = -1;
|
||
|
|
for (int i = 0; i < count; i++) {
|
||
|
|
int len = (int)strlen(labels[i]);
|
||
|
|
if (len == 0 || len > n) continue;
|
||
|
|
int start = i * cell + bar_w / 2 - len / 2;
|
||
|
|
if (start < 0) start = 0;
|
||
|
|
if (start + len > n) start = n - len;
|
||
|
|
if (start <= last_end) continue;
|
||
|
|
memcpy(line + start, labels[i], (size_t)len);
|
||
|
|
last_end = start + len;
|
||
|
|
}
|
||
|
|
while (n > 0 && line[n - 1] == ' ') line[--n] = '\0';
|
||
|
|
printf("%s%*s%s%s\n", sgr(C_DIM), AXIS_W, "", line, sgr(C_RESET));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* The footer may hold several lines; each is indented under the plot. */
|
||
|
|
for (const char *p = footer; p && *p; ) {
|
||
|
|
const char *nl = strchr(p, '\n');
|
||
|
|
int len = nl ? (int)(nl - p) : (int)strlen(p);
|
||
|
|
printf("%s%*s%.*s%s\n", sgr(C_DIM), AXIS_W, "", len, p, sgr(C_RESET));
|
||
|
|
p = nl ? nl + 1 : p + len;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Charts ------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
void graph_latency_histogram(const double *durations_ms, int count) {
|
||
|
|
if (count <= 0) return;
|
||
|
|
|
||
|
|
int buckets = bar_capacity(5);
|
||
|
|
if (buckets > 14) buckets = 14;
|
||
|
|
|
||
|
|
double *sorted = malloc(sizeof(double) * (size_t)count);
|
||
|
|
if (!sorted) return;
|
||
|
|
memcpy(sorted, durations_ms, sizeof(double) * (size_t)count);
|
||
|
|
qsort(sorted, (size_t)count, sizeof(double), cmp_double);
|
||
|
|
|
||
|
|
double lo = sorted[0];
|
||
|
|
double hi = sorted[count - 1];
|
||
|
|
double sum = 0.0;
|
||
|
|
for (int i = 0; i < count; i++) sum += durations_ms[i];
|
||
|
|
double mean = sum / count;
|
||
|
|
double var = 0.0;
|
||
|
|
for (int i = 0; i < count; i++) var += (durations_ms[i] - mean) * (durations_ms[i] - mean);
|
||
|
|
double sd = (count > 1) ? sqrt(var / (count - 1)) : 0.0;
|
||
|
|
|
||
|
|
/* A long tail would squash every column into the first bucket. When the
|
||
|
|
slowest request dwarfs p95, clip the axis there and say so in the
|
||
|
|
footer rather than quietly dropping the outliers. */
|
||
|
|
int p95_idx = (int)(count * 0.95) - 1;
|
||
|
|
if (p95_idx < 0) p95_idx = 0;
|
||
|
|
double p95 = sorted[p95_idx];
|
||
|
|
double axis_hi = hi;
|
||
|
|
int clipped = 0;
|
||
|
|
if (hi > p95 * 3.0 && p95 > lo) {
|
||
|
|
axis_hi = p95;
|
||
|
|
for (int i = count - 1; i >= 0 && sorted[i] > axis_hi; i--) clipped++;
|
||
|
|
}
|
||
|
|
free(sorted);
|
||
|
|
|
||
|
|
/* A single distinct value has no distribution; show it as one column. */
|
||
|
|
double span = axis_hi - lo;
|
||
|
|
if (span <= 0.0) {
|
||
|
|
span = 1.0;
|
||
|
|
buckets = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
double values[MAX_BARS] = {0};
|
||
|
|
char labels[MAX_BARS][LABEL_MAX];
|
||
|
|
for (int i = 0; i < count; i++) {
|
||
|
|
if (durations_ms[i] > axis_hi) continue;
|
||
|
|
int b = (int)((durations_ms[i] - lo) / span * buckets);
|
||
|
|
if (b < 0) b = 0;
|
||
|
|
if (b >= buckets) b = buckets - 1;
|
||
|
|
values[b] += 1.0;
|
||
|
|
}
|
||
|
|
for (int b = 0; b < buckets; b++) {
|
||
|
|
fmt_num(labels[b], LABEL_MAX, lo + span * b / buckets);
|
||
|
|
}
|
||
|
|
|
||
|
|
char footer[160];
|
||
|
|
int written = snprintf(footer, sizeof footer,
|
||
|
|
"%d samples mean %.1f ms sd %.1f ms range %.0f-%.0f ms",
|
||
|
|
count, mean, sd, lo, hi);
|
||
|
|
if (clipped > 0 && written > 0 && written < (int)sizeof footer) {
|
||
|
|
snprintf(footer + written, sizeof footer - (size_t)written,
|
||
|
|
"\naxis clipped at p95 %.0f ms; %d slower requests are off the chart",
|
||
|
|
axis_hi, clipped);
|
||
|
|
}
|
||
|
|
|
||
|
|
render_bars("Response time distribution", "y: requests x: ms",
|
||
|
|
values, NULL, NULL, buckets, labels, footer);
|
||
|
|
}
|
||
|
|
|
||
|
|
void graph_throughput(const double *finish_ms, const int *failed, int count,
|
||
|
|
double total_seconds) {
|
||
|
|
if (count <= 0 || total_seconds <= 0.0) return;
|
||
|
|
|
||
|
|
int buckets = bar_capacity(3);
|
||
|
|
if (buckets > 30) buckets = 30;
|
||
|
|
|
||
|
|
double slice = total_seconds / buckets;
|
||
|
|
if (slice <= 0.0) return;
|
||
|
|
|
||
|
|
double ok[MAX_BARS] = {0}, bad[MAX_BARS] = {0};
|
||
|
|
char labels[MAX_BARS][LABEL_MAX];
|
||
|
|
int failures = 0;
|
||
|
|
|
||
|
|
for (int i = 0; i < count; i++) {
|
||
|
|
int b = (int)(finish_ms[i] / 1000.0 / slice);
|
||
|
|
if (b < 0) b = 0;
|
||
|
|
if (b >= buckets) b = buckets - 1;
|
||
|
|
if (failed[i]) { bad[b] += 1.0; failures++; }
|
||
|
|
else { ok[b] += 1.0; }
|
||
|
|
}
|
||
|
|
for (int b = 0; b < buckets; b++) {
|
||
|
|
ok[b] /= slice;
|
||
|
|
bad[b] /= slice;
|
||
|
|
fmt_seconds(labels[b], LABEL_MAX, b * slice, total_seconds);
|
||
|
|
}
|
||
|
|
|
||
|
|
char footer[128];
|
||
|
|
snprintf(footer, sizeof footer,
|
||
|
|
"%.1f req/s mean %d ok %d failed (%.2f%%)",
|
||
|
|
count / total_seconds, count - failures, failures,
|
||
|
|
(double)failures / count * 100.0);
|
||
|
|
|
||
|
|
char unit[128];
|
||
|
|
snprintf(unit, sizeof unit, "y: req/s x: seconds %s█%s ok %s█%s failed",
|
||
|
|
sgr(C_ACCENT), sgr(C_DIM), sgr(C_FAIL), sgr(C_DIM));
|
||
|
|
|
||
|
|
render_bars("Throughput over time", unit, ok, bad, NULL, buckets, labels, footer);
|
||
|
|
}
|
||
|
|
|
||
|
|
void graph_latency_over_time(const double *finish_ms, const double *durations_ms,
|
||
|
|
const int *failed, int count, double total_seconds) {
|
||
|
|
if (count <= 0 || total_seconds <= 0.0) return;
|
||
|
|
|
||
|
|
int buckets = bar_capacity(3);
|
||
|
|
if (buckets > 30) buckets = 30;
|
||
|
|
|
||
|
|
double slice = total_seconds / buckets;
|
||
|
|
if (slice <= 0.0) return;
|
||
|
|
|
||
|
|
double sum[MAX_BARS] = {0}, values[MAX_BARS] = {0};
|
||
|
|
int n[MAX_BARS] = {0};
|
||
|
|
char labels[MAX_BARS][LABEL_MAX];
|
||
|
|
|
||
|
|
for (int i = 0; i < count; i++) {
|
||
|
|
if (failed[i]) continue;
|
||
|
|
int b = (int)(finish_ms[i] / 1000.0 / slice);
|
||
|
|
if (b < 0) b = 0;
|
||
|
|
if (b >= buckets) b = buckets - 1;
|
||
|
|
sum[b] += durations_ms[i];
|
||
|
|
n[b]++;
|
||
|
|
}
|
||
|
|
double peak = 0.0;
|
||
|
|
for (int b = 0; b < buckets; b++) {
|
||
|
|
values[b] = n[b] ? sum[b] / n[b] : 0.0;
|
||
|
|
if (values[b] > peak) peak = values[b];
|
||
|
|
fmt_seconds(labels[b], LABEL_MAX, b * slice, total_seconds);
|
||
|
|
}
|
||
|
|
|
||
|
|
char footer[128];
|
||
|
|
snprintf(footer, sizeof footer, "mean per %.2f s slice peak %.0f ms", slice, peak);
|
||
|
|
|
||
|
|
render_bars("Response time over time", "y: ms x: seconds", values, NULL, NULL,
|
||
|
|
buckets, labels, footer);
|
||
|
|
}
|
||
|
|
|
||
|
|
void graph_percentiles(const double *durations_ms, int count) {
|
||
|
|
if (count <= 0) return;
|
||
|
|
|
||
|
|
static const int points[] = {50, 66, 75, 80, 90, 95, 98, 99, 100};
|
||
|
|
const int n_points = (int)(sizeof points / sizeof points[0]);
|
||
|
|
|
||
|
|
double *sorted = malloc(sizeof(double) * (size_t)count);
|
||
|
|
if (!sorted) return;
|
||
|
|
memcpy(sorted, durations_ms, sizeof(double) * (size_t)count);
|
||
|
|
qsort(sorted, (size_t)count, sizeof(double), cmp_double);
|
||
|
|
|
||
|
|
double values[MAX_BARS];
|
||
|
|
unsigned char emph[MAX_BARS];
|
||
|
|
char labels[MAX_BARS][LABEL_MAX];
|
||
|
|
|
||
|
|
for (int i = 0; i < n_points; i++) {
|
||
|
|
int idx = (int)(count * points[i] / 100.0) - 1;
|
||
|
|
if (idx < 0) idx = 0;
|
||
|
|
if (idx >= count) idx = count - 1;
|
||
|
|
values[i] = sorted[idx];
|
||
|
|
emph[i] = (points[i] >= 95);
|
||
|
|
snprintf(labels[i], LABEL_MAX, "p%d", points[i]);
|
||
|
|
}
|
||
|
|
|
||
|
|
char footer[128];
|
||
|
|
snprintf(footer, sizeof footer,
|
||
|
|
"median %.0f ms p95 %.0f ms p99 %.0f ms max %.0f ms",
|
||
|
|
values[0], values[5], values[7], values[8]);
|
||
|
|
|
||
|
|
char unit[128];
|
||
|
|
snprintf(unit, sizeof unit, "y: ms %s█%s tail (p95+)", sgr(C_TAIL), sgr(C_DIM));
|
||
|
|
|
||
|
|
render_bars("Response time percentiles", unit, values, NULL, emph, n_points,
|
||
|
|
labels, footer);
|
||
|
|
|
||
|
|
free(sorted);
|
||
|
|
}
|