Skip to content
Open
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
202 changes: 202 additions & 0 deletions hyperdx/server/lib/time.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
153 changes: 153 additions & 0 deletions hyperdx/server/lib/time.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TimeInputSchema>;

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<string, number> = {
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±<duration>"
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}`;
13 changes: 5 additions & 8 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 { TIME_INPUT_DESCRIPTION, TimeInputSchema } from "./time.ts";

// ============================================================================
// HyperDX API Types
Expand Down Expand Up @@ -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."),
Expand Down
Loading
Loading