diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index 271549b74bd8..86759922b82c 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -128,5 +128,126 @@ } } ] + }, + { + "name": "chart", + "description": "Display numeric data as a compact Sentry-style chart. For line, area, and bar charts, prefer at least three points. Use x_axis \"time\" only with offset-bearing ISO 8601 timestamps and \"category\" for named buckets. For heatmaps, each series is a row, each point is a colored cell, and values must be non-negative. Wheel charts require one category series with 2-12 non-negative points and a positive total. Duration values are milliseconds, percentage values are 0-100, and byte values are raw bytes.", + "level": ["block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "subtitle": { + "type": "string" + }, + "visualization": { + "default": "line", + "type": "string", + "enum": ["line", "area", "bar", "heatmap", "wheel"] + }, + "x_axis": { + "default": "time", + "type": "string", + "enum": ["time", "category"] + }, + "y_axis_unit": { + "default": "number", + "type": "string", + "enum": ["number", "percentage", "duration", "bytes"] + }, + "y_axis_label": { + "type": "string" + }, + "series": { + "minItems": 1, + "maxItems": 5, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "data": { + "minItems": 1, + "maxItems": 200, + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "y": { + "type": "number" + } + }, + "required": ["x", "y"], + "additionalProperties": false + } + } + }, + "required": ["name", "data"], + "additionalProperties": false + } + } + }, + "required": ["title", "visualization", "x_axis", "y_axis_unit", "series"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Error volume", + "data": { + "title": "Error volume", + "subtitle": "Last 6 hours", + "visualization": "area", + "x_axis": "time", + "y_axis_unit": "number", + "series": [ + { + "name": "Errors", + "data": [ + { + "x": "2026-07-30T12:00:00Z", + "y": 12 + }, + { + "x": "2026-07-30T13:00:00Z", + "y": 18 + }, + { + "x": "2026-07-30T14:00:00Z", + "y": 15 + }, + { + "x": "2026-07-30T15:00:00Z", + "y": 31 + }, + { + "x": "2026-07-30T16:00:00Z", + "y": 46 + }, + { + "x": "2026-07-30T17:00:00Z", + "y": 38 + } + ] + } + ] + } + } + ] } ] diff --git a/static/app/components/seer/markdown/embeds/components/chart.spec.tsx b/static/app/components/seer/markdown/embeds/components/chart.spec.tsx new file mode 100644 index 000000000000..525d732d3b4f --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/chart.spec.tsx @@ -0,0 +1,382 @@ +import {ThemeFixture} from 'sentry-fixture/theme'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {BaseChart} from 'sentry/components/charts/baseChart'; +import {SeerMarkdown} from 'sentry/components/seer/markdown'; + +jest.mock('sentry/components/charts/baseChart', () => ({ + BaseChart: jest.fn(() => null), +})); + +describe('Chart embed', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(['line', 'area', 'bar'] as const)( + 'renders a %s chart from a markdown extension', + visualization => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Error volume', + subtitle: 'Last three hours', + visualization, + x_axis: 'time', + y_axis_unit: 'number', + series: [ + { + name: 'Errors', + data: [ + {x: '2026-07-30T14:00:00Z', y: 15}, + {x: '2026-07-30T12:00:00Z', y: 12}, + {x: '2026-07-30T13:00:00Z', y: 18}, + ], + }, + ], + })}{% /chart %}`; + + render(); + + expect(screen.getByText('Error volume')).toBeInTheDocument(); + expect(screen.getByText('Last three hours')).toBeInTheDocument(); + expect(screen.getByTestId('seer-chart-embed')).toBeInTheDocument(); + const props = jest.mocked(BaseChart).mock.calls.at(-1)![0]; + expect(props.series).toEqual([ + expect.objectContaining({ + name: 'Errors', + type: visualization === 'bar' ? 'bar' : 'line', + }), + ]); + expect(props.start).toEqual(new Date('2026-07-30T12:00:00Z')); + expect(props.end).toEqual(new Date('2026-07-30T14:00:00Z')); + expect(props.showTimeInTooltip).toBe(true); + expect(props.tooltip).toEqual(expect.objectContaining({trigger: 'axis'})); + if (visualization === 'area') { + expect(props.series?.[0]).toHaveProperty('areaStyle'); + } + if (visualization === 'bar') { + expect(props.series?.[0]?.data).toEqual([ + expect.objectContaining({value: [Date.parse('2026-07-30T12:00:00Z'), 12]}), + expect.objectContaining({value: [Date.parse('2026-07-30T13:00:00Z'), 18]}), + expect.objectContaining({value: [Date.parse('2026-07-30T14:00:00Z'), 15]}), + ]); + } else { + expect(props.series?.[0]?.data).toEqual([ + [Date.parse('2026-07-30T12:00:00Z'), 12], + [Date.parse('2026-07-30T13:00:00Z'), 18], + [Date.parse('2026-07-30T14:00:00Z'), 15], + ]); + } + } + ); + + it('stacks multiple area series so each remains visible', () => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Errors by browser', + visualization: 'area', + x_axis: 'category', + series: [ + {name: 'Chrome', data: [{x: 'Monday', y: 12}]}, + {name: 'Safari', data: [{x: 'Monday', y: 8}]}, + ], + })}{% /chart %}`; + + render(); + + const props = jest.mocked(BaseChart).mock.calls.at(-1)![0]; + expect(props.series).toEqual([ + expect.objectContaining({name: 'Chrome', stack: 'area'}), + expect.objectContaining({name: 'Safari', stack: 'area'}), + ]); + }); + + it('uses one duration unit across the y axis', () => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Latency', + visualization: 'line', + x_axis: 'category', + y_axis_unit: 'duration', + series: [ + { + name: 'p95', + data: [ + {x: 'Chrome', y: 500}, + {x: 'Safari', y: 1000}, + ], + }, + ], + })}{% /chart %}`; + + render(); + + const formatter = jest.mocked(BaseChart).mock.calls.at(-1)![0].yAxis?.axisLabel + ?.formatter as ((value: number) => string) | undefined; + expect(formatter?.(500)).toBe('500ms'); + expect(formatter?.(1000)).toBe('1000ms'); + }); + + it('normalizes numeric category values to strings', () => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Errors by status', + visualization: 'line', + x_axis: 'category', + series: [ + { + name: 'Errors', + data: [ + {x: 200, y: 12}, + {x: 500, y: 4}, + ], + }, + ], + })}{% /chart %}`; + + render(); + + expect(jest.mocked(BaseChart).mock.calls.at(-1)![0].series?.[0]?.data).toEqual([ + ['200', 12], + ['500', 4], + ]); + }); + + it('renders a heatmap from the shared series schema', () => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Latency by browser', + visualization: 'heatmap', + x_axis: 'time', + y_axis_unit: 'duration', + series: [ + { + name: 'Chrome', + data: [ + {x: '2026-07-31T12:00:00Z', y: 480}, + {x: '2026-07-30T12:00:00Z', y: 120}, + ], + }, + { + name: 'Safari', + data: [ + {x: '2026-07-30T12:00:00Z', y: 150}, + {x: '2026-07-31T12:00:00Z', y: 520}, + ], + }, + ], + })}{% /chart %}`; + + render(); + + const props = jest.mocked(BaseChart).mock.calls.at(-1)![0]; + expect(props).toEqual( + expect.objectContaining({ + series: [ + expect.objectContaining({ + type: 'heatmap', + data: [ + [0, 0, 120], + [1, 0, 480], + [0, 1, 150], + [1, 1, 520], + ], + }), + ], + visualMap: expect.objectContaining({min: 0, max: 520}), + xAxis: expect.objectContaining({ + type: 'category', + data: [Date.parse('2026-07-30T12:00:00Z'), Date.parse('2026-07-31T12:00:00Z')], + axisLabel: expect.objectContaining({showMaxLabel: true, showMinLabel: true}), + }), + yAxis: {type: 'category', data: ['Chrome', 'Safari']}, + }) + ); + const formatColumn = props.xAxis?.axisLabel?.formatter as + | ((value: number) => string) + | undefined; + expect(formatColumn?.(Date.parse('2026-07-30T12:00:00Z'))).not.toBe( + formatColumn?.(Date.parse('2026-07-31T12:00:00Z')) + ); + const tooltipFormatter = props.tooltip?.formatter as + | ((params: {value: [number, number, number]}) => string) + | undefined; + const tooltip = tooltipFormatter?.({value: [0, 0, 120]}); + expect(tooltip).toContain('Chrome'); + expect(tooltip).toContain('tooltip-series-solo'); + expect(tooltip).toContain('tooltip-arrow'); + }); + + it('renders a wheel from one category series', () => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Issue status', + visualization: 'wheel', + x_axis: 'category', + series: [ + { + name: 'Issues', + data: [ + {x: 'Resolved', y: 70}, + {x: 'Unresolved', y: 20}, + {x: 'Ignored', y: 10}, + ], + }, + ], + })}{% /chart %}`; + + render(); + + const props = jest.mocked(BaseChart).mock.calls.at(-1)![0]; + expect(props).toEqual( + expect.objectContaining({ + series: [ + expect.objectContaining({ + type: 'pie', + name: 'Issues', + data: [ + {name: 'Resolved', value: 70}, + {name: 'Unresolved', value: 20}, + {name: 'Ignored', value: 10}, + ], + }), + ], + xAxis: null, + yAxis: null, + }) + ); + const colors = props.colors; + expect(colors).toEqual(expect.any(Function)); + expect(typeof colors === 'function' ? colors(ThemeFixture()) : undefined).toEqual( + ThemeFixture().chart.getColorPalette(2) + ); + const tooltipFormatter = props.tooltip?.formatter as + | ((params: {name: string; value: number}) => string) + | undefined; + const tooltip = tooltipFormatter?.({ + name: '', + value: 70, + }); + expect(tooltip).toContain('<img src=x onerror=alert(1)>'); + expect(tooltip).toContain('tooltip-series-solo'); + expect(tooltip).toContain('tooltip-arrow'); + }); + + it.each(['not-a-timestamp', 1_785_405_600])( + 'does not render invalid time-axis value %s', + value => { + const raw = `{% chart %}${JSON.stringify({ + title: 'Error volume', + x_axis: 'time', + series: [{name: 'Errors', data: [{x: value, y: 12}]}], + })}{% /chart %}`; + + render(); + + expect(screen.queryByTestId('seer-chart-embed')).not.toBeInTheDocument(); + } + ); + + it.each([ + { + visualization: 'heatmap', + x_axis: 'category', + series: [{name: 'A', data: [{x: 'one', y: -1}]}], + }, + { + visualization: 'wheel', + x_axis: 'time', + series: [ + { + name: 'A', + data: [ + {x: '2026-07-30T12:00:00Z', y: 1}, + {x: '2026-07-30T13:00:00Z', y: 2}, + ], + }, + ], + }, + { + visualization: 'wheel', + x_axis: 'category', + series: [ + { + name: 'A', + data: [ + {x: 'one', y: 1}, + {x: 'two', y: 2}, + ], + }, + { + name: 'B', + data: [ + {x: 'one', y: 3}, + {x: 'two', y: 4}, + ], + }, + ], + }, + { + visualization: 'wheel', + x_axis: 'category', + series: [{name: 'A', data: [{x: 'one', y: 1}]}], + }, + { + visualization: 'wheel', + x_axis: 'category', + series: [ + {name: 'A', data: Array.from({length: 13}, (_, index) => ({x: index, y: 1}))}, + ], + }, + { + visualization: 'wheel', + x_axis: 'category', + series: [ + { + name: 'A', + data: [ + {x: 'one', y: -1}, + {x: 'two', y: 2}, + ], + }, + ], + }, + { + visualization: 'wheel', + x_axis: 'category', + series: [ + { + name: 'A', + data: [ + {x: 'one', y: 0}, + {x: 'two', y: 0}, + ], + }, + ], + }, + ])('does not render invalid shared-schema visualizations', chart => { + const raw = `{% chart %}${JSON.stringify({title: 'Invalid', ...chart})}{% /chart %}`; + + render(); + + expect(screen.queryByTestId('seer-chart-embed')).not.toBeInTheDocument(); + }); + + it('escapes category labels before rendering the HTML tooltip', () => { + const unsafeLabel = ''; + const raw = `{% chart %}${JSON.stringify({ + title: 'Errors by browser', + visualization: 'bar', + x_axis: 'category', + series: [{name: 'Errors', data: [{x: unsafeLabel, y: 12}]}], + })}{% /chart %}`; + + render(); + + const tooltip = jest.mocked(BaseChart).mock.calls.at(-1)![0].tooltip; + const formatAxisLabel = tooltip?.formatAxisLabel as + | ((value: string) => string) + | undefined; + expect(formatAxisLabel?.(unsafeLabel)).toBe('<img src=x onerror=alert(1)>'); + expect(jest.mocked(BaseChart).mock.calls.at(-1)![0].xAxis).toEqual( + expect.objectContaining({ + axisLabel: expect.objectContaining({showMaxLabel: true, showMinLabel: true}), + }) + ); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/chart.tsx b/static/app/components/seer/markdown/embeds/components/chart.tsx new file mode 100644 index 000000000000..3be678dd7838 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/chart.tsx @@ -0,0 +1,131 @@ +import {Container, Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {AreaChart} from 'sentry/components/charts/areaChart'; +import {BarChart} from 'sentry/components/charts/barChart'; +import {LineChart} from 'sentry/components/charts/lineChart'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; +import {escape} from 'sentry/utils'; +import {formatBytesBase2} from 'sentry/utils/bytes/formatBytesBase2'; +import {getDurationUnit} from 'sentry/utils/discover/charts'; +import {axisDuration} from 'sentry/utils/duration/axisDuration'; +import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; + +import type {ChartSeries, ChartUnit} from './chartTypes'; +import {HeatmapChart} from './heatmapChart'; +import {WheelChart} from './wheelChart'; + +function formatValue(value: number, unit: ChartUnit, durationUnit?: number): string { + switch (unit) { + case 'percentage': + return `${formatAbbreviatedNumber(value)}%`; + case 'duration': + return axisDuration(value, durationUnit); + case 'bytes': + return formatBytesBase2(value); + case 'number': + default: + return formatAbbreviatedNumber(value); + } +} + +export const Chart = defineSeerEmbed({ + name: 'chart', + render({ + title, + subtitle, + visualization, + x_axis: xAxis, + y_axis_unit: yAxisUnit, + y_axis_label: yAxisLabel, + series, + }) { + const chartSeries: ChartSeries[] = series.map(item => { + const data = item.data.map(point => ({ + name: xAxis === 'time' ? Date.parse(String(point.x)) : String(point.x), + value: point.y, + })); + if (xAxis === 'time') { + data.sort((left, right) => Number(left.name) - Number(right.name)); + } + return {seriesName: item.name, data}; + }); + const durationUnit = + yAxisUnit === 'duration' ? getDurationUnit(chartSeries) : undefined; + const timestamps = + xAxis === 'time' + ? chartSeries.flatMap(item => item.data.map(point => Number(point.name))) + : []; + const start = timestamps.length > 0 ? new Date(Math.min(...timestamps)) : undefined; + const end = timestamps.length > 0 ? new Date(Math.max(...timestamps)) : undefined; + const chartProps = { + animation: false, + end, + grid: {left: 12, right: 12, top: series.length > 1 ? 36 : 12, bottom: 8}, + height: 220, + isGroupedByDate: xAxis === 'time', + legend: series.length > 1 ? {left: 0, top: 0} : {show: false}, + renderer: 'svg' as const, + series: chartSeries, + showTimeInTooltip: xAxis === 'time', + start, + tooltip: { + formatAxisLabel: + xAxis === 'category' ? (value: number) => escape(String(value)) : undefined, + trigger: 'axis' as const, + valueFormatter: (value: number) => formatValue(value, yAxisUnit, durationUnit), + }, + xAxis: + xAxis === 'category' + ? {axisLabel: {formatter: String, showMaxLabel: true, showMinLabel: true}} + : undefined, + yAxis: { + name: yAxisLabel, + axisLabel: { + formatter: (value: number) => formatValue(value, yAxisUnit, durationUnit), + }, + }, + }; + + return ( + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {visualization === 'area' ? ( + 1} /> + ) : visualization === 'bar' ? ( + + ) : visualization === 'heatmap' ? ( + formatValue(value, yAxisUnit, durationUnit)} + xAxis={xAxis} + /> + ) : visualization === 'wheel' ? ( + formatValue(value, yAxisUnit, durationUnit)} + /> + ) : ( + + )} + + ); + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/chartTypes.ts b/static/app/components/seer/markdown/embeds/components/chartTypes.ts new file mode 100644 index 000000000000..242caccebd2f --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/chartTypes.ts @@ -0,0 +1,13 @@ +export type ChartAxis = 'time' | 'category'; + +export type ChartUnit = 'number' | 'percentage' | 'duration' | 'bytes'; + +interface ChartPoint { + name: string | number; + value: number; +} + +export interface ChartSeries { + data: ChartPoint[]; + seriesName: string; +} diff --git a/static/app/components/seer/markdown/embeds/components/heatmapChart.tsx b/static/app/components/seer/markdown/embeds/components/heatmapChart.tsx new file mode 100644 index 000000000000..04506bd6d390 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/heatmapChart.tsx @@ -0,0 +1,102 @@ +import 'echarts/lib/chart/heatmap'; + +import type {HeatmapSeriesOption} from 'echarts'; +import moment from 'moment-timezone'; + +import {BaseChart} from 'sentry/components/charts/baseChart'; +import {escape} from 'sentry/utils'; +import {getTimeFormat, getUserTimezone} from 'sentry/utils/dates'; +import {HEATMAP_COLORS} from 'sentry/views/dashboards/widgets/heatMapWidget/settings'; +import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp'; + +import type {ChartAxis, ChartSeries} from './chartTypes'; + +interface HeatmapChartProps { + series: ChartSeries[]; + valueFormatter: (value: number) => string; + xAxis: ChartAxis; +} + +export function HeatmapChart({series, valueFormatter, xAxis}: HeatmapChartProps) { + const columns = Array.from( + new Set(series.flatMap(item => item.data.map(point => point.name))) + ); + if (xAxis === 'time') { + columns.sort((left, right) => Number(left) - Number(right)); + } + const columnIndexes = new Map(columns.map((column, index) => [column, index])); + const rows = series.map(item => item.seriesName); + const cells = series.flatMap((item, rowIndex) => + item.data.map(point => [columnIndexes.get(point.name)!, rowIndex, point.value]) + ); + const maxValue = Math.max(1, ...cells.map(cell => cell[2]!)); + const timezone = getUserTimezone(); + const timeColumnDates = + xAxis === 'time' ? columns.map(column => moment.tz(Number(column), timezone)) : []; + const spansMultipleDays = + new Set(timeColumnDates.map(date => date.format('YYYY-MM-DD'))).size > 1; + const spansMultipleYears = + new Set(timeColumnDates.map(date => date.format('YYYY'))).size > 1; + const formatColumn = (value: string | number) => { + if (xAxis !== 'time') { + return String(value); + } + if (!spansMultipleDays) { + return formatXAxisTimestamp(Number(value), timezone); + } + const format = spansMultipleYears + ? `MMM D YYYY, ${getTimeFormat()}` + : `MMM D, ${getTimeFormat()}`; + return moment.tz(Number(value), timezone).format(format); + }; + const heatmapSeries: HeatmapSeriesOption = { + type: 'heatmap', + name: 'Heatmap', + data: cells, + emphasis: { + itemStyle: { + borderWidth: 1, + }, + }, + }; + + return ( + { + const params = Array.isArray(rawParams) ? rawParams[0] : rawParams; + if (!params) { + return ''; + } + const [columnIndex, rowIndex, value] = params.value as [number, number, number]; + const row = escape(rows[rowIndex] ?? ''); + const column = escape(formatColumn(columns[columnIndex]!)); + return `
${row} ${column}: ${valueFormatter(value)}
`; + }, + }} + visualMap={{ + type: 'continuous', + show: false, + min: 0, + max: maxValue, + inRange: {color: [...HEATMAP_COLORS]}, + }} + xAxis={{ + type: 'category', + data: columns, + axisLabel: { + formatter: value => formatColumn(value), + showMaxLabel: true, + showMinLabel: true, + }, + }} + yAxis={{type: 'category', data: rows}} + /> + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/wheelChart.tsx b/static/app/components/seer/markdown/embeds/components/wheelChart.tsx new file mode 100644 index 000000000000..c4bf07fa41ce --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/wheelChart.tsx @@ -0,0 +1,46 @@ +import 'echarts/lib/chart/pie'; + +import type {PieSeriesOption} from 'echarts'; + +import {BaseChart} from 'sentry/components/charts/baseChart'; +import {escape} from 'sentry/utils'; + +import type {ChartSeries} from './chartTypes'; + +interface WheelChartProps { + series: ChartSeries; + valueFormatter: (value: number) => string; +} + +export function WheelChart({series, valueFormatter}: WheelChartProps) { + const wheelSeries: PieSeriesOption = { + type: 'pie', + name: series.seriesName, + radius: ['40%', '70%'], + data: series.data.map(point => ({name: String(point.name), value: point.value})), + label: {formatter: '{b}: {d}%'}, + avoidLabelOverlap: true, + }; + + return ( + theme.chart.getColorPalette(series.data.length - 1)} + height={220} + renderer="svg" + series={[wheelSeries]} + tooltip={{ + trigger: 'item', + formatter: rawParams => { + const params = Array.isArray(rawParams) ? rawParams[0] : rawParams; + if (!params) { + return ''; + } + return `
${escape(String(params.name))} ${valueFormatter(Number(params.value))}
`; + }, + }} + xAxis={null} + yAxis={null} + /> + ); +} diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index c5999020e362..8679d58ed5de 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -1,10 +1,11 @@ +import {Chart} from './components/chart'; import {Docs} from './components/docs'; import {Dsn} from './components/dsn'; import {Timestamp} from './components/timestamp'; import {User} from './components/user'; import {SeerEmbedRegistry} from './registry'; -const embeds = [Docs, Dsn, Timestamp, User]; +const embeds = [Chart, Docs, Dsn, Timestamp, User]; for (const embed of embeds) { SeerEmbedRegistry.register(embed.displayName, embed); } diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 855b06f0c57d..d57a5cd6672f 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -1,5 +1,7 @@ import {z} from 'zod'; +const isoTimestampSchema = z.iso.datetime({offset: true}); + type SeerEmbedLevel = 'inline' | 'block'; export interface SeerEmbedExample { @@ -72,6 +74,149 @@ export const SEER_EMBED_SCHEMAS = { {label: 'Team', data: {id: '2', type: 'team', name: 'platform'}}, ], }, + chart: { + description: + 'Display numeric data as a compact Sentry-style chart. For line, area, and bar charts, ' + + 'prefer at least three points. Use x_axis "time" only with offset-bearing ISO 8601 ' + + 'timestamps and "category" for named buckets. For heatmaps, each series is a row, ' + + 'each point is a colored cell, and values must be non-negative. Wheel charts require ' + + 'one category series with 2-12 non-negative points and a positive total. ' + + 'Duration values are milliseconds, percentage values are 0-100, and byte values are raw bytes.', + level: ['block'], + schema: z + .object({ + title: z.string().min(1), + subtitle: z.string().optional(), + visualization: z + .enum(['line', 'area', 'bar', 'heatmap', 'wheel']) + .default('line'), + x_axis: z.enum(['time', 'category']).default('time'), + y_axis_unit: z + .enum(['number', 'percentage', 'duration', 'bytes']) + .default('number'), + y_axis_label: z.string().optional(), + series: z + .array( + z.object({ + name: z.string(), + data: z + .array( + z.object({ + x: z.union([z.string(), z.number()]), + y: z.number(), + }) + ) + .min(1) + .max(200), + }) + ) + .min(1) + .max(5), + }) + .superRefine((chart, context) => { + if (chart.x_axis === 'time') { + chart.series.forEach((series, seriesIndex) => { + series.data.forEach((point, pointIndex) => { + if ( + typeof point.x !== 'string' || + !isoTimestampSchema.safeParse(point.x).success + ) { + context.addIssue({ + code: 'custom', + message: 'Time-axis values must be ISO 8601 timestamps', + path: ['series', seriesIndex, 'data', pointIndex, 'x'], + }); + } + }); + }); + } + + if (chart.visualization === 'heatmap') { + chart.series.forEach((series, seriesIndex) => { + series.data.forEach((point, pointIndex) => { + if (point.y >= 0) { + return; + } + context.addIssue({ + code: 'custom', + message: 'Heatmap values must be non-negative', + path: ['series', seriesIndex, 'data', pointIndex, 'y'], + }); + }); + }); + } + + if (chart.visualization !== 'wheel') { + return; + } + + if (chart.x_axis !== 'category') { + context.addIssue({ + code: 'custom', + message: 'Wheel charts require a category x-axis', + path: ['x_axis'], + }); + } + if (chart.series.length !== 1) { + context.addIssue({ + code: 'custom', + message: 'Wheel charts require exactly one series', + path: ['series'], + }); + } + + const points = chart.series[0]?.data ?? []; + if (points.length < 2 || points.length > 12) { + context.addIssue({ + code: 'custom', + message: 'Wheel charts require between 2 and 12 points', + path: ['series', 0, 'data'], + }); + } + points.forEach((point, pointIndex) => { + if (point.y >= 0) { + return; + } + context.addIssue({ + code: 'custom', + message: 'Wheel chart values must be non-negative', + path: ['series', 0, 'data', pointIndex, 'y'], + }); + }); + if (points.reduce((total, point) => total + point.y, 0) <= 0) { + context.addIssue({ + code: 'custom', + message: 'Wheel chart values must have a positive total', + path: ['series', 0, 'data'], + }); + } + }), + examples: [ + { + label: 'Error volume', + data: { + title: 'Error volume', + subtitle: 'Last 6 hours', + visualization: 'area', + x_axis: 'time', + y_axis_unit: 'number', + series: [ + { + name: 'Errors', + data: [ + {x: '2026-07-30T12:00:00Z', y: 12}, + {x: '2026-07-30T13:00:00Z', y: 18}, + {x: '2026-07-30T14:00:00Z', y: 15}, + {x: '2026-07-30T15:00:00Z', y: 31}, + {x: '2026-07-30T16:00:00Z', y: 46}, + {x: '2026-07-30T17:00:00Z', y: 38}, + ], + }, + ], + }, + }, + ], + }, } as const satisfies Record; export type SeerEmbedName = keyof typeof SEER_EMBED_SCHEMAS;