Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions apps/cli-docs/src/fragments/commands/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ recent buckets may still be waiting for ingestion or for the interval to end.
Temporal output has no pagination cursor.

In terminals with Kitty or Sixel graphics support, each numeric aggregate gets
its own chart and scale. Null and incomplete buckets leave gaps; a table below
the chart retains their timestamps, original values, and completeness reasons.
Complete buckets have at least a baseline tick, including zero values. Sampling,
scan coverage, and ingestion warnings remain visible. All times are UTC.
its own chart with a value axis, UTC time labels, and a colored aggregate legend,
using the same presentation as dashboard charts. Null and incomplete buckets leave
gaps; a table below the chart retains their timestamps, original values, and
completeness reasons. Complete buckets have at least a baseline tick, including
zero values. The interval and sampling or scan warnings appear in a compact
caption below each chart. The full aggregate expression is included there when
the legend abbreviates it. Ingestion details follow the results. All times are UTC;
the query range in the header is distinct from the bucket-start labels on the axes.

Other terminals use tables, as do series with strings, negative values, irregular
bucket spacing, or too many buckets to draw individually. The CLI never averages
buckets to fit an Explore chart. Use `SENTRY_NO_GRAPHICS=1` or the graphics default
setting to keep table output. Graphics do not change `--json` or `--fields`.
bucket spacing, too many buckets to draw individually, or too little room for
readable axes. The CLI never averages buckets to fit an Explore chart. Use
`SENTRY_NO_GRAPHICS=1` or the graphics default setting to keep table output.
Graphics do not change `--json` or `--fields`.

## Metrics

Expand Down
278 changes: 278 additions & 0 deletions packages/cli/src/lib/formatters/chart-presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* Shared chart presentation for terminal graphics.
*
* Composes the existing bar rasterizer with axes, caller-formatted labels, and
* a series legend. Data conversion, units, and time zones belong to the caller.
*/

import type { DecodedImage } from "../sixel-image.js";
import {
type ChartModel,
hexToRgb,
rasterizeChart,
seriesColor,
} from "./chart-core.js";
import {
blitPixelImage,
createPixelCanvas,
drawPixelRect,
drawPixelText,
type Rgb,
} from "./pixel-canvas.js";

/** Muted axes that remain visible on light and dark terminal backgrounds. */
const FRAME_COLOR: Rgb = [128, 128, 128];

/** The same text color used by the dashboard compositor. */
const TEXT_COLOR: Rgb = [224, 224, 224];

/** Aggregate function and its simple arguments in a raw query expression. */
const AGGREGATE_LABEL_RE = /^([a-z][a-z0-9_]*)\(([^()]*)\)/i;

/** Presentation and fitting options for a chart with axes and a legend. */
export type ChartPresentationOptions = {
/** Total canvas width in pixels, including the value-axis gutter. */
width: number;
/** Total canvas height in pixels, including time labels and the legend. */
height: number;
/** Width of one terminal text cell in pixels. */
cellWidth: number;
/** Height of one terminal text cell in pixels. */
cellHeight: number;
/** Number of text cells reserved for the value axis; zero hides that axis. */
gutterColumns: number;
/** Value-axis maximum already formatted with the caller's units/precision. */
maximumLabel: string;
/** First x-axis label; omitted values leave that label empty. */
firstLabel?: string;
/** Last x-axis label; omitted values leave that label empty. */
lastLabel?: string;
/** Minimum single-series bar height in pixels; nulls stay empty. Default: 0. */
minBarHeight?: number;
/** Allow the core's numeric bucket averaging to fit the plot. Default: true. */
allowDownsampling?: boolean;
};

/**
* Render a chart, its axes, and a colored legend into one transparent canvas.
* Returns undefined when the plot cannot fit or disallowed averaging is needed.
* Labels and legend use the dashboard's existing height-based visibility rules.
*/
export function renderChartWithLabels(
model: ChartModel,
options: ChartPresentationOptions,
): DecodedImage | undefined {
const { cellWidth, cellHeight, gutterColumns } = options;
const width = Math.floor(options.width);
const height = Math.floor(options.height);
const contentRows = Math.max(1, Math.floor(height / cellHeight));
const hasLegend = contentRows >= 4;
const hasAxisLabels = contentRows >= 5;
const gutterWidth = gutterColumns * cellWidth;
const footerHeight =
(hasLegend ? 1 : 0) * cellHeight + (hasAxisLabels ? 1 : 0) * cellHeight;
// Axes occupy their own pixels so a zero/tiny-value tick never covers a gap.
const plotX = gutterWidth + (gutterColumns > 0 ? 1 : 0);
const plotWidth = Math.floor(width - plotX);
const plotHeight = Math.floor(height - footerHeight - 1);
if (
plotWidth < 16 ||
plotHeight < 8 ||
(options.allowDownsampling === false &&
model.kind === "timeseries" &&
model.buckets > Math.floor(plotWidth / 2))
) {
return;
}
const chart = rasterizeChart(model, {
width: plotWidth,
height: plotHeight,
backgroundTransparent: true,
minBarHeight: options.minBarHeight,
});
if (!chart) {
return;
}

const image = createPixelCanvas({ width, height });
blitPixelImage(image, chart, plotX, 0);
drawPixelRect(image, {
x: gutterWidth,
y: plotHeight,
width: width - gutterWidth,
height: 1,
color: FRAME_COLOR,
});
if (gutterColumns > 0) {
drawPixelRect(image, {
x: gutterWidth,
y: 0,
width: 1,
height: plotHeight + 1,
color: FRAME_COLOR,
});
drawPixelText(image, options.maximumLabel, {
x: 0,
y: 0,
cellWidth,
cellHeight,
maxColumns: gutterColumns,
color: FRAME_COLOR,
});
drawPixelText(image, "0", {
x: 0,
y: plotHeight - cellHeight,
cellWidth,
cellHeight,
maxColumns: gutterColumns,
color: FRAME_COLOR,
});
}

let footerY = plotHeight + 1;
if (hasAxisLabels) {
drawChartLabels(image, {
first: options.firstLabel ?? "",
last: options.lastLabel ?? "",
x: plotX,
y: footerY,
width: plotWidth,
cellWidth,
cellHeight,
});
footerY += cellHeight;
}
if (hasLegend) {
drawLegend(image, {
series: model.series,
x: 0,
y: footerY,
width,
cellWidth,
cellHeight,
});
}
return image;
}

/** Draw concise first/last labels without interpreting the caller's data. */
function drawChartLabels(
image: DecodedImage,
options: {
first: string;
last: string;
x: number;
y: number;
width: number;
cellWidth: number;
cellHeight: number;
},
): void {
const columns = Math.max(1, Math.floor(options.width / options.cellWidth));
drawPixelText(image, options.first, {
x: options.x,
y: options.y,
cellWidth: options.cellWidth,
cellHeight: options.cellHeight,
maxColumns: Math.max(1, Math.floor(columns / 2)),
color: FRAME_COLOR,
});
const lastColumns = Math.min(Math.floor(columns / 2), options.last.length);
drawPixelText(image, options.last, {
x: options.x + Math.max(0, columns - lastColumns) * options.cellWidth,
y: options.y,
cellWidth: options.cellWidth,
cellHeight: options.cellHeight,
maxColumns: Math.max(1, lastColumns),
color: FRAME_COLOR,
});
}

/** Draw colored series keys and labels, clipping safely to the widget width. */
function drawLegend(
image: ReturnType<typeof createPixelCanvas>,
options: {
series: { label: string }[];
x: number;
y: number;
width: number;
cellWidth: number;
cellHeight: number;
},
): void {
let column = 0;
const maxColumns = Math.max(1, Math.floor(options.width / options.cellWidth));
for (let index = 0; index < options.series.length; index += 1) {
const series = options.series[index];
if (!series || column >= maxColumns) {
break;
}
const availableColumns = maxColumns - column - 2;
const { text: label } = formatLegendLabel(series.label, availableColumns);
const requiredColumns = Math.min(maxColumns, label.length + 2);
if (column + requiredColumns > maxColumns) {
break;
}
drawPixelRect(image, {
x: options.x + column * options.cellWidth,
y: options.y + Math.max(1, Math.floor(options.cellHeight / 3)),
width: Math.max(2, Math.floor(options.cellWidth / 2)),
height: Math.max(2, Math.floor(options.cellHeight / 3)),
color: hexToRgb(seriesColor(series.label, index)),
});
drawPixelText(image, label, {
x: options.x + (column + 1) * options.cellWidth,
y: options.y,
cellWidth: options.cellWidth,
cellHeight: options.cellHeight,
maxColumns: Math.min(label.length, maxColumns - column - 1),
color: TEXT_COLOR,
});
column += requiredColumns;
}
}

/** A fitted legend label and whether a caption must retain its full expression. */
export type ChartLegendLabel = {
/** Text drawn next to the series color key. */
text: string;
/** True when arguments, expression terms, or clipped characters are omitted. */
abbreviated: boolean;
};

/** Compact and fit a legend label, tracking information lost beyond punctuation. */
export function formatLegendLabel(
label: string,
availableColumns = 18,
): ChartLegendLabel {
let text = label;
let abbreviated = false;
const aggregate = AGGREGATE_LABEL_RE.exec(label);
if (aggregate) {
const operation = aggregate[1] ?? label;
const arguments_ =
aggregate[2]?.split(",").map((argument) => argument.trim()) ?? [];
const metric = arguments_[0] === "value" ? arguments_[1] : arguments_[0];
text = metric ? `${operation} ${metric}` : operation;
abbreviated =
aggregate[0] !== label ||
arguments_.length > 1 ||
arguments_[0] === "value";
} else if (label.startsWith("equation|")) {
text = "equation";
abbreviated = true;
}
const fitted = truncateLegendLabel(text, Math.min(18, availableColumns));
return { text: fitted, abbreviated: abbreviated || fitted !== text };
}

/** Clip a legend label without leaving a partial aggregate expression. */
function truncateLegendLabel(label: string, maxColumns: number): string {
if (label.length <= maxColumns) {
return label;
}
if (maxColumns <= 3) {
return label.slice(0, Math.max(0, maxColumns));
}
return `${label.slice(0, maxColumns - 3)}...`;
}
Loading
Loading