diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index b8d6bd1f7..f0a30f788 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -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. diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 50329e92c..f2afdbff3 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -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" + diff --git a/packages/cli/src/lib/formatters/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts index 7d1fe7d83..c9f1c2e21 100644 --- a/packages/cli/src/lib/formatters/chart-core.ts +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -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"; @@ -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). */ @@ -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), })); @@ -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; } } @@ -216,6 +216,8 @@ 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; }; /** @@ -223,7 +225,7 @@ export type RasterizeOpts = { * * 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, @@ -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({ @@ -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; @@ -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 }; } @@ -296,6 +314,7 @@ function drawBars( model: ChartModel, height: number, layout: BarLayout, + minBarHeight: number, ): void { const series = model.series[0]; if (!series) { @@ -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, diff --git a/packages/cli/src/lib/formatters/explore-timeseries-chart.ts b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts new file mode 100644 index 000000000..9e07a05a6 --- /dev/null +++ b/packages/cli/src/lib/formatters/explore-timeseries-chart.ts @@ -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); +} diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index ab8df9411..14b7c7c40 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -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"; @@ -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**`, @@ -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), @@ -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, diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts index 280ce5e61..b4256e36a 100644 --- a/packages/cli/src/lib/formatters/sixel-dashboard.ts +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -431,7 +431,7 @@ function drawChartLabels( image: ReturnType, options: { modelKind: "timeseries" | "categorical"; - series: { label: string; values: number[] }[]; + series: { label: string }[]; data: Extract; x: number; y: number; @@ -478,7 +478,7 @@ function drawChartLabels( function drawLegend( image: ReturnType, options: { - series: { label: string; values: number[] }[]; + series: { label: string }[]; x: number; y: number; width: number; diff --git a/packages/cli/test/lib/formatters/chart-core.test.ts b/packages/cli/test/lib/formatters/chart-core.test.ts index b1b7d21d7..419f2ddc0 100644 --- a/packages/cli/test/lib/formatters/chart-core.test.ts +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -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 }); diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index b67c49eb6..7fc74fd3b 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -2,7 +2,15 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { type ExploreTimeSeriesData, formatExploreTimeSeriesHuman, + jsonTransformExploreTimeSeries, } from "../../../src/lib/formatters/explore-timeseries.js"; +import { + renderCommandOutput, + resolveRenderer, +} from "../../../src/lib/formatters/output.js"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- terminal capability boundary for vi.spyOn +import * as terminal from "../../../src/lib/sixel.js"; +import type { DecodedImage } from "../../../src/lib/sixel-image.js"; /** Sanitized shape of a captured spans timeseries response, including ingestion metadata. */ function makeData(): ExploreTimeSeriesData { @@ -66,6 +74,7 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); }); @@ -188,3 +197,294 @@ describe("formatExploreTimeSeriesHuman", () => { ); }); }); + +/** Decode the emitted Kitty RGBA payload, including continuation chunks. */ +function kittyImages(output: string): DecodedImage[] { + const images: DecodedImage[] = []; + let payload = ""; + for (const packet of output.split("\x1b_G").slice(1)) { + const separator = packet.indexOf(";"); + const keys = Object.fromEntries( + packet + .slice(0, separator) + .split(",") + .map((key) => key.split("=")), + ); + if (keys.a === "T") { + images.push({ + width: Number(keys.s), + height: Number(keys.v), + data: new Uint8Array(), + }); + payload = ""; + } + payload += packet.slice(separator + 1, packet.indexOf("\x1b\\")); + if (keys.m === "0") { + images.at(-1)!.data = Buffer.from(payload, "base64"); + } + } + return images; +} + +function alphaAt( + image: DecodedImage, + x: number, + y: number, +): number | undefined { + return image.data[(y * image.width + x) * 4 + 3]; +} + +describe("Explore time-series graphics", () => { + let savedColumns: number | undefined; + + beforeEach(() => { + savedColumns = process.stdout.columns; + process.stdout.columns = 80; + vi.stubEnv("SENTRY_PLAIN_OUTPUT", "0"); + vi.spyOn(terminal, "selectGraphicsFormat").mockReturnValue("kitty"); + vi.spyOn(terminal, "graphicsCellSize").mockReturnValue({ + cellWidth: 8, + cellHeight: 8, + }); + vi.spyOn(terminal, "terminalPixelWidth").mockReturnValue(160); + }); + + afterEach(() => { + process.stdout.columns = savedColumns; + }); + + test.each(["kitty", "sixel"] as const)( + "renders one %s chart and scale per aggregate", + (format) => { + vi.mocked(terminal.selectGraphicsFormat).mockReturnValue(format); + vi.stubEnv("TZ", "America/Los_Angeles"); + const data = makeData(); + const count = data.timeSeries[0]!; + count.values[2]!.incomplete = false; + data.timeSeries.push({ + yAxis: "p95(span.duration)", + meta: { + valueType: "duration", + valueUnit: "millisecond", + interval: 15_000, + }, + values: [12.5, 25].map((value, index) => ({ + timestamp: Date.UTC(2026, 0, 1) + index * 15_000, + value, + incomplete: false, + })), + }); + const original = structuredClone(data); + + const output = formatExploreTimeSeriesHuman(data); + + expect( + output.split(format === "kitty" ? "\x1b_Ga=T" : "\x1bP"), + ).toHaveLength(3); + expect(output).toContain("count() over time"); + expect(output).toContain("p95(span.duration) over time"); + expect(output).toContain("Scale: 0 to 1,234"); + expect(output).toContain("Scale: 0 to 25ms"); + expect(output).toContain("Unit: millisecond"); + expect(output).toContain("2026-01-01 00:00:00 to 2026-01-01 00:00:15"); + expect(output).not.toContain("TIME (UTC)"); + expect(data).toEqual(original); + }, + ); + + test("keeps gaps in place, distinguishes zero, and retains incomplete values and warnings", () => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.meta.dataScanned = "partial"; + series.values = [10_000, null, 0, 90_000, 80_000, 70_000, 5000, 1].map( + (value, index) => ({ + timestamp: Date.UTC(2026, 0, 1) + index * series.meta.interval, + value, + incomplete: false, + }), + ); + series.values[3]!.incomplete = true; + series.values[3]!.incompleteReason = "OUTSIDE_RETENTION"; + series.values[4]!.incomplete = true; + series.values[4]!.incompleteReason = "INGESTION_PENDING"; + series.values[5]!.incomplete = true; + series.values[5]!.incompleteReason = "NOT_ELAPSED"; + series.values[6]!.sampleRate = 0.1; + series.values[6]!.confidence = "low"; + const original = structuredClone(data); + + const output = formatExploreTimeSeriesHuman(data); + const images = kittyImages(output); + + expect(images).toHaveLength(1); + const image = images[0]!; + expect(image.data).toHaveLength(image.width * image.height * 4); + // Eight slots span the image: absent values keep their slots, and a later + // complete bucket keeps its time position instead of closing the gaps. + expect(alphaAt(image, 10, 0)).toBe(255); + expect(alphaAt(image, 30, 63)).toBe(0); + expect(alphaAt(image, 50, 63)).toBe(255); + expect(alphaAt(image, 50, 62)).toBe(0); + expect(alphaAt(image, 70, 63)).toBe(0); + expect(alphaAt(image, 90, 63)).toBe(0); + expect(alphaAt(image, 110, 63)).toBe(0); + expect(alphaAt(image, 130, 31)).toBe(0); + expect(alphaAt(image, 130, 32)).toBe(255); + // A small positive value must survive a large peak, just as zero does. + expect(alphaAt(image, 150, 63)).toBe(255); + expect(alphaAt(image, 150, 62)).toBe(0); + expect(output).toContain("Scale: 0 to 10,000"); + expect(output).toContain("Gaps are missing or incomplete buckets"); + expect(output).toMatch(/2026-01-01 00:05:00\s+│\s+—/); + expect(output).toMatch( + /2026-01-01 00:15:00\s+│\s+90,000\s+│\s+Outside retention/, + ); + expect(output).toMatch( + /2026-01-01 00:20:00\s+│\s+80,000\s+│\s+Ingestion pending/, + ); + expect(output).toMatch( + /2026-01-01 00:25:00\s+│\s+70,000\s+│\s+Window not elapsed/, + ); + expect(output).toContain("Only part of the available data was scanned"); + expect(output).toContain("Some buckets have low confidence"); + expect(output).toContain("Results include sampled data"); + expect(output).toContain("Reported ingestion delay: 4m 48s"); + expect(data).toEqual(original); + }); + + test("scales sub-centesimal measurements without labelling them zero", () => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.meta.valueType = "duration"; + series.meta.valueUnit = "millisecond"; + series.values = [0.001, 0.002].map((value, index) => ({ + timestamp: Date.UTC(2026, 0, 1) + index * series.meta.interval, + value, + incomplete: false, + })); + + const output = formatExploreTimeSeriesHuman(data); + const [image] = kittyImages(output); + + expect(output).toContain("Scale: 0 to 0.002ms"); + expect(image).toBeDefined(); + expect(alphaAt(image!, 10, 31)).toBe(0); + expect(alphaAt(image!, 10, 32)).toBe(255); + expect(alphaAt(image!, 90, 0)).toBe(255); + }); + + test.each(["negative", "string", "irregular", "missing"] as const)( + "uses the table for a %s series", + (kind) => { + const data = makeData(); + const series = data.timeSeries[0]!; + if (kind === "negative") { + series.values[0]!.value = -5; + } else if (kind === "string") { + series.values[0]!.value = "00123"; + } else if (kind === "irregular") { + series.values[1]!.timestamp += 1000; + } else { + series.values = series.values.map((bucket) => ({ + ...bucket, + value: null, + })); + } + + const output = formatExploreTimeSeriesHuman(data); + + expect(output).toContain("TIME (UTC)"); + expect(output).toContain("Ingestion pending"); + expect(output).not.toContain("\x1b_G"); + expect(output).not.toContain("\x1bP"); + if (kind === "negative") expect(output).toContain("-5"); + if (kind === "string") expect(output).toContain("00123"); + if (kind === "irregular") expect(output).toContain("2026-01-01 00:05:01"); + if (kind === "missing") expect(output).toContain("—"); + }, + ); + + test("uses the full table instead of averaging dense percentile buckets", () => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.yAxis = "p95(span.duration)"; + series.values = Array.from({ length: 81 }, (_, index) => ({ + timestamp: Date.UTC(2026, 0, 1) + index * series.meta.interval, + value: index === 80 ? 999 : index, + incomplete: false, + })); + + const output = formatExploreTimeSeriesHuman(data); + + expect(output).not.toContain("\x1b_G"); + expect(output).toContain("TIME (UTC)"); + expect(output).toMatch(/2026-01-01 06:40:00\s+│\s+999/); + }); + + test("uses tables without graphics support or when plain output is requested", () => { + vi.mocked(terminal.selectGraphicsFormat).mockReturnValue(undefined); + const data = makeData(); + const unsupported = formatExploreTimeSeriesHuman(data); + + expect(unsupported).toContain("TIME (UTC)"); + expect(unsupported).toContain("1,234"); + expect(unsupported).toContain("Ingestion pending"); + expect(unsupported).not.toContain("\x1b_G"); + + vi.mocked(terminal.selectGraphicsFormat).mockReturnValue("kitty"); + vi.stubEnv("SENTRY_PLAIN_OUTPUT", "1"); + const plain = formatExploreTimeSeriesHuman(data); + + expect(plain).toContain("TIME (UTC)"); + expect(plain).not.toContain("\x1b_G"); + }); + + test.each([undefined, ["timestamp", "value", "incomplete"]])( + "preserves JSON with fields %j without probing the terminal", + (fields) => { + const data = makeData(); + data.timeSeries[0]!.values[1]!.value = null; + const original = structuredClone(data); + const chunks: (string | Uint8Array)[] = []; + const config = { + human: formatExploreTimeSeriesHuman, + jsonTransform: jsonTransformExploreTimeSeries, + }; + renderCommandOutput( + { + write: (chunk) => { + chunks.push(chunk); + return true; + }, + }, + data, + config, + resolveRenderer(config.human), + { json: true, fields }, + ); + + const response = JSON.parse(chunks.join("")); + const { org: _org, project: _project, ...expected } = original; + expect(response).toEqual( + fields + ? { + ...expected, + timeSeries: expected.timeSeries.map((series) => ({ + ...series, + values: series.values.map( + ({ timestamp, value, incomplete }) => ({ + timestamp, + value, + incomplete, + }), + ), + })), + } + : expected, + ); + expect(terminal.selectGraphicsFormat).not.toHaveBeenCalled(); + expect(terminal.graphicsCellSize).not.toHaveBeenCalled(); + expect(data).toEqual(original); + }, + ); +});