feat: support saved query/filter values in external api (#1814)

In https://github.com/hyperdxio/hyperdx/pull/1584 we added saved default query/filter values support to dashboards. This PR extends that support to the external API.

Fixes HDX-3519
This commit is contained in:
Himanshu Kapoor 2026-03-04 17:45:18 +01:00 committed by GitHub
parent f5828d1bfa
commit daab2cace1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 453 additions and 20 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---
support saved query/filter values in external api

View file

@ -508,6 +508,26 @@
],
"description": "Query language for the where clause."
},
"SavedFilterValue": {
"type": "object",
"required": [
"condition"
],
"properties": {
"type": {
"type": "string",
"enum": [
"sql"
],
"default": "sql"
},
"condition": {
"type": "string",
"description": "SQL filter condition. For example use expressions in the form \"column IN ('value')\".",
"example": "ServiceName IN ('hdx-oss-dev-api')"
}
}
},
"MetricDataType": {
"type": "string",
"enum": [
@ -1525,6 +1545,26 @@
"items": {
"$ref": "#/components/schemas/Filter"
}
},
"savedQuery": {
"type": "string",
"nullable": true,
"description": "Optional default dashboard query restored when loading the dashboard.",
"example": "service.name = 'api'"
},
"savedQueryLanguage": {
"$ref": "#/components/schemas/QueryLanguage",
"nullable": true,
"description": "Query language used by savedQuery.",
"default": "lucene",
"example": "sql"
},
"savedFilterValues": {
"type": "array",
"description": "Optional default dashboard filter values restored when loading the dashboard.",
"items": {
"$ref": "#/components/schemas/SavedFilterValue"
}
}
}
},
@ -1563,6 +1603,26 @@
"items": {
"$ref": "#/components/schemas/FilterInput"
}
},
"savedQuery": {
"type": "string",
"nullable": true,
"description": "Optional default dashboard query to persist on the dashboard.",
"example": "service.name = 'api'"
},
"savedQueryLanguage": {
"$ref": "#/components/schemas/QueryLanguage",
"nullable": true,
"description": "Query language used by savedQuery.",
"default": "lucene",
"example": "sql"
},
"savedFilterValues": {
"type": "array",
"description": "Optional default dashboard filter values to persist on the dashboard.",
"items": {
"$ref": "#/components/schemas/SavedFilterValue"
}
}
}
},
@ -1603,6 +1663,26 @@
"items": {
"$ref": "#/components/schemas/Filter"
}
},
"savedQuery": {
"type": "string",
"nullable": true,
"description": "Optional default dashboard query to persist on the dashboard.",
"example": "service.name = 'api'"
},
"savedQueryLanguage": {
"$ref": "#/components/schemas/QueryLanguage",
"nullable": true,
"description": "Query language used by savedQuery.",
"default": "lucene",
"example": "sql"
},
"savedFilterValues": {
"type": "array",
"description": "Optional default dashboard filter values to persist on the dashboard.",
"items": {
"$ref": "#/components/schemas/SavedFilterValue"
}
}
}
},

View file

