Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/dirty-rings-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/api": minor
"@hyperdx/app": minor
---

feat: Add per-series number formats
Comment thread
pulpdrew marked this conversation as resolved.
9 changes: 7 additions & 2 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -578,9 +578,10 @@
"time",
"number",
"data_rate",
"throughput"
"throughput",
"duration"
],
"description": "Output format type (currency, percent, byte, time, number, data_rate, throughput)."
"description": "Output format type (currency, percent, byte, time, number, data_rate, throughput, duration)."
},
"AggregationFunction": {
"type": "string",
Expand Down Expand Up @@ -1167,6 +1168,10 @@
],
"description": "Optional period aggregation function for Gauge metrics (e.g., compute the delta over the period).",
"example": "delta"
},
"numberFormat": {
"$ref": "#/components/schemas/NumberFormat",
"description": "Per-series number formatting options. When set, takes precedence over the chart-level numberFormat for this select item only.\n"
}
}
},
Expand Down
80 changes: 80 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,86 @@ describe('MCP Dashboard Tools', () => {
const output = JSON.parse(getFirstText(result));
expect(output.tiles).toHaveLength(1);
});

it.each([
{ output: 'duration', factor: 1e-9 },
{ output: 'data_rate' },
{ output: 'throughput' },
] as const)(
'should round-trip numberFormat output "$output" through save and get',
async numberFormat => {
const sourceId = traceSource._id.toString();

const saveResult = await callTool(client, 'hyperdx_save_dashboard', {
name: `NumberFormat ${numberFormat.output}`,
tiles: [
{
name: 'Number Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
numberFormat,
},
},
{
name: 'Line Tile',
config: {
displayType: 'line',
sourceId,
select: [
{ aggFn: 'count' },
{
aggFn: 'avg',
valueExpression: 'Duration',
numberFormat,
},
],
},
},
],
});

expect(saveResult.isError).toBeFalsy();
const saved = JSON.parse(getFirstText(saveResult));

const getResult = await callTool(client, 'hyperdx_get_dashboard', {
id: saved.id,
});
expect(getResult.isError).toBeFalsy();
const fetched = JSON.parse(getFirstText(getResult));

const numberTile = fetched.tiles.find(
(t: { name: string }) => t.name === 'Number Tile',
);
expect(numberTile.config.numberFormat).toEqual(numberFormat);

const lineTile = fetched.tiles.find(
(t: { name: string }) => t.name === 'Line Tile',
);
expect(lineTile.config.select[1].numberFormat).toEqual(numberFormat);
},
);

it('should reject numberFormat with an unknown output value', async () => {
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Bad NumberFormat',
tiles: [
{
name: 'Number Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
numberFormat: { output: 'not_a_real_output' },
},
},
],
});

expect(result.isError).toBe(true);
});
});

