Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions hyperdx/server/lib/coerce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import { z } from "zod";
import { arr, num } from "./coerce.ts";
import { queryChartDataInputSchema } from "./types.ts";

describe("arr", () => {
const schema = arr(z.array(z.string()));

test("passes arrays through unchanged", () => {
expect(schema.parse(["a", "b"])).toEqual(["a", "b"]);
});

test("parses JSON-encoded array string", () => {
expect(schema.parse('["a","b"]')).toEqual(["a", "b"]);
});

test("tolerates whitespace around JSON string", () => {
expect(schema.parse(' ["a","b"] ')).toEqual(["a", "b"]);
});

test("non-JSON-looking string falls through to inner schema (errors)", () => {
expect(() => schema.parse("not-an-array")).toThrow();
});

test("malformed JSON string falls through to inner schema (errors)", () => {
expect(() => schema.parse("[a, b]")).toThrow();
});

test("respects optional + default on inner schema", () => {
const optionalSchema = arr(z.array(z.string()).optional().default(["x"]));
expect(optionalSchema.parse(undefined)).toEqual(["x"]);
expect(optionalSchema.parse('["a"]')).toEqual(["a"]);
});

test("parses nested object arrays", () => {
const objSchema = arr(z.array(z.object({ k: z.string() })));
expect(objSchema.parse('[{"k":"v"}]')).toEqual([{ k: "v" }]);
});
});

describe("num", () => {
test("passes numbers through", () => {
expect(num().parse(42)).toBe(42);
});

test("coerces numeric string", () => {
expect(num().parse("42")).toBe(42);
});

test("rejects non-numeric string", () => {
expect(() => num().parse("abc")).toThrow();
});
});

describe("queryChartDataInputSchema with stringified series", () => {
test("accepts a JSON-encoded series array", () => {
const result = queryChartDataInputSchema.parse({
series:
'[{"dataSource":"events","aggFn":"count","where":"","groupBy":["service"]}]',
});
expect(result.series).toEqual([
{
dataSource: "events",
aggFn: "count",
where: "",
groupBy: ["service"],
},
]);
});

test("accepts JSON-encoded groupBy inside series", () => {
const result = queryChartDataInputSchema.parse({
series: [
{
dataSource: "events",
aggFn: "count",
where: "",
groupBy: '["service","level"]',
},
],
});
expect(result.series[0]?.groupBy).toEqual(["service", "level"]);
});
});
33 changes: 33 additions & 0 deletions hyperdx/server/lib/coerce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Input coercion helpers.
*
* Some MCP transports JSON-encode structured params (arrays, objects) as
* strings before delivering them to the server, and stringify numeric params.
* These helpers make input schemas tolerant of that quirk so calls like
* `{ groupBy: '["service","level"]' }` or `{ limit: "100" }` succeed.
*/

import { z } from "zod";

const parseJsonString = (value: unknown): unknown => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (
!(
(trimmed.startsWith("[") && trimmed.endsWith("]")) ||
(trimmed.startsWith("{") && trimmed.endsWith("}"))
)
) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
};

export const arr = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess(parseJsonString, schema);

export const num = () => z.coerce.number();
5 changes: 3 additions & 2 deletions hyperdx/server/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { z } from "zod";
import { arr } from "./coerce.ts";
import { TIME_INPUT_DESCRIPTION, TimeInputSchema } from "./time.ts";

