diff --git a/hyperdx/server/lib/time.test.ts b/hyperdx/server/lib/time.test.ts new file mode 100644 index 00000000..201a2bd7 --- /dev/null +++ b/hyperdx/server/lib/time.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTime, resolveTimeRange } from "./time.ts"; + +const NOW = 1777324800000; // 2026-04-25T00:00:00.000Z — fixed anchor + +describe("resolveTime", () => { + describe("number input", () => { + test("epoch ms passes through", () => { + expect(resolveTime(1777037400000)).toBe(1777037400000); + }); + + test("rejects NaN", () => { + expect(() => resolveTime(Number.NaN)).toThrow(/Invalid epoch ms/); + }); + + test("rejects Infinity", () => { + expect(() => resolveTime(Number.POSITIVE_INFINITY)).toThrow( + /Invalid epoch ms/, + ); + }); + }); + + describe("integer string", () => { + test("treated as epoch ms", () => { + expect(resolveTime("1777037400000")).toBe(1777037400000); + }); + + test("negative integer string is allowed (pre-1970)", () => { + expect(resolveTime("-1000")).toBe(-1000); + }); + }); + + describe("'now' arithmetic", () => { + test("'now' returns the anchor", () => { + expect(resolveTime("now", { now: NOW })).toBe(NOW); + }); + + test("'now-1h' subtracts an hour", () => { + expect(resolveTime("now-1h", { now: NOW })).toBe(NOW - 3_600_000); + }); + + test("'now+15m' adds 15 minutes", () => { + expect(resolveTime("now+15m", { now: NOW })).toBe(NOW + 900_000); + }); + + test("whitespace around sign is tolerated", () => { + expect(resolveTime("now - 30m", { now: NOW })).toBe(NOW - 1_800_000); + }); + + test("compound 'now-2h30m'", () => { + expect(resolveTime("now-2h30m", { now: NOW })).toBe(NOW - 9_000_000); + }); + + test("'now-' (no duration) throws", () => { + expect(() => resolveTime("now-", { now: NOW })).toThrow(); + }); + }); + + describe("shorthand 'N ago' duration", () => { + test("'30m'", () => { + expect(resolveTime("30m", { now: NOW })).toBe(NOW - 1_800_000); + }); + + test("'2h'", () => { + expect(resolveTime("2h", { now: NOW })).toBe(NOW - 7_200_000); + }); + + test("'7d'", () => { + expect(resolveTime("7d", { now: NOW })).toBe(NOW - 7 * 86_400_000); + }); + + test("'15s'", () => { + expect(resolveTime("15s", { now: NOW })).toBe(NOW - 15_000); + }); + + test("'500ms'", () => { + expect(resolveTime("500ms", { now: NOW })).toBe(NOW - 500); + }); + + test("compound '2h30m'", () => { + expect(resolveTime("2h30m", { now: NOW })).toBe(NOW - 9_000_000); + }); + + test("compound '1d2h30m'", () => { + expect(resolveTime("1d2h30m", { now: NOW })).toBe( + NOW - 86_400_000 - 7_200_000 - 1_800_000, + ); + }); + + test("malformed duration produces a duration-specific error, not a TZ error", () => { + // '1h-30m' looks like an attempt at duration arithmetic — the error + // should explicitly call out durations, not timezones. + expect(() => resolveTime("1h-30m", { now: NOW })).toThrow( + /Could not parse '1h-30m' as a duration/, + ); + // And the misleading "no timezone" message should not appear. + try { + resolveTime("1h-30m", { now: NOW }); + } catch (e) { + expect((e as Error).message).not.toMatch(/has no timezone/); + } + }); + + test("'1hfoo' is rejected", () => { + expect(() => resolveTime("1hfoo", { now: NOW })).toThrow(); + }); + }); + + describe("ISO 8601 with timezone", () => { + test("UTC 'Z' suffix", () => { + expect(resolveTime("2026-04-24T14:00:00Z")).toBe( + Date.parse("2026-04-24T14:00:00Z"), + ); + }); + + test("negative offset", () => { + expect(resolveTime("2026-04-24T14:00:00-03:00")).toBe( + Date.parse("2026-04-24T14:00:00-03:00"), + ); + }); + + test("positive offset", () => { + expect(resolveTime("2026-04-24T14:00:00+05:30")).toBe( + Date.parse("2026-04-24T14:00:00+05:30"), + ); + }); + + test("with milliseconds", () => { + expect(resolveTime("2026-04-24T14:00:00.123-03:00")).toBe( + Date.parse("2026-04-24T14:00:00.123-03:00"), + ); + }); + + test("GMT-3 worked example matches manual calculation", () => { + // 14:00 in GMT-3 == 17:00 UTC + const result = resolveTime("2026-04-24T14:00:00-03:00"); + expect(new Date(result).toISOString()).toBe("2026-04-24T17:00:00.000Z"); + }); + }); + + describe("date only", () => { + test("treated as UTC midnight", () => { + expect(resolveTime("2026-04-24")).toBe( + Date.parse("2026-04-24T00:00:00Z"), + ); + }); + }); + + describe("rejection cases", () => { + test("naive ISO without timezone produces a timezone-specific error", () => { + expect(() => resolveTime("2026-04-24T14:00:00")).toThrow(/timezone/); + }); + + test("error message instructs how to fix the missing timezone", () => { + expect(() => resolveTime("2026-04-24T14:00:00")).toThrow( + /Append 'Z' for UTC or an offset/, + ); + }); + + test("gibberish throws", () => { + expect(() => resolveTime("tomorrow afternoon")).toThrow(); + }); + + test("empty string throws", () => { + expect(() => resolveTime("")).toThrow(/Empty time value/); + }); + + test("whitespace-only string throws", () => { + expect(() => resolveTime(" ")).toThrow(/Empty time value/); + }); + }); +}); + +describe("resolveTimeRange", () => { + test("uses a single 'now' anchor for both bounds", () => { + const r = resolveTimeRange("1h", "now", { now: NOW }); + expect(r.endTime - r.startTime).toBe(3_600_000); + expect(r.endTime).toBe(NOW); + }); + + test("mixed input shapes resolve correctly", () => { + const r = resolveTimeRange( + "2026-04-24T13:30:00-03:00", + "2026-04-24T14:30:00-03:00", + ); + expect(r.endTime - r.startTime).toBe(3_600_000); + }); + + test("propagates errors from either side", () => { + expect(() => resolveTimeRange("2026-04-24T14:00:00", "now")).toThrow( + /timezone/, + ); + expect(() => resolveTimeRange("now", "")).toThrow(/Empty/); + }); + + test("explicit 'now' option is propagated to both calls", () => { + const fixedNow = 1_000_000_000_000; + const r = resolveTimeRange("now-1h", "now", { now: fixedNow }); + expect(r.endTime).toBe(fixedNow); + expect(r.startTime).toBe(fixedNow - 3_600_000); + }); +}); diff --git a/hyperdx/server/lib/time.ts b/hyperdx/server/lib/time.ts new file mode 100644 index 00000000..3371262d --- /dev/null +++ b/hyperdx/server/lib/time.ts @@ -0,0 +1,153 @@ +/** + * Time-input resolver for HyperDX tool parameters. + * + * Accepts LLM-friendly expressions and resolves them to epoch milliseconds + * so HyperDX tools don't force the LLM to compute `Date.now() ± offset` itself. + */ + +import { z } from "zod"; + +export const TimeInputSchema = z.union([z.number(), z.string()]); +export type TimeInput = z.infer; + +const SIMPLE_DURATION_RE = /^(\d+)\s*(ms|s|m|h|d)$/i; +const NOW_EXPR_RE = /^now(?:\s*([+-])\s*([0-9smhd\s]+))?$/i; +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; +const HAS_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/; +// Heuristic: "looks like someone tried to write a duration" +const DURATION_LIKE_RE = /^[0-9smhd\s]+$/i; + +const UNIT_MS: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +const FORMS_LIST = + "epoch ms (number); ISO 8601 with timezone " + + "(e.g. '2026-04-24T14:00:00-03:00' or '...Z'); 'now', 'now-1h', 'now+15m'; " + + "shorthand duration like '30m', '2h', '7d' (= N ago); date only YYYY-MM-DD " + + "(treated as UTC midnight)."; + +const HELP_TEXT = `Accepted forms: ${FORMS_LIST}`; + +function parseDurationMs(expr: string): number { + const trimmed = expr.trim(); + if (!trimmed) { + throw new Error(`Empty duration. ${HELP_TEXT}`); + } + const simple = SIMPLE_DURATION_RE.exec(trimmed); + if (simple) { + const [, n, unit] = simple; + return Number(n) * UNIT_MS[unit.toLowerCase()]; + } + // Compound like "2h30m" — every token must match contiguously and cover the + // whole string. Local regex (not module-level) so there is no shared state. + const compoundRe = /(\d+)\s*(ms|s|m|h|d)/gi; + let total = 0; + let consumed = 0; + let match: RegExpExecArray | null; + while ((match = compoundRe.exec(trimmed)) !== null) { + const [whole, n, unit] = match; + if (match.index !== consumed) break; + total += Number(n) * UNIT_MS[unit.toLowerCase()]; + consumed += whole.length; + } + if (consumed !== trimmed.length || total === 0) { + throw new Error( + `Could not parse duration '${expr}'. Use forms like '30m', '2h', '7d', '2h30m'. ${HELP_TEXT}`, + ); + } + return total; +} + +/** + * Resolve a user-supplied time value to epoch milliseconds. + * + * Throws if the string is unparseable — the error is surfaced back to the LLM + * via MCP so it can self-correct (e.g. attach a missing timezone). + */ +export function resolveTime(input: TimeInput, opts?: { now?: number }): number { + const now = opts?.now ?? Date.now(); + + if (typeof input === "number") { + if (!Number.isFinite(input)) { + throw new Error(`Invalid epoch ms: ${input}. ${HELP_TEXT}`); + } + return input; + } + + const raw = input.trim(); + if (!raw) { + throw new Error(`Empty time value. ${HELP_TEXT}`); + } + + // Pure integer string → epoch ms + if (/^-?\d+$/.test(raw)) { + return Number(raw); + } + + // "now" or "now±" + const nowMatch = NOW_EXPR_RE.exec(raw); + if (nowMatch) { + const [, sign, dur] = nowMatch; + if (!sign) return now; + const deltaMs = parseDurationMs(dur); + return sign === "+" ? now + deltaMs : now - deltaMs; + } + + // Bare duration like "1h", "30m", "2h30m" → N ago. + // Strict path: composed only of digits, duration units, and whitespace. + if (DURATION_LIKE_RE.test(raw)) { + return now - parseDurationMs(raw); + } + + // Plain date → UTC midnight. + if (DATE_ONLY_RE.test(raw)) { + const parsed = Date.parse(`${raw}T00:00:00Z`); + if (Number.isFinite(parsed)) return parsed; + } + + // Duration-shaped fallback: ends with a unit char and contains a digit, but + // didn't pass the strict path (e.g. "1h-30m", "2h foo"). Surface a + // duration-specific error rather than the generic "no timezone" one. + if (/[smhd]$/i.test(raw) && /\d/.test(raw) && !raw.includes("T")) { + throw new Error( + `Could not parse '${raw}' as a duration. Use forms like '30m', '2h', '7d', '2h30m'. ${HELP_TEXT}`, + ); + } + + // ISO 8601 — require explicit timezone so we don't silently guess. + if (!HAS_TZ_RE.test(raw)) { + throw new Error( + `Timestamp '${raw}' has no timezone. Append 'Z' for UTC or an offset ` + + `like '-03:00'. ${HELP_TEXT}`, + ); + } + + const parsed = Date.parse(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`Could not parse time '${raw}'. ${HELP_TEXT}`); + } + return parsed; +} + +/** + * Convenience: resolve a start/end pair with a shared `now` anchor so + * "now-1h"/"now" resolve against the same instant. + */ +export function resolveTimeRange( + startTime: TimeInput, + endTime: TimeInput, + opts?: { now?: number }, +): { startTime: number; endTime: number } { + const now = opts?.now ?? Date.now(); + return { + startTime: resolveTime(startTime, { now }), + endTime: resolveTime(endTime, { now }), + }; +} + +export const TIME_INPUT_DESCRIPTION = `Accepts: ${FORMS_LIST}`; diff --git a/hyperdx/server/lib/types.ts b/hyperdx/server/lib/types.ts index bd1b041d..a5a195d9 100644 --- a/hyperdx/server/lib/types.ts +++ b/hyperdx/server/lib/types.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; +import { TIME_INPUT_DESCRIPTION, TimeInputSchema } from "./time.ts"; // ============================================================================ // HyperDX API Types @@ -129,18 +130,14 @@ const SerieSchema = z.object({ }); export const queryChartDataInputSchema = z.object({ - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 15 * 60 * 1000) .describe( - "Start time in milliseconds since epoch. Defaults to 15 minutes ago.", + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 15 minutes ago.`, ), - endTime: z - .number() - .optional() + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in milliseconds since epoch. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), granularity: GranularitySchema.optional() .default("1 minute") .describe("Time bucket granularity for aggregation. Defaults to 1 minute."), diff --git a/hyperdx/server/prompts.ts b/hyperdx/server/prompts.ts index bac86ae1..5d8bcb4e 100644 --- a/hyperdx/server/prompts.ts +++ b/hyperdx/server/prompts.ts @@ -10,6 +10,148 @@ import { createPrompt } from "@decocms/runtime"; +// ============================================================================ +// HYPERDX_AGENT_GUIDE (top-level entry point) +// ============================================================================ + +export const agentGuidePrompt = createPrompt({ + name: "HYPERDX_AGENT_GUIDE", + title: "HyperDX Agent — Main Instructions", + description: + "Entry-point system prompt for an agent using the HyperDX MCP. Covers what the MCP does, the recommended onboarding flow, time range syntax, tool selection, search essentials, and common pitfalls.", + execute: () => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: "I am an agent that just connected to the HyperDX MCP. How should I operate?", + }, + }, + { + role: "assistant" as const, + content: { + type: "text" as const, + text: `# HyperDX Agent — Main Instructions + +You are an observability agent backed by the **HyperDX MCP**. HyperDX is an OpenTelemetry-native observability platform; this MCP exposes tools to query logs, traces (spans), and metrics, and to create and manage dashboards and alerts. + +--- + +## 1. Start every session with \`DISCOVER_DATA\` + +HyperDX instances vary wildly — services, log levels, and field schemas are all instance-specific. Running \`DISCOVER_DATA\` first returns a tailored \`agentPrompt\` that enumerates: + +- Active services by event volume +- Actual log levels in use (some instances use \`ok\` instead of \`info\`) +- Top error messages +- Top span operations +- Available dashboards and the fields they query + +Pass \`hints\` with domain keywords if the user's context suggests specific fields to look for. + +\`\`\`json +{ "hints": "checkout payment cloud.provider rendering" } +\`\`\` + +Use the returned \`agentPrompt\` as additional context before answering the user. + +--- + +## 2. Time ranges: pass expressions, not epoch ms + +Every time-range tool accepts flexible inputs — **do not compute epoch milliseconds yourself**. The server has the reliable clock; you don't. + +| Form | Example | Meaning | +|---|---|---| +| number | \`1777037400000\` | Epoch ms (still supported) | +| ISO 8601 w/ timezone | \`"2026-04-24T14:00:00-03:00"\` | Exact instant | +| \`now\` arithmetic | \`"now"\`, \`"now-1h"\`, \`"now+5m"\` | Relative to server clock | +| shorthand duration | \`"30m"\`, \`"2h"\`, \`"7d"\`, \`"2h30m"\` | N ago | +| date only | \`"2026-04-24"\` | UTC midnight | + +**Rule:** When the user names a local wall-clock time ("14:00 GMT-3", "9am EST"), always attach an explicit offset (\`-03:00\`, \`-05:00\`, …). **Naive timestamps are rejected** — the resolver returns an actionable error rather than guessing UTC. + +**Worked example.** User: *"at around 14:00 GMT-3 we had a spike of errors, what was it?"* + +\`\`\`json +{ + "query": "level:error", + "startTime": "2026-04-24T13:30:00-03:00", + "endTime": "2026-04-24T14:30:00-03:00" +} +\`\`\` + +If the phrasing is ambiguous (no timezone, fuzzy "this morning"), call \`RESOLVE_TIME_RANGE\` first to preview the window. **It has no side effects and no API cost — use it liberally.** + +\`\`\`json +{ "startTime": "2h", "endTime": "now" } +// → { startTime: ..., endTime: ..., humanReadable: "... → ... (2.0h)" } +\`\`\` + +--- + +## 3. Tool selection cheat sheet + +| User goal | Tool | +|---|---| +| First time / map the instance | **DISCOVER_DATA** | +| "Show me recent errors" | SEARCH_LOGS | +| "Show me details + trace context" | GET_LOG_DETAILS | +| "Plot error count over time" | QUERY_CHART_DATA | +| "Latency / slow requests / p95" | QUERY_SPANS | +| "CPU / memory / counters" | QUERY_METRICS | +| "Is service X healthy right now?" | GET_SERVICE_HEALTH | +| "Did this get worse vs last week?" | COMPARE_TIME_RANGES | +| "Preview a time window before querying" | RESOLVE_TIME_RANGE | +| "What dashboards exist?" | LIST_DASHBOARDS / GET_DASHBOARD | +| Create dashboard / alert | CREATE_DASHBOARD / CREATE_ALERT | + +--- + +## 4. Search query syntax (essentials) + +The \`where\` and \`query\` parameters use HyperDX search syntax: + +\`\`\` +level:error — property filter +level:error service:"my-app" — AND (implicit); quote spaces +service:api OR service:web — OR +level:error -service:healthcheck — exclude +duration:>1000 — range (spans slower than 1s) +http.status_code:>=500 — HTTP 5xx +trace_id:* — existence check +service:api* — prefix wildcard +level:error "connection refused" — full-text phrase +\`\`\` + +Retrieve the **\`HYPERDX_SEARCH_SYNTAX\`** prompt for the full grammar (boolean operators, wildcards, ranges, existence checks, all common patterns). + +--- + +## 5. Common pitfalls + +1. **\`body\` is overloaded.** It holds **log messages** for logs but **span names** (e.g. "GET", "cache-match") for spans. Always filter by \`level:error\` when you want actual log messages, or by \`duration:>0\` for spans. +2. **Log levels may be non-standard.** Some instances use \`ok\` as the most common level. Never assume — \`DISCOVER_DATA\` reveals what exists. +3. **Default time windows are short.** \`SEARCH_LOGS\` defaults to 15 min. For rare events, extend with \`"startTime": "24h"\` (= 24h ago). +4. **Timezone is mandatory on wall-clock times.** The resolver rejects naive ISO strings to prevent silent UTC guesses. +5. **Learn from dashboards.** Existing dashboards encode battle-tested queries. \`LIST_DASHBOARDS\` + \`GET_DASHBOARD\` show the team's conventions. + +--- + +## 6. Further reading + +- **\`HYPERDX_SEARCH_SYNTAX\`** — complete query grammar reference. +- **\`HYPERDX_QUERY_GUIDE\`** — per-tool examples and an extended pitfalls list. + +Retrieve either via the standard MCP \`prompts/get\` request when needed. +`, + }, + }, + ], + }), +}); + // ============================================================================ // HYPERDX_SEARCH_SYNTAX // ============================================================================ @@ -190,15 +332,54 @@ Pass domain-specific hints to get targeted results: --- +## Time Range Syntax + +Every tool's \`startTime\` and \`endTime\` (plus the four \`currentStart\`/\`currentEnd\`/\`priorStart\`/\`priorEnd\` on \`COMPARE_TIME_RANGES\`) accept any of: + +- **Epoch milliseconds** (number): \`1777037400000\` +- **ISO 8601 with timezone**: \`"2026-04-24T14:00:00-03:00"\`, \`"2026-04-24T14:00:00Z"\`, \`"2026-04-24T14:00:00.123+05:30"\` +- **\`now\` arithmetic**: \`"now"\`, \`"now-1h"\`, \`"now-30m"\`, \`"now+5m"\` +- **Shorthand duration** (interpreted as "N ago"): \`"30m"\`, \`"2h"\`, \`"7d"\`, \`"2h30m"\`, \`"15s"\` +- **Date only** (UTC midnight): \`"2026-04-24"\` + +**Don't compute epoch ms yourself** — pass the expression and let the server resolve it against its reliable clock. + +**Naive timestamps without a timezone are rejected.** If the user says a local time, append an explicit offset. + +### Worked example: "around 14:00 GMT-3 we had a spike of errors" + +1. GMT-3 ≡ offset \`-03:00\`. Convert the local hour into an ISO 8601 string — the server does the epoch math. +2. Pick a ±30 min window around the mentioned instant. +3. Call the query tool: + +\`\`\`json +{ + "query": "level:error", + "startTime": "2026-04-24T13:30:00-03:00", + "endTime": "2026-04-24T14:30:00-03:00" +} +\`\`\` + +If the phrasing is ambiguous, call \`RESOLVE_TIME_RANGE\` first to preview the exact epoch ms the server resolves the expression to. The tool has no side effects and no API cost. + +\`\`\`json +{ "startTime": "2h", "endTime": "now" } +// → { startTime: ..., endTime: ..., humanReadable: "... → ... (2.0h)" } +\`\`\` + +--- + ## Common Pitfalls 1. **body ≠ log message for spans.** The \`body\` field contains span names for spans and log messages for logs. Searching without a level filter returns mostly span names. 2. **Levels may be non-standard.** Some instances use \`ok\` as the most common level. Don't assume standard levels — DISCOVER_DATA reveals what exists. -3. **Default time windows are short.** SEARCH_LOGS defaults to 15 min. For rare events, extend \`startTime\`. +3. **Default time windows are short.** SEARCH_LOGS defaults to 15 min. For rare events, extend \`startTime\` (e.g. \`"24h"\`). + +4. **Always include a timezone for wall‑clock times.** The resolver rejects naive ISO strings to avoid silently guessing UTC. -4. **Learn from dashboards.** Existing dashboards contain battle-tested queries. Use LIST_DASHBOARDS + GET_DASHBOARD to see what fields and filters the team uses. +5. **Learn from dashboards.** Existing dashboards contain battle-tested queries. Use LIST_DASHBOARDS + GET_DASHBOARD to see what fields and filters the team uses. --- @@ -275,4 +456,4 @@ Typical width: 12 units. Common sizes: 6×2 (half), 12×2 (full), 4×2 (third). }), }); -export const prompts = [searchSyntaxPrompt, queryGuidePrompt]; +export const prompts = [agentGuidePrompt, searchSyntaxPrompt, queryGuidePrompt]; diff --git a/hyperdx/server/tools/hyperdx.ts b/hyperdx/server/tools/hyperdx.ts index 3e8c1b78..63330c37 100644 --- a/hyperdx/server/tools/hyperdx.ts +++ b/hyperdx/server/tools/hyperdx.ts @@ -10,6 +10,12 @@ import { z } from "zod"; import type { Env } from "../main.ts"; import { createHyperDXClient } from "../lib/client.ts"; import { getHyperDXApiKey } from "../lib/env.ts"; +import { + resolveTime, + resolveTimeRange, + TIME_INPUT_DESCRIPTION, + TimeInputSchema, +} from "../lib/time.ts"; import { queryChartDataInputSchema, queryChartDataOutputSchema, @@ -29,16 +35,14 @@ export const createSearchLogsTool = (_env: Env) => .describe( "Search query (e.g., 'level:error', 'service:admin', 'level:error service:api').", ), - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 15 * 60 * 1000) - .describe("Start time in ms. Defaults to 15 minutes ago."), - endTime: z - .number() - .optional() + .describe( + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 15 minutes ago.`, + ), + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in ms. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), limit: z .number() .optional() @@ -59,9 +63,14 @@ export const createSearchLogsTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); + const response = await client.queryChartSeries({ - startTime: context.startTime, - endTime: context.endTime, + startTime, + endTime, series: [ { dataSource: "events", @@ -110,16 +119,14 @@ export const createGetLogDetailsTool = (_env: Env) => .describe( "Fields to group by and return. Defaults to ['body', 'service', 'site']. Other useful fields: trace_id, span_id, userEmail, env, level.", ), - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 15 * 60 * 1000) - .describe("Start time in ms. Defaults to 15 minutes ago."), - endTime: z - .number() - .optional() + .describe( + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 15 minutes ago.`, + ), + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in ms. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), limit: z .number() .optional() @@ -142,9 +149,14 @@ export const createGetLogDetailsTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); + const response = await client.queryChartSeries({ - startTime: context.startTime, - endTime: context.endTime, + startTime, + endTime, series: [ { dataSource: "events", @@ -184,8 +196,11 @@ export const createQueryChartDataTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); - const { startTime, endTime, granularity, series, seriesReturnType } = - context; + const { granularity, series, seriesReturnType } = context; + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); const response = await client.queryChartSeries({ startTime, @@ -273,16 +288,14 @@ export const createQuerySpansTool = (_env: Env) => .optional() .default("5 minute") .describe("Time bucket granularity. Defaults to 5 minutes."), - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 60 * 60 * 1000) - .describe("Start time in ms. Defaults to 1 hour ago."), - endTime: z - .number() - .optional() + .describe( + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 1 hour ago.`, + ), + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in ms. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), limit: z .number() .optional() @@ -296,14 +309,19 @@ export const createQuerySpansTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); + // Ensure we only query spans (not logs) by requiring duration:>0 const spanFilter = context.query ? `duration:>0 ${context.query}` : "duration:>0"; const response = await client.queryChartSeries({ - startTime: context.startTime, - endTime: context.endTime, + startTime, + endTime, granularity: context.granularity, series: [ { @@ -394,16 +412,14 @@ export const createQueryMetricsTool = (_env: Env) => .optional() .default("5 minute") .describe("Time bucket granularity. Defaults to 5 minutes."), - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 60 * 60 * 1000) - .describe("Start time in ms. Defaults to 1 hour ago."), - endTime: z - .number() - .optional() + .describe( + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 1 hour ago.`, + ), + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in ms. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), }), outputSchema: z.object({ data: z.array(z.record(z.string(), z.unknown())), @@ -412,9 +428,14 @@ export const createQueryMetricsTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); + const response = await client.queryChartSeries({ - startTime: context.startTime, - endTime: context.endTime, + startTime, + endTime, granularity: context.granularity, series: [ { @@ -456,16 +477,14 @@ export const createGetServiceHealthTool = (_env: Env) => .optional() .default("5 minute") .describe("Time bucket granularity. Defaults to 5 minutes."), - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 60 * 60 * 1000) - .describe("Start time in ms. Defaults to 1 hour ago."), - endTime: z - .number() - .optional() + .describe( + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 1 hour ago.`, + ), + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in ms. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), }), outputSchema: z.object({ description: z.string(), @@ -475,9 +494,14 @@ export const createGetServiceHealthTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); + const response = await client.queryChartSeries({ - startTime: context.startTime, - endTime: context.endTime, + startTime, + endTime, granularity: context.granularity, series: [ { @@ -535,12 +559,18 @@ export const createCompareTimeRangesTool = (_env: Env) => .describe( "Field to aggregate (required for avg, p50, p95, p99, max, sum — e.g., 'duration').", ), - currentStart: z.number().describe("Start of the current period in ms."), - currentEnd: z.number().describe("End of the current period in ms."), - priorStart: z - .number() - .describe("Start of the prior/baseline period in ms."), - priorEnd: z.number().describe("End of the prior/baseline period in ms."), + currentStart: TimeInputSchema.describe( + `Start of the current period. ${TIME_INPUT_DESCRIPTION}`, + ), + currentEnd: TimeInputSchema.describe( + `End of the current period. ${TIME_INPUT_DESCRIPTION}`, + ), + priorStart: TimeInputSchema.describe( + `Start of the prior/baseline period. ${TIME_INPUT_DESCRIPTION}`, + ), + priorEnd: TimeInputSchema.describe( + `End of the prior/baseline period. ${TIME_INPUT_DESCRIPTION}`, + ), groupBy: z .array(z.string()) .optional() @@ -581,16 +611,26 @@ export const createCompareTimeRangesTool = (_env: Env) => groupBy: context.groupBy, }; + // Resolve all four time inputs against a single `now` so "now-1h"-style + // relative values across current/prior stay aligned. + const sharedNow = Date.now(); + const currentStart = resolveTime(context.currentStart, { + now: sharedNow, + }); + const currentEnd = resolveTime(context.currentEnd, { now: sharedNow }); + const priorStart = resolveTime(context.priorStart, { now: sharedNow }); + const priorEnd = resolveTime(context.priorEnd, { now: sharedNow }); + // Query both periods in parallel with separate time windows const [currentRes, priorRes] = await Promise.all([ client.queryChartSeries({ - startTime: context.currentStart, - endTime: context.currentEnd, + startTime: currentStart, + endTime: currentEnd, series: [seriesConfig], }), client.queryChartSeries({ - startTime: context.priorStart, - endTime: context.priorEnd, + startTime: priorStart, + endTime: priorEnd, series: [seriesConfig], }), ]); @@ -661,18 +701,14 @@ export const createDiscoverDataTool = (_env: Env) => .describe( "Domain-specific keywords or field names to search for. The tool will run targeted queries to find how these appear in the data. Example: 'section loader cloud.provider rendering build vtex shopify'. Separate with spaces.", ), - startTime: z - .number() - .optional() + startTime: TimeInputSchema.optional() .default(() => Date.now() - 6 * 60 * 60 * 1000) .describe( - "Start time in ms. Defaults to 6 hours ago for broader coverage.", + `Start time. ${TIME_INPUT_DESCRIPTION} Defaults to 6 hours ago for broader coverage.`, ), - endTime: z - .number() - .optional() + endTime: TimeInputSchema.optional() .default(() => Date.now()) - .describe("End time in ms. Defaults to now."), + .describe(`End time. ${TIME_INPUT_DESCRIPTION} Defaults to now.`), }), outputSchema: z.object({ services: z @@ -744,7 +780,11 @@ export const createDiscoverDataTool = (_env: Env) => const apiKey = getHyperDXApiKey(runtimeContext.env as Env); const client = createHyperDXClient({ apiKey }); - const { startTime, endTime, hints } = context; + const { hints } = context; + const { startTime, endTime } = resolveTimeRange( + context.startTime, + context.endTime, + ); // Parse hints into individual keywords const hintWords = hints @@ -1179,6 +1219,68 @@ If you are setting up monitoring for the first time, follow this flow: }, }); +/** + * RESOLVE_TIME_RANGE - Preview how a time expression resolves to epoch ms. + * + * A cheap no-side-effects tool so the agent can confirm its interpretation + * of user phrasing ("around 14:00 GMT-3 today") before committing to a chart + * query. Accepts the same flexible time inputs as every other tool. + */ +export const createResolveTimeRangeTool = (_env: Env) => + createTool({ + id: "RESOLVE_TIME_RANGE", + description: + "Resolve a pair of time expressions to epoch milliseconds. No side effects, no API cost — call this freely whenever the user's time phrasing is ambiguous (e.g. 'around 14:00 GMT-3') to preview the exact window before running a heavier query. Accepts numbers, ISO 8601 with timezone, 'now'/'now±', and shorthand like '1h', '30m'. Throws an actionable error on unparseable input (e.g. naive ISO without timezone) so you can correct and retry safely.", + inputSchema: z.object({ + startTime: TimeInputSchema.describe( + `Start time. ${TIME_INPUT_DESCRIPTION}`, + ), + endTime: TimeInputSchema.describe(`End time. ${TIME_INPUT_DESCRIPTION}`), + }), + outputSchema: z.object({ + startTime: z.number().describe("Resolved start in epoch milliseconds."), + endTime: z.number().describe("Resolved end in epoch milliseconds."), + startIso: z.string().describe("Resolved start as ISO 8601 (UTC)."), + endIso: z.string().describe("Resolved end as ISO 8601 (UTC)."), + durationMs: z.number().describe("endTime − startTime in milliseconds."), + humanReadable: z + .string() + .describe("Human summary of the resolved window."), + }), + execute: async ({ context }) => { + const sharedNow = Date.now(); + const startTime = resolveTime(context.startTime, { now: sharedNow }); + const endTime = resolveTime(context.endTime, { now: sharedNow }); + const durationMs = endTime - startTime; + + const formatDuration = (ms: number): string => { + const abs = Math.abs(ms); + if (abs < 60_000) return `${Math.round(abs / 1000)}s`; + if (abs < 3_600_000) return `${Math.round(abs / 60_000)}m`; + if (abs < 86_400_000) { + return `${(abs / 3_600_000).toFixed(1)}h`; + } + return `${(abs / 86_400_000).toFixed(1)}d`; + }; + + const startIso = new Date(startTime).toISOString(); + const endIso = new Date(endTime).toISOString(); + const humanReadable = + durationMs >= 0 + ? `${startIso} → ${endIso} (${formatDuration(durationMs)})` + : `Invalid: endTime precedes startTime by ${formatDuration(durationMs)}`; + + return { + startTime, + endTime, + startIso, + endIso, + durationMs, + humanReadable, + }; + }, + }); + // Export all tools as an array export const hyperdxTools = [ createSearchLogsTool, @@ -1189,6 +1291,7 @@ export const hyperdxTools = [ createGetServiceHealthTool, createCompareTimeRangesTool, createDiscoverDataTool, + createResolveTimeRangeTool, // SUGGEST_AUTOMATIONS is not a separate tool — it's a section of the DISCOVER_DATA agentPrompt. // After running DISCOVER_DATA, the agent has all the context needed to suggest automations // using CREATE_ALERT and CREATE_DASHBOARD tools directly. diff --git a/hyperdx/server/tools/index.ts b/hyperdx/server/tools/index.ts index e841034a..43a4e4c1 100644 --- a/hyperdx/server/tools/index.ts +++ b/hyperdx/server/tools/index.ts @@ -13,6 +13,7 @@ import { createGetServiceHealthTool, createCompareTimeRangesTool, createDiscoverDataTool, + createResolveTimeRangeTool, } from "./hyperdx.ts"; import { alertTools } from "./alerts.ts"; import { dashboardTools } from "./dashboards.ts"; @@ -27,6 +28,7 @@ export const tools = [ createGetServiceHealthTool, createCompareTimeRangesTool, createDiscoverDataTool, + createResolveTimeRangeTool, ...alertTools, ...dashboardTools, ];