describe('hyperdx_delete_dashboard', () => {
Expand Down
126 changes: 74 additions & 52 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,68 @@ import { z } from 'zod';
import { externalQuantileLevelSchema } from '@/utils/zod';

// ─── Shared tile schemas for MCP dashboard tools ─────────────────────────────
const mcpNumberFormatSchema = z
.object({
output: z
.enum([
'currency',
'percent',
'byte',
'time',
'duration',
'number',
'data_rate',
'throughput',
])
.describe(
'Format category. "duration" auto-formats elapsed times as e.g. "1.2s" (use factor for input unit). ' +
'"time" formats clock-style durations. "byte" formats as KB/MB/GB. ' +
'"data_rate" formats as bytes/sec. "throughput" formats as count/sec. ' +
'"currency" prepends a symbol. "percent" appends %.',
),
mantissa: z
.number()
.int()
.optional()
.describe(
'Decimal places (0–10). Not used for "time" or "duration" output.',
),
thousandSeparated: z
.boolean()
.optional()
.describe('Separate thousands (e.g. 1,234,567)'),
average: z
.boolean()
.optional()
.describe('Abbreviate large numbers (e.g. 1.2m)'),
decimalBytes: z
.boolean()
.optional()
.describe(
'Use decimal base for bytes (1KB = 1000). Only for "byte" output.',
),
factor: z
.number()
.optional()
.describe(
'Input unit factor for "time" or "duration" output. ' +
'1 = seconds, 0.001 = milliseconds, 0.000001 = microseconds, 0.000000001 = nanoseconds.',
),
currencySymbol: z
.string()
.optional()
.describe('Currency symbol (e.g. "$"). Only for "currency" output.'),
unit: z
.string()
.optional()
.describe('Suffix appended to the value (e.g. " req/s")'),
})
.describe(
'Controls how the number value is formatted for display. ' +
'Most useful: { output: "duration", factor: 0.000000001 } to auto-format nanosecond durations, ' +
'or { output: "number", mantissa: 2, thousandSeparated: true } for clean counts.',
);

const mcpTileSelectItemSchema = z
.object({
aggFn: AggregateFunctionSchema.describe(
Expand All @@ -31,6 +93,13 @@ const mcpTileSelectItemSchema = z
level: externalQuantileLevelSchema
.optional()
.describe('Percentile level for aggFn="quantile"'),
numberFormat: mcpNumberFormatSchema
.optional()
.describe(
'Per-series display formatting, applied to this series only (overrides any tile-level numberFormat). ' +
'Example: { output: "duration", factor: 0.000000001 } to render a nanosecond Duration series as human-readable time ' +
'while leaving sibling count series unformatted.',
),
})
.superRefine((data, ctx) => {
if (data.level && data.aggFn !== 'quantile') {
Expand Down Expand Up @@ -156,55 +225,6 @@ const mcpTableTileSchema = mcpTileLayoutSchema.extend({
}),
});

const mcpNumberFormatSchema = z
.object({
output: z
.enum(['currency', 'percent', 'byte', 'time', 'number'])
.describe(
'Format category. "time" auto-formats durations (use factor for input unit). ' +
'"byte" formats as KB/MB/GB. "currency" prepends a symbol. "percent" appends %.',
),
mantissa: z
.number()
.int()
.optional()
.describe('Decimal places (0–10). Not used for "time" output.'),
thousandSeparated: z
.boolean()
.optional()
.describe('Separate thousands (e.g. 1,234,567)'),
average: z
.boolean()
.optional()
.describe('Abbreviate large numbers (e.g. 1.2m)'),
decimalBytes: z
.boolean()
.optional()
.describe(
'Use decimal base for bytes (1KB = 1000). Only for "byte" output.',
),
factor: z
.number()
.optional()
.describe(
'Input unit factor for "time" output. ' +
'1 = seconds, 0.001 = milliseconds, 0.000001 = microseconds, 0.000000001 = nanoseconds.',
),
currencySymbol: z
.string()
.optional()
.describe('Currency symbol (e.g. "$"). Only for "currency" output.'),
unit: z
.string()
.optional()
.describe('Suffix appended to the value (e.g. " req/s")'),
})
.describe(
'Controls how the number value is formatted for display. ' +
'Most useful: { output: "time", factor: 0.000000001 } to auto-format nanosecond durations, ' +
'or { output: "number", mantissa: 2, thousandSeparated: true } for clean counts.',
);

const mcpNumberTileSchema = mcpTileLayoutSchema.extend({
config: z.object({
displayType: z.literal('number').describe('Single aggregate scalar value'),
Expand All @@ -216,7 +236,7 @@ const mcpNumberTileSchema = mcpTileLayoutSchema.extend({
numberFormat: mcpNumberFormatSchema
.optional()
.describe(
'Display formatting for the number value. Example: { output: "time", factor: 0.000000001 } ' +
'Display formatting for the number value. Example: { output: "duration", factor: 0.000000001 } ' +
'to auto-format nanosecond durations as human-readable time.',
),
}),
Expand Down Expand Up @@ -318,10 +338,12 @@ export const mcpTilesParam = z
'1. Line chart: { "name": "Error Rate", "config": { "displayType": "line", "sourceId": "<from list_sources>", ' +
'"groupBy": "ResourceAttributes[\'service.name\']", "select": [{ "aggFn": "count", "where": "StatusCode:STATUS_CODE_ERROR" }] } }\n' +
'2. Table: { "name": "Top Endpoints", "config": { "displayType": "table", "sourceId": "<from list_sources>", ' +
'"groupBy": "SpanAttributes[\'http.route\']", "select": [{ "aggFn": "count" }, { "aggFn": "avg", "valueExpression": "Duration" }] } }\n' +
'"groupBy": "SpanAttributes[\'http.route\']", "select": [{ "aggFn": "count" }, ' +
'{ "aggFn": "avg", "valueExpression": "Duration", "numberFormat": { "output": "duration", "factor": 0.000000001 } }] } }\n' +
' (per-series numberFormat lets one column render as a duration while a sibling count column stays a plain number)\n' +
'3. Number: { "name": "Total Requests", "config": { "displayType": "number", "sourceId": "<from list_sources>", ' +
'"select": [{ "aggFn": "count" }], "numberFormat": { "output": "number", "average": true } } }\n' +
'4. Number (duration): { "name": "P95 Latency", "config": { "displayType": "number", "sourceId": "<from list_sources>", ' +
'"select": [{ "aggFn": "quantile", "level": 0.95, "valueExpression": "Duration" }], ' +
'"numberFormat": { "output": "time", "factor": 0.000000001 } } }',
'"numberFormat": { "output": "duration", "factor": 0.000000001 } } }',
);
34 changes: 34 additions & 0 deletions packages/api/src/routers/external-api/__tests__/dashboards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2174,6 +2174,10 @@ describe('External API v2 Dashboards - new format', () => {
alias: '95th Percentile Duration',
where: "env = 'production'",
whereLanguage: 'sql',
numberFormat: {
output: 'duration',
factor: 1e-9,
},
},
{
aggFn: 'quantile',
Expand All @@ -2182,6 +2186,11 @@ describe('External API v2 Dashboards - new format', () => {
alias: '99th Percentile Duration',
where: 'env:production',
whereLanguage: 'lucene',
numberFormat: {
output: 'duration',
factor: 1e-9,
mantissa: 3,
},
},
],
},
Expand Down Expand Up @@ -2236,6 +2245,10 @@ describe('External API v2 Dashboards - new format', () => {
alias: 'Median Duration',
where: "env = 'production'",
whereLanguage: 'sql',
numberFormat: {
output: 'duration',
factor: 1e-9,
},
},
{
aggFn: 'quantile',
Expand Down Expand Up @@ -2276,6 +2289,10 @@ describe('External API v2 Dashboards - new format', () => {
alias: '50th Percentile Duration',
where: "env = 'production'",
whereLanguage: 'sql',
numberFormat: {
output: 'duration',
factor: 1e-9,
},
},
],
numberFormat: {
Expand Down Expand Up @@ -3009,6 +3026,10 @@ describe('External API v2 Dashboards - new format', () => {
alias: '95th Percentile Duration',
where: "env = 'production'",
whereLanguage: 'sql',
numberFormat: {
output: 'duration',
factor: 1e-9,
},
},
{
aggFn: 'quantile',
Expand All @@ -3017,6 +3038,11 @@ describe('External API v2 Dashboards - new format', () => {
alias: '99th Percentile Duration',
where: 'env:production',
whereLanguage: 'lucene',
numberFormat: {
output: 'duration',
factor: 1e-9,
mantissa: 3,
},
},
],
},
Expand Down Expand Up @@ -3073,6 +3099,10 @@ describe('External API v2 Dashboards - new format', () => {
alias: 'Median Duration',
where: "env = 'production'",
whereLanguage: 'sql',
numberFormat: {
output: 'duration',
factor: 1e-9,
},
},
{
aggFn: 'quantile',
Expand Down Expand Up @@ -3114,6 +3144,10 @@ describe('External API v2 Dashboards - new format', () => {
alias: '50th Percentile Duration',
where: "env = 'production'",
whereLanguage: 'sql',
numberFormat: {
output: 'duration',
factor: 1e-9,
},
},
],
numberFormat: {
Expand Down
Loading
Loading