// ============================================================================
Expand Down Expand Up @@ -122,7 +123,7 @@ const SerieSchema = z.object({
.describe(
"Search query filter (e.g., 'level:error service:\"my-service\"').",
),
groupBy: z.array(z.string()).describe("Fields to group results by."),
groupBy: arr(z.array(z.string())).describe("Fields to group results by."),
metricDataType: z
.enum(["Sum", "Gauge", "Histogram"])
.optional()
Expand All @@ -141,7 +142,7 @@ export const queryChartDataInputSchema = z.object({
granularity: GranularitySchema.optional()
.default("1 minute")
.describe("Time bucket granularity for aggregation. Defaults to 1 minute."),
series: z.array(SerieSchema).describe("Array of series to query."),
series: arr(z.array(SerieSchema)).describe("Array of series to query."),
seriesReturnType: z
.enum(["column", "ratio"])
.optional()
Expand Down
11 changes: 6 additions & 5 deletions hyperdx/server/tools/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createTool } from "@decocms/runtime/tools";
import { z } from "zod";
import type { Env } from "../main.ts";
import { createHyperDXClient } from "../lib/client.ts";
import { arr, num } from "../lib/coerce.ts";
import { getHyperDXApiKey } from "../lib/env.ts";

// ============================================================================
Expand All @@ -29,7 +30,7 @@ const AlertIntervalSchema = z.enum([
const AlertChannelSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
recipients: z.array(z.string()).describe("Email addresses to notify."),
recipients: arr(z.array(z.string())).describe("Email addresses to notify."),
}),
z.object({
type: z.literal("slack"),
Expand Down Expand Up @@ -125,9 +126,9 @@ export const createCreateAlertTool = (_env: Env) =>
interval: AlertIntervalSchema.describe(
"How often to evaluate the alert condition.",
),
threshold: z
.number()
.describe("Numeric threshold value that triggers the alert."),
threshold: num().describe(
"Numeric threshold value that triggers the alert.",
),
threshold_type: z
.enum(["above", "below"])
.describe("Fire when the value is 'above' or 'below' the threshold."),
Expand Down Expand Up @@ -196,7 +197,7 @@ export const createUpdateAlertTool = (_env: Env) =>
inputSchema: z.object({
id: z.string().describe("The alert ID to update."),
interval: AlertIntervalSchema.optional(),
threshold: z.number().optional(),
threshold: num().optional(),
threshold_type: z.enum(["above", "below"]).optional(),
source: z.enum(["chart", "search"]).optional(),
channel: AlertChannelSchema.optional(),
Expand Down
41 changes: 19 additions & 22 deletions hyperdx/server/tools/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createTool } from "@decocms/runtime/tools";
import { z } from "zod";
import type { Env } from "../main.ts";
import { createHyperDXClient } from "../lib/client.ts";
import { arr, num } from "../lib/coerce.ts";
import { getHyperDXApiKey } from "../lib/env.ts";

// ============================================================================
Expand Down Expand Up @@ -48,7 +49,7 @@ const DashboardSeriesSchema = z.object({
aggFn: DashboardAggFnSchema.describe("Aggregation function."),
field: z.string().optional().describe("Field to aggregate."),
where: z.string().describe("Search filter query."),
groupBy: z.array(z.string()).describe("Fields to group by."),
groupBy: arr(z.array(z.string())).describe("Fields to group by."),
metricDataType: z
.enum(["Sum", "Gauge", "Histogram"])
.optional()
Expand All @@ -58,13 +59,13 @@ const DashboardSeriesSchema = z.object({
const DashboardChartSchema = z.object({
id: z.string().optional().describe("Chart ID (auto-generated on create)."),
name: z.string().describe("Chart display name."),
x: z.number().describe("Grid column position (0-based)."),
y: z.number().describe("Grid row position (0-based)."),
w: z.number().describe("Width in grid units."),
h: z.number().describe("Height in grid units."),
series: z
.array(DashboardSeriesSchema)
.describe("Data series for this chart (up to 5)."),
x: num().describe("Grid column position (0-based)."),
y: num().describe("Grid row position (0-based)."),
w: num().describe("Width in grid units."),
h: num().describe("Height in grid units."),
series: arr(z.array(DashboardSeriesSchema)).describe(
"Data series for this chart (up to 5).",
),
});

// ============================================================================
Expand Down Expand Up @@ -128,16 +129,12 @@ export const createCreateDashboardTool = (_env: Env) =>
.describe(
"Global filter applied to all charts on the dashboard (e.g., 'env:production').",
),
tags: z
.array(z.string())
.optional()
.default([])
.describe("Organizational tags for the dashboard."),
charts: z
.array(DashboardChartSchema)
.describe(
"Array of charts to include. Each chart needs a name, grid position (x/y/w/h), and series. Charts are placed on a grid — typical width is 12 units total.",
),
tags: arr(z.array(z.string()).optional().default([])).describe(
"Organizational tags for the dashboard.",
),
charts: arr(z.array(DashboardChartSchema)).describe(
"Array of charts to include. Each chart needs a name, grid position (x/y/w/h), and series. Charts are placed on a grid — typical width is 12 units total.",
),
}),
outputSchema: z.record(z.string(), z.unknown()),
execute: async ({ context, runtimeContext }) => {
Expand All @@ -164,10 +161,10 @@ export const createUpdateDashboardTool = (_env: Env) =>
.optional()
.default("")
.describe("Global filter applied to all charts."),
tags: z.array(z.string()).optional().default([]),
charts: z
.array(DashboardChartSchema)
.describe("Full replacement chart array."),
tags: arr(z.array(z.string()).optional().default([])),
charts: arr(z.array(DashboardChartSchema)).describe(
"Full replacement chart array.",
),
}),
outputSchema: z.record(z.string(), z.unknown()),
execute: async ({ context, runtimeContext }) => {
Expand Down
52 changes: 20 additions & 32 deletions hyperdx/server/tools/hyperdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createTool } from "@decocms/runtime/tools";
import { z } from "zod";
import type { Env } from "../main.ts";
import { createHyperDXClient } from "../lib/client.ts";
import { arr, num } from "../lib/coerce.ts";
import { getHyperDXApiKey } from "../lib/env.ts";
import {
resolveTime,
Expand Down Expand Up @@ -43,8 +44,7 @@ export const createSearchLogsTool = (_env: Env) =>
endTime: TimeInputSchema.optional()
.default(() => Date.now())
.describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`),
limit: z
.number()
limit: num()
.optional()
.default(50)
.describe("Max number of distinct messages to return. Defaults to 50."),
Expand Down Expand Up @@ -112,13 +112,11 @@ export const createGetLogDetailsTool = (_env: Env) =>
query: z
.string()
.describe("Search query (e.g., 'level:error service:admin')."),
groupBy: z
.array(z.string())
.optional()
.default(DEFAULT_GROUP_BY)
.describe(
"Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.",
),
groupBy: arr(
z.array(z.string()).optional().default(DEFAULT_GROUP_BY),
).describe(
"Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.",
),
startTime: TimeInputSchema.optional()
.default(() => Date.now() - 15 * 60 * 1000)
.describe(
Expand All @@ -127,8 +125,7 @@ export const createGetLogDetailsTool = (_env: Env) =>
endTime: TimeInputSchema.optional()
.default(() => Date.now())
.describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`),
limit: z
.number()
limit: num()
.optional()
.default(20)
.describe("Max entries to return. Defaults to 20."),
Expand Down Expand Up @@ -264,13 +261,11 @@ export const createQuerySpansTool = (_env: Env) =>
.describe(
"Field to aggregate. Defaults to 'duration' (span duration in ms). Use 'duration' for latency analysis.",
),
groupBy: z
.array(z.string())
.optional()
.default(["span_name", "service"])
.describe(
"Fields to group by. Defaults to ['span_name', 'service']. Other useful fields: http.method, http.status_code, db.system.",
),
groupBy: arr(
z.array(z.string()).optional().default(["span_name", "service"]),
).describe(
"Fields to group by. Defaults to ['span_name', 'service']. Other useful fields: http.method, http.status_code, db.system.",
),
granularity: z
.enum([
"30 second",
Expand All @@ -296,8 +291,7 @@ export const createQuerySpansTool = (_env: Env) =>
endTime: TimeInputSchema.optional()
.default(() => Date.now())
.describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`),
limit: z
.number()
limit: num()
.optional()
.default(20)
.describe("Max entries to return. Defaults to 20."),
Expand Down Expand Up @@ -388,13 +382,9 @@ export const createQueryMetricsTool = (_env: Env) =>
.describe(
"Filter query (e.g., 'host:web-01', 'k8s.namespace:production'). Leave empty to query all.",
),
groupBy: z
.array(z.string())
.optional()
.default([])
.describe(
"Fields to group by (e.g., ['host'], ['k8s.pod.name'], ['service']). Leave empty for a single aggregated series.",
),
groupBy: arr(z.array(z.string()).optional().default([])).describe(
"Fields to group by (e.g., ['host'], ['k8s.pod.name'], ['service']). Leave empty for a single aggregated series.",
),
granularity: z
.enum([
"30 second",
Expand Down Expand Up @@ -571,11 +561,9 @@ export const createCompareTimeRangesTool = (_env: Env) =>
priorEnd: TimeInputSchema.describe(
`End of the prior/baseline period. ${TIME_INPUT_DESCRIPTION}`,
),
groupBy: z
.array(z.string())
.optional()
.default([])
.describe("Fields to group the comparison by (e.g., ['service'])."),
groupBy: arr(z.array(z.string()).optional().default([])).describe(
"Fields to group the comparison by (e.g., ['service']).",
),
}),
outputSchema: z.object({
description: z.string(),
Expand Down
Loading