From 9803431e498ce04ded386bcbd3ac4af5886c6d91 Mon Sep 17 00:00:00 2001 From: Takaaki Abe Date: Tue, 13 Oct 2026 12:41:21 +0900 Subject: [PATCH] fix(tinybird): reject timezones whose offset is not a whole hour in the hourly status pipes --- packages/tinybird/src/client.test.ts | 48 ++++++++++++++++++++++++++++ packages/tinybird/src/client.ts | 37 ++++++++++++++++++--- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/tinybird/src/client.test.ts b/packages/tinybird/src/client.test.ts index 17b015f40..826228e0c 100644 --- a/packages/tinybird/src/client.test.ts +++ b/packages/tinybird/src/client.test.ts @@ -156,3 +156,51 @@ describe("response-log facet pipes", () => { }); } }); + +const status60mGetters = [ + "httpStatus60m", + "tcpStatus60m", + "dnsStatus60m", + "icmpStatus60m", + "grpcStatus60m", +] as const; + +describe("hourly status pipes", () => { + const tb = new OSTinybird({ token: "a-token" }); + + for (const name of status60mGetters) { + for (const timezone of [ + "Asia/Kolkata", + "Asia/Kathmandu", + "Australia/Lord_Howe", + "Mars/Olympus", + ]) { + test(`${name} rejects ${timezone}, whose days don't start on a UTC hour`, async () => { + await expect( + tb[name]({ monitorIds: ["1"], timezone }), + ).rejects.toThrow(); + }); + } + + test(`${name} accepts whole-hour timezones, with or without DST`, async () => { + for (const timezone of [ + "UTC", + "Asia/Tokyo", + "Europe/Berlin", + "America/New_York", + ]) { + expect(await tb[name]({ monitorIds: ["1"], timezone })).toEqual({ + meta: [], + data: [], + }); + } + }); + + test(`${name} defaults to UTC`, async () => { + expect(await tb[name]({ monitorIds: ["1"] })).toEqual({ + meta: [], + data: [], + }); + }); + } +}); diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index be2918e14..c74c27d29 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -14,6 +14,33 @@ const PUBLIC_CACHE = 300; // 5 * 60 = 300s = 5m const DEV_CACHE = 10 * 60; // 10m const REVALIDATE = env.NODE_ENV === "development" ? DEV_CACHE : 0; +// The 60m pipes group UTC-hour buckets into local days, which only lines up +// when every offset the zone uses is a whole number of hours. January and +// July cover both sides of DST. +function isWholeHourTimeZone(timeZone: string): boolean { + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "longOffset", + }); + } catch (error) { + if (error instanceof RangeError) return false; + throw error; + } + const year = new Date().getUTCFullYear(); + return [0, 6].every((month) => { + const offset = format + .formatToParts(new Date(Date.UTC(year, month, 1))) + .find((p) => p.type === "timeZoneName")?.value; + return offset === "GMT" || /^GMT[+-]\d{2}:00$/.test(offset ?? ""); + }); +} + +const wholeHourTimeZone = z + .string() + .refine(isWholeHourTimeZone, "timezone offset must be a whole hour"); + // Daily-aggregate columns shared by the external-status history pipes (page- // level and per-component). One shape so the `day` GMT-parse can't drift. const externalStatusHistoryDailyShape = { @@ -817,7 +844,7 @@ export class OSTinybird { pipe: "endpoint__http_status_60m__v0", parameters: z.object({ monitorIds: z.string().array(), - timezone: z.string().prefault("UTC"), + timezone: wholeHourTimeZone.prefault("UTC"), }), data: z.object({ day: z.number().transform((ms) => new Date(ms).toISOString()), @@ -1403,7 +1430,7 @@ export class OSTinybird { pipe: "endpoint__tcp_status_60m__v0", parameters: z.object({ monitorIds: z.string().array(), - timezone: z.string().prefault("UTC"), + timezone: wholeHourTimeZone.prefault("UTC"), }), data: z.object({ day: z.number().transform((ms) => new Date(ms).toISOString()), @@ -1822,7 +1849,7 @@ export class OSTinybird { pipe: "endpoint__icmp_status_60m__v0", parameters: z.object({ monitorIds: z.string().array(), - timezone: z.string().prefault("UTC"), + timezone: wholeHourTimeZone.prefault("UTC"), }), data: z.object({ day: z.number().transform((ms) => new Date(ms).toISOString()), @@ -2312,7 +2339,7 @@ export class OSTinybird { pipe: "endpoint__grpc_status_60m__v0", parameters: z.object({ monitorIds: z.string().array(), - timezone: z.string().prefault("UTC"), + timezone: wholeHourTimeZone.prefault("UTC"), }), data: z.object({ day: z.number().transform((ms) => new Date(ms).toISOString()), @@ -3617,7 +3644,7 @@ export class OSTinybird { pipe: "endpoint__dns_status_60m__v0", parameters: z.object({ monitorIds: z.string().array(), - timezone: z.string().prefault("UTC"), + timezone: wholeHourTimeZone.prefault("UTC"), }), data: z.object({ day: z.number().transform((ms) => new Date(ms).toISOString()), -- 2.51.2