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 ``;
+ },
+ }}
+ 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;