@ -40,6 +40,9 @@ describe('External API v2 Dashboards - old format', () => {
name: 'Test External Dashboard',
tiles: [makeExternalChart({ sourceId }), makeExternalChart({ sourceId })],
tags: TEST_TAGS,
savedQuery: null,
savedQueryLanguage: null,
savedFilterValues: [],
...overrides,
});
@ -57,6 +60,9 @@ describe('External API v2 Dashboards - old format', () => {
],
tags: TEST_TAGS,
filters: [],
savedQuery: null,
savedQueryLanguage: null,
savedFilterValues: [],
...overrides,
});
@ -280,6 +286,9 @@ describe('External API v2 Dashboards - old format', () => {
],
tags: ['format-test'],
filters: [],
savedQuery: null,
savedQueryLanguage: null,
savedFilterValues: [],
},
});
@ -362,6 +371,80 @@ describe('External API v2 Dashboards - old format', () => {
expect(dashboards[0].tiles).toHaveLength(2);
});
it('should create a dashboard with saved query defaults', async () => {
const mockDashboard = createMockDashboard(traceSource._id.toString(), {
savedQuery: "service.name = 'api'",
savedQueryLanguage: 'sql',
savedFilterValues: [
{
type: 'sql',
condition: "ServiceName IN ('hdx-oss-dev-api')",
},
],
});
const response = await authRequest('post', BASE_URL)
.send(mockDashboard)
.expect(200);
expect(response.body.data.savedQuery).toBe("service.name = 'api'");
expect(response.body.data.savedQueryLanguage).toBe('sql');
expect(response.body.data.savedFilterValues).toEqual([
{
type: 'sql',
condition: "ServiceName IN ('hdx-oss-dev-api')",
},
]);
const dashboardInDb = await Dashboard.findById(
response.body.data.id,
).lean();
expect(dashboardInDb?.savedQuery).toBe("service.name = 'api'");
expect(dashboardInDb?.savedQueryLanguage).toBe('sql');
expect(dashboardInDb?.savedFilterValues).toEqual([
{
type: 'sql',
condition: "ServiceName IN ('hdx-oss-dev-api')",
},
]);
});
it('should default savedQueryLanguage to lucene when savedQuery is provided without a language', async () => {
const mockDashboard = omit(
createMockDashboard(traceSource._id.toString(), {
savedQuery: "service.name = 'api'",
}),
'savedQueryLanguage',
);
const response = await authRequest('post', BASE_URL)
.send(mockDashboard)
.expect(200);
expect(response.body.data.savedQuery).toBe("service.name = 'api'");
expect(response.body.data.savedQueryLanguage).toBe('lucene');
const dashboardInDb = await Dashboard.findById(
response.body.data.id,
).lean();
expect(dashboardInDb?.savedQueryLanguage).toBe('lucene');
});
it('should return 400 when savedQueryLanguage is null and savedQuery is provided', async () => {
const mockDashboard = createMockDashboard(traceSource._id.toString(), {
savedQuery: "service.name = 'api'",
savedQueryLanguage: null,
});
const response = await authRequest('post', BASE_URL)
.send(mockDashboard)
.expect(400);
expect(response.body.message).toContain(
'savedQueryLanguage cannot be null when savedQuery is provided',
);
});
it('can create all chart types', async () => {
const dashboardWithAllCharts = {
name: 'Test Dashboard with All Chart Types',
@ -881,6 +964,56 @@ describe('External API v2 Dashboards - old format', () => {
expect(updatedDashboardInDb?.tiles).toHaveLength(2);
});
it('should update and clear saved query defaults', async () => {
const dashboard = await createTestDashboard({
savedQuery: 'service:api',
savedQueryLanguage: 'lucene',
savedFilterValues: [
{
type: 'lucene',
condition: 'env:prod',
},
],
});
const updatedDashboard = createMockDashboardWithIds(
traceSource._id.toString(),
);
const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`)
.send(updatedDashboard)
.expect(200);
expect(response.body.data.savedQuery).toBeNull();
expect(response.body.data.savedQueryLanguage).toBeNull();
expect(response.body.data.savedFilterValues).toEqual([]);
const updatedDashboardInDb = await Dashboard.findById(
dashboard._id,
).lean();
expect(updatedDashboardInDb?.savedQuery).toBeNull();
expect(updatedDashboardInDb?.savedQueryLanguage).toBeNull();
expect(updatedDashboardInDb?.savedFilterValues).toEqual([]);
});
it('should return 400 when savedQueryLanguage is null and savedQuery is provided on update', async () => {
const dashboard = await createTestDashboard();
const updatedDashboard = createMockDashboardWithIds(
traceSource._id.toString(),
{
savedQuery: "service.name = 'api'",
savedQueryLanguage: null,
},
);
const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`)
.send(updatedDashboard)
.expect(400);
expect(response.body.message).toContain(
'savedQueryLanguage cannot be null when savedQuery is provided',
);
});
it('should update dashboard filters when provided', async () => {
const dashboard = await createTestDashboard();
const filterId1 = new ObjectId().toString();
@ -1814,6 +1947,9 @@ describe('External API v2 Dashboards - new format', () => {
],
tags: ['format-test'],
filters: [],
savedQuery: null,
savedQueryLanguage: null,
savedFilterValues: [],
},
});
@ -1924,6 +2060,21 @@ describe('External API v2 Dashboards - new format', () => {
expect(dashboards[0].tiles).toHaveLength(2);
});
it('should return 400 when savedQueryLanguage is null and savedQuery is provided', async () => {
const mockDashboard = createMockDashboard(traceSource._id.toString(), {
savedQuery: "service.name = 'api'",
savedQueryLanguage: null,
});
const response = await authRequest('post', BASE_URL)
.send(mockDashboard)
.expect(400);
expect(response.body.message).toContain(
'savedQueryLanguage cannot be null when savedQuery is provided',
);
});
it('can create all chart types', async () => {
const dashboardWithAllCharts = {
name: 'Test Dashboard with All Chart Types',
@ -2306,6 +2457,25 @@ describe('External API v2 Dashboards - new format', () => {
expect(updatedDashboardInDb?.tiles).toHaveLength(2);
});
it('should return 400 when savedQueryLanguage is null and savedQuery is provided on update', async () => {
const dashboard = await createTestDashboard();
const updatedDashboard = createMockDashboardWithIds(
traceSource._id.toString(),
{
savedQuery: "service.name = 'api'",
savedQueryLanguage: null,
},
);
const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`)
.send(updatedDashboard)
.expect(400);
expect(response.body.message).toContain(
'savedQueryLanguage cannot be null when savedQuery is provided',
);
});
it('should update dashboard filters when provided', async () => {
const dashboard = await createTestDashboard();
const filterId1 = new ObjectId().toString();

View file

@ -1,3 +1,4 @@
import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { uniq } from 'lodash';
import { ObjectId } from 'mongodb';
@ -17,6 +18,7 @@ import {
externalDashboardFilterSchema,
externalDashboardFilterSchemaWithId,
ExternalDashboardFilterWithId,
externalDashboardSavedFilterValueSchema,
externalDashboardTileListSchema,
ExternalDashboardTileWithId,
objectIdSchema,
@ -67,6 +69,62 @@ async function getMissingSources(
return [...sourceIds].filter(sourceId => !existingSourceIds.has(sourceId));
}
type SavedQueryLanguage = z.infer<typeof whereLanguageSchema>;
function resolveSavedQueryLanguage(params: {
savedQuery: string | null | undefined;
savedQueryLanguage: SavedQueryLanguage | null | undefined;
}): SavedQueryLanguage | null | undefined {
const { savedQuery, savedQueryLanguage } = params;
if (savedQueryLanguage !== undefined) return savedQueryLanguage;
if (savedQuery === null) return null;
if (savedQuery) return 'lucene';
return undefined;
}
const dashboardBodyBaseShape = {
name: z.string().max(1024),
tiles: externalDashboardTileListSchema,
tags: tagsSchema,
savedQuery: z.string().nullable().optional(),
savedQueryLanguage: whereLanguageSchema.nullable().optional(),
savedFilterValues: z
.array(externalDashboardSavedFilterValueSchema)
.optional(),
};
function buildDashboardBodySchema(filterSchema: z.ZodTypeAny): z.ZodEffects<
z.ZodObject<
typeof dashboardBodyBaseShape & {
filters: z.ZodOptional<z.ZodArray<z.ZodTypeAny>>;
}
>
> {
return z
.object({
...dashboardBodyBaseShape,
filters: z.array(filterSchema).optional(),
})
.superRefine((data, ctx) => {
if (data.savedQuery != null && data.savedQueryLanguage === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'savedQueryLanguage cannot be null when savedQuery is provided',
path: ['savedQueryLanguage'],
});
}
});
}
const createDashboardBodySchema = buildDashboardBodySchema(
externalDashboardFilterSchema,
);
const updateDashboardBodySchema = buildDashboardBodySchema(
externalDashboardFilterSchemaWithId,
);
/**
* @openapi
* components:
@ -83,6 +141,18 @@ async function getMissingSources(
* type: string
* enum: [sql, lucene]
* description: Query language for the where clause.
* SavedFilterValue:
* type: object
* required: [condition]
* properties:
* type:
* type: string
* enum: [sql]
* default: sql
* condition:
* type: string
* description: SQL filter condition. For example use expressions in the form "column IN ('value')".
* example: "ServiceName IN ('hdx-oss-dev-api')"
* MetricDataType:
* type: string
* enum: [sum, gauge, histogram, summary, exponential histogram]
@ -838,6 +908,22 @@ async function getMissingSources(
* description: Dashboard filter keys added to the dashboard and applied to all tiles
* items:
* $ref: '#/components/schemas/Filter'
* savedQuery:
* type: string
* nullable: true
* description: Optional default dashboard query restored when loading the dashboard.
* example: "service.name = 'api'"
* savedQueryLanguage:
* $ref: '#/components/schemas/QueryLanguage'
* nullable: true
* description: Query language used by savedQuery.
* default: "lucene"
* example: "sql"
* savedFilterValues:
* type: array
* description: Optional default dashboard filter values restored when loading the dashboard.
* items:
* $ref: '#/components/schemas/SavedFilterValue'
*
* CreateDashboardRequest:
* type: object
@ -865,6 +951,22 @@ async function getMissingSources(
* description: Dashboard filter keys to add to the dashboard and apply across all tiles
* items:
* $ref: '#/components/schemas/FilterInput'
* savedQuery:
* type: string
* nullable: true
* description: Optional default dashboard query to persist on the dashboard.
* example: "service.name = 'api'"
* savedQueryLanguage:
* $ref: '#/components/schemas/QueryLanguage'
* nullable: true
* description: Query language used by savedQuery.
* default: "lucene"
* example: "sql"
* savedFilterValues:
* type: array
* description: Optional default dashboard filter values to persist on the dashboard.
* items:
* $ref: '#/components/schemas/SavedFilterValue'
*
* UpdateDashboardRequest:
* type: object
@ -893,6 +995,22 @@ async function getMissingSources(
* description: Dashboard filter keys on the dashboard, applied across all tiles
* items:
* $ref: '#/components/schemas/Filter'
* savedQuery:
* type: string
* nullable: true
* description: Optional default dashboard query to persist on the dashboard.
* example: "service.name = 'api'"
* savedQueryLanguage:
* $ref: '#/components/schemas/QueryLanguage'
* nullable: true
* description: Query language used by savedQuery.
* default: "lucene"
* example: "sql"
* savedFilterValues:
* type: array
* description: Optional default dashboard filter values to persist on the dashboard.
* items:
* $ref: '#/components/schemas/SavedFilterValue'
*
* DashboardResponse:
* allOf:
@ -994,7 +1112,16 @@ router.get('/', async (req, res, next) => {
const dashboards = await Dashboard.find(
{ team: teamId },
{ _id: 1, name: 1, tiles: 1, tags: 1, filters: 1 },
{
_id: 1,
name: 1,
tiles: 1,
tags: 1,
filters: 1,
savedQuery: 1,
savedQueryLanguage: 1,
savedFilterValues: 1,
},
).sort({ name: -1 });
res.json({
@ -1102,7 +1229,16 @@ router.get(
const dashboard = await Dashboard.findOne(
{ team: teamId, _id: req.params.id },
{ _id: 1, name: 1, tiles: 1, tags: 1, filters: 1 },
{
_id: 1,
name: 1,
tiles: 1,
tags: 1,
filters: 1,
savedQuery: 1,
savedQueryLanguage: 1,
savedFilterValues: 1,
},
);
if (dashboard == null) {
@ -1252,12 +1388,7 @@ router.get(
router.post(
'/',
validateRequest({
body: z.object({
name: z.string().max(1024),
tiles: externalDashboardTileListSchema,
tags: tagsSchema,
filters: z.array(externalDashboardFilterSchema).optional(),
}),
body: createDashboardBodySchema,
}),
async (req, res, next) => {
try {
@ -1266,7 +1397,15 @@ router.post(
return res.sendStatus(403);
}
const { name, tiles, tags, filters } = req.body;
const {
name,
tiles,
tags,
filters,
savedQuery,
savedQueryLanguage,
savedFilterValues,
} = req.body;
const missingSources = await getMissingSources(teamId, tiles, filters);
if (missingSources.length > 0) {
@ -1299,11 +1438,19 @@ router.post(
}),
);
const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({
savedQuery,
savedQueryLanguage,
});
const newDashboard = await new Dashboard({
name,
tiles: internalTiles,
tags: tags && uniq(tags),
filters: filtersWithIds,
savedQuery,
savedQueryLanguage: normalizedSavedQueryLanguage,
savedFilterValues,
team: teamId,
}).save();
@ -1460,12 +1607,7 @@ router.put(
params: z.object({
id: objectIdSchema,
}),
body: z.object({
name: z.string().max(1024),
tiles: externalDashboardTileListSchema,
tags: tagsSchema,
filters: z.array(externalDashboardFilterSchemaWithId).optional(),
}),
body: updateDashboardBodySchema,
}),
async (req, res, next) => {
try {
@ -1478,7 +1620,15 @@ router.put(
return res.sendStatus(400);
}
const { name, tiles, tags, filters } = req.body ?? {};
const {
name,
tiles,
tags,
filters,
savedQuery,
savedQueryLanguage,
savedFilterValues,
} = req.body ?? {};
const missingSources = await getMissingSources(teamId, tiles, filters);
if (missingSources.length > 0) {
@ -1528,6 +1678,19 @@ router.put(
},
);
}
if (savedQuery !== undefined) {
setPayload.savedQuery = savedQuery;
}
const normalizedSavedQueryLanguage = resolveSavedQueryLanguage({
savedQuery,
savedQueryLanguage,
});
if (normalizedSavedQueryLanguage !== undefined) {
setPayload.savedQueryLanguage = normalizedSavedQueryLanguage;
}
if (savedFilterValues !== undefined) {
setPayload.savedFilterValues = savedFilterValues;
}
const updatedDashboard = await Dashboard.findOneAndUpdate(
{ _id: dashboardId, team: teamId },

View file

@ -47,6 +47,9 @@ export type ExternalDashboard = {
tiles: ExternalDashboardTileWithId[];
tags?: string[];
filters?: ExternalDashboardFilterWithId[];
savedQuery?: string | null;
savedQueryLanguage?: string | null;
savedFilterValues?: DashboardDocument['savedFilterValues'];
};
// --------------------------------------------------------------------------------
@ -196,6 +199,9 @@ export function convertToExternalDashboard(
tiles: dashboard.tiles.map(convertTileToExternalChart),
tags: dashboard.tags || [],
filters: dashboard.filters?.map(translateFilterToExternalFilter) || [],
savedQuery: dashboard.savedQuery ?? null,
savedQueryLanguage: dashboard.savedQueryLanguage ?? null,
savedFilterValues: dashboard.savedFilterValues ?? [],
};
}

View file

@ -143,6 +143,15 @@ export type ExternalDashboardFilter = z.infer<
typeof externalDashboardFilterSchema
>;
export const externalDashboardSavedFilterValueSchema = z.object({
type: z.literal('sql').optional().default('sql'),
condition: z.string().max(10000),
});
export type ExternalDashboardSavedFilterValue = z.infer<
typeof externalDashboardSavedFilterValueSchema
>;
// ================================
// Dashboards (new format)
// ================================

View file

@ -913,7 +913,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
: null;
const currentFilterValues = rawFilterQueries?.length
? rawFilterQueries
: null;
: [];
setDashboard(
produce(dashboard, draft => {
@ -946,7 +946,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
produce(dashboard, draft => {
draft.savedQuery = null;
draft.savedQueryLanguage = null;
draft.savedFilterValues = null;
draft.savedFilterValues = [];
}),
() => {
notifications.show({

View file

@ -32,7 +32,7 @@ export type Dashboard = {
filters?: DashboardFilter[];
savedQuery?: string | null;
savedQueryLanguage?: SearchConditionLanguage | null;
savedFilterValues?: Filter[] | null;
savedFilterValues?: Filter[];
};
export function useUpdateDashboard() {

View file

@ -572,7 +572,7 @@ export const DashboardSchema = z.object({
filters: z.array(DashboardFilterSchema).optional(),
savedQuery: z.string().nullable().optional(),
savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(),
savedFilterValues: z.array(FilterSchema).nullable().optional(),
savedFilterValues: z.array(FilterSchema).optional(),
});
export const DashboardWithoutIdSchema = DashboardSchema.omit({ id: true });
export type DashboardWithoutId = z.infer<typeof DashboardWithoutIdSchema>;