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
11 changes: 11 additions & 0 deletions apps/cli-docs/src/fragments/commands/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ Null values remain null, and incomplete buckets remain marked as incomplete;
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.

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`.

## Metrics

Use `--metric` (`-m`) to query metrics by name. The CLI auto-resolves the metric's type and unit.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,9 +684,10 @@ export const exploreCommand = buildListCommand("explore", {
"Query the Sentry Explore API for aggregate event data.\n\n" +
"Supports arbitrary fields including columns (title, project),\n" +
"aggregates (count(), count_unique(user), p50(span.duration)),\n" +
"and equations. Results are returned as a table.\n\n" +
"and equations. Results default to a table.\n\n" +
"Use --time-series to calculate aggregates in time buckets with an automatic interval, " +
"or pass a bucket duration such as --time-series 15m (-b 15m). " +
"Time series use one chart per aggregate on graphics-capable terminals, with table fallback. " +
"This is a single historical query, not a live stream. " +
"Grouping, --sort, --limit, and --cursor are table-only.\n\n" +
"Datasets:\n" +
Expand Down
59 changes: 42 additions & 17 deletions packages/cli/src/lib/formatters/chart-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
*
* Turns a {@link TimeseriesResult} into a resolution-independent
* {@link ChartModel}, then rasterizes that model into an RGBA pixel canvas.
* Both the sixel renderer (pixel resolution) and the ASCII renderer
* (character-cell resolution) consume this single core so the two paths
* agree on layout, palette, and stacking. The output resolution is chosen by
* the target and fed in upfront via {@link rasterizeChart}.
* Dashboard and Explore graphics share this rasterizer. Callers can also
* construct a model directly when their data has different stacking or scale
* semantics. The output resolution is chosen by {@link rasterizeChart}'s caller.
*/

import type { TimeseriesResult } from "../../types/dashboard.js";
Expand Down Expand Up @@ -71,7 +70,8 @@ export function hexToRgb(hex: string): [number, number, number] {
/** One series in a chart model: a label plus its per-bucket values. */
export type ChartSeries = {
label: string;
values: number[];
/** Per-bucket values; null leaves a gap without shifting later buckets. */
values: (number | null)[];
};

/** Heatmap grid model: categories (rows) × time buckets (columns). */
Expand Down Expand Up @@ -115,7 +115,7 @@ export function buildChartModel(
return;
}

const series: ChartSeries[] = data.series.map((s) => ({
const series = data.series.map((s) => ({
label: s.label,
values: s.values.map((v) => v.value),
}));
Expand Down Expand Up @@ -197,7 +197,7 @@ function bucketTotals(series: ChartSeries[], buckets: number): number[] {
for (let i = 0; i < buckets; i++) {
const total = totals[i];
const value = s.values[i];
if (total !== undefined && value !== undefined) {
if (total !== undefined && value != null) {
totals[i] = total + value;
}
}
Expand All @@ -216,14 +216,16 @@ export type RasterizeOpts = {
height: number;
/** Leave the background transparent instead of filling it. */
backgroundTransparent?: boolean;
/** Minimum single-series bar height in pixels; nulls stay empty. Default: 0. */
minBarHeight?: number;
};

/**
* Rasterize a chart model into an RGBA pixel canvas at the given resolution.
*
* This is the pixel core: the resolution is chosen by the caller for its
* output target (sixel cell pixels, or an ASCII cell-grid multiple). Returns
* `undefined` when the model has no buckets to draw.
* `undefined` when there are no buckets or fitting would average across gaps.
*/
export function rasterizeChart(
model: ChartModel,
Expand All @@ -239,6 +241,9 @@ export function rasterizeChart(
// ~half the canvas width would push later columns off-canvas and clip them.
// Downsample to fit, mirroring what the ASCII sparkline path already does.
const fitted = fitModelToWidth(model, width);
if (!fitted) {
return;
}

const transparent = opts.backgroundTransparent ?? true;
const img = createPixelCanvas({
Expand All @@ -253,7 +258,7 @@ export function rasterizeChart(
} else if (fitted.stacked) {
drawStackedColumns(img, fitted, height, layout);
} else {
drawBars(img, fitted, height, layout);
drawBars(img, fitted, height, layout, opts.minBarHeight ?? 0);
}

return img;
Expand All @@ -262,17 +267,30 @@ export function rasterizeChart(
/**
* Downsample a model's series so the bucket count fits the canvas: with a 1px
* bar and 1px gap each column needs ~2px, so cap buckets at `width / 2`.
* Returns the model unchanged when it already fits.
* Returns the model unchanged when it already fits; declines to average gaps.
*/
function fitModelToWidth(model: ChartModel, width: number): ChartModel {
function fitModelToWidth(
model: ChartModel,
width: number,
): ChartModel | undefined {
const maxBuckets = Math.max(1, Math.floor(width / 2));
if (model.buckets <= maxBuckets || model.kind === "categorical") {
return model;
}
const series = model.series.map((s) => ({
label: s.label,
values: downsample(s.values, maxBuckets),
}));
const series: ChartSeries[] = [];
for (const s of model.series) {
// Averaging across a gap would invent data. Let the caller use its fallback.
if (s.values.some((value) => value === null)) {
return;
}
series.push({
label: s.label,
values: downsample(
s.values.filter((value) => value !== null),
maxBuckets,
),
});
}
const buckets = Math.max(...series.map((s) => s.values.length));
return { ...model, series, buckets };
}
Expand All @@ -296,6 +314,7 @@ function drawBars(
model: ChartModel,
height: number,
layout: BarLayout,
minBarHeight: number,
): void {
const series = model.series[0];
if (!series) {
Expand All @@ -304,8 +323,14 @@ function drawBars(
const color = hexToRgb(seriesColor(series.label, 0));

for (let i = 0; i < series.values.length; i++) {
const value = series.values[i] ?? 0;
const h = Math.round((value / model.maxVal) * height);
const value = series.values[i];
if (value == null) {
continue;
}
const h = Math.max(
minBarHeight,
Math.round((value / model.maxVal) * height),
);
const x0 = i * (layout.barWidth + layout.gap);
drawPixelRect(img, {
x: x0,
Expand Down
116 changes: 116 additions & 0 deletions packages/cli/src/lib/formatters/explore-timeseries-chart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Explore's adapter to the shared raster engine. Each aggregate keeps its own
* scale; missing and incomplete buckets keep their positions as gaps. Decline
* graphics when the engine would need to average buckets or change their meaning.
*/

import type { EventsTimeSeriesResponse } from "../api/explore.js";
import { encodeImageToKitty } from "../kitty-image.js";
import {
graphicsCellSize,
selectGraphicsFormat,
terminalPixelWidth,
} from "../sixel.js";
import { encodeImageToSixel } from "../sixel-image.js";
import { rasterizeChart } from "./chart-core.js";
import { escapeMarkdownInline, renderMarkdown } from "./markdown.js";
import { formatCellValue } from "./meta-table.js";
import { appendUnitSuffix } from "./numbers.js";
import { isPlainOutput } from "./plain-detect.js";
import { formatUtcTimestamp } from "./time-utils.js";

type TimeSeries = EventsTimeSeriesResponse["timeSeries"][number];

/** Render a single aggregate using terminal graphics, or defer to its table. */
export function formatExploreTimeSeriesChart(
series: TimeSeries,
): string | undefined {
const columns = process.stdout.columns;
if (isPlainOutput() || !columns || columns < 1) {
return;
}
const format = selectGraphicsFormat();
const cell = graphicsCellSize();
if (!format || !cell) {
return;
}
const width = Math.floor(
Math.min(terminalPixelWidth(columns) ?? columns * cell.cellWidth, 2560),
);
const height = Math.floor(Math.min(cell.cellHeight * 8, 240));
const { values, meta } = series;
const first = values[0];
const last = values.at(-1);
if (
!first ||
!last ||
width < 16 ||
height < 8 ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
!Number.isFinite(meta.interval) ||
meta.interval <= 0 ||
// The shared engine averages dense series. That is not valid for arbitrary
// Explore aggregates (for example percentiles and unique counts).
values.length > Math.floor(width / 2) ||
values.some(
(bucket, index) =>
!Number.isFinite(bucket.timestamp) ||
bucket.timestamp !== first.timestamp + index * meta.interval ||
(bucket.value !== null &&
(typeof bucket.value !== "number" ||
!Number.isFinite(bucket.value) ||
bucket.value < 0)),
)
) {
return;
}
const plotted = values.map((bucket) =>
bucket.incomplete || typeof bucket.value !== "number" ? null : bucket.value,
);
const complete = plotted.filter((value) => value !== null);
if (complete.length === 0) {
return;
}
const maximum = Math.max(...complete);
const image = rasterizeChart(
{
kind: "timeseries",
series: [{ label: series.yAxis, values: plotted }],
buckets: values.length,
maxVal: maximum || 1,
stacked: false,
},
{ width, height, minBarHeight: 1 },
);
if (!image) {
return;
}
const output =
format === "kitty"
? encodeImageToKitty(image, width, true)
: encodeImageToSixel(image, width, true);
if (!output) {
return;
}
const scale = renderMarkdown(
`Scale: 0 to ${escapeMarkdownInline(formatMaximum(maximum, series))}`,
);
const range = renderMarkdown(
`Bucket starts (UTC): ${formatUtcTimestamp(first.timestamp)} to ${formatUtcTimestamp(last.timestamp)}`,
);
return `${scale}\n\n${output}\n${range}`;
}

function formatMaximum(value: number, series: TimeSeries): string {
const { valueType, valueUnit } = series.meta;
// The table formatter rounds to two decimals. Keep a sub-centesimal scale
// legible instead of labelling a nonzero chart maximum as zero.
if (value > 0 && value < 0.01) {
if (valueType === "percentage") {
return `${value * 100}%`;
}
return appendUnitSuffix(String(value), valueUnit);
}
return formatCellValue(value, valueType, valueUnit);
}
31 changes: 24 additions & 7 deletions packages/cli/src/lib/formatters/explore-timeseries.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Human tables and JSON output for Explore aggregates bucketed over time.
* Human charts/tables and JSON output for Explore aggregates bucketed over time.
* Keeps response metadata and bucket values intact for machine consumers.
*/

import type { EventsTimeSeriesResponse } from "../api/explore.js";
import { formatExploreTimeSeriesChart } from "./explore-timeseries-chart.js";
import { filterFields } from "./json.js";
import { escapeMarkdownInline, renderMarkdown } from "./markdown.js";
import { buildMetaColumns } from "./meta-table.js";
Expand Down Expand Up @@ -44,7 +45,7 @@ function formatBucketStatus(bucket: Bucket): string {
}
}

/** Render one measure using the same typed cell formatting as Explore tables. */
/** Render one measure, retaining metadata and exact values for graph gaps. */
function formatSeries(series: TimeSeries): string {
const details = [
`**${escapeMarkdownInline(series.yAxis)} over time**`,
Expand Down Expand Up @@ -75,7 +76,23 @@ function formatSeries(series: TimeSeries): string {
return `${header}\n\nNo time buckets returned.`;
}

const rows = series.values.map((bucket) => ({
const chart = formatExploreTimeSeriesChart(series);
if (!chart) {
return `${header}\n\n${formatBuckets(series, series.values)}`;
}
const gaps = series.values.filter(
(bucket) => bucket.value === null || bucket.incomplete,
);
const gapDetails =
gaps.length > 0
? `\n\n${renderMarkdown("Gaps are missing or incomplete buckets, listed below. Complete buckets have at least a baseline tick, including zeros.")}\n\n${formatBuckets(series, gaps)}`
: "";
return `${header}\n\n${chart}${gapDetails}`;
}

/** Use the same typed cells for the full fallback and the chart's gap details. */
function formatBuckets(series: TimeSeries, buckets: Bucket[]): string {
const rows = buckets.map((bucket) => ({
time: formatUtcTimestamp(bucket.timestamp),
value: bucket.value,
status: formatBucketStatus(bucket),
Expand All @@ -95,16 +112,16 @@ function formatSeries(series: TimeSeries): string {
: column.value(row),
},
);
if (series.values.some((bucket) => bucket.incomplete)) {
if (buckets.some((bucket) => bucket.incomplete)) {
columns.push(...buildMetaColumns(["status"]));
}

return `${header}\n\n${formatTable(rows, columns)}`;
return formatTable(rows, columns);
}

/**
* Render temporal buckets in UTC, retaining nulls, string values, and completeness
* distinctions. No totals or peaks are inferred from the selected aggregate.
* Render temporal buckets in UTC, with a table fallback for terminals or series
* without graphics support. JSON and the original bucket values stay untouched.
*/
export function formatExploreTimeSeriesHuman(
data: ExploreTimeSeriesData,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/formatters/sixel-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ function drawChartLabels(
image: ReturnType<typeof createPixelCanvas>,
options: {
modelKind: "timeseries" | "categorical";
series: { label: string; values: number[] }[];
series: { label: string }[];
data: Extract<WidgetDataResult, { type: "timeseries" }>;
x: number;
y: number;
Expand Down Expand Up @@ -478,7 +478,7 @@ function drawChartLabels(
function drawLegend(
image: ReturnType<typeof createPixelCanvas>,
options: {
series: { label: string; values: number[] }[];
series: { label: string }[];
x: number;
y: number;
width: number;
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/test/lib/formatters/chart-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ describe("buildCategoricalChartModel", () => {
});

describe("rasterizeChart", () => {
test("declines dense nullable buckets instead of averaging across gaps", () => {
const model = buildChartModel(makeTimeseries())!;
model.series[0]!.values = [10, null, 20, 30, 40, 50, 60, 70, 80];
model.buckets = 9;

expect(rasterizeChart(model, { width: 16, height: 8 })).toBeUndefined();
});

test("returns a canvas at the requested resolution", () => {
const model = buildChartModel(makeTimeseries());
const img = rasterizeChart(model!, { width: 64, height: 32 });
Expand Down
Loading
Loading