diff --git a/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe index af35a85b7..ce72f612b 100644 --- a/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe @@ -2,9 +2,10 @@ NODE endpoint SQL > % +WITH {{ String(timezone, 'UTC') }} AS tz SELECT -- calendar date of the local day; the client re-keys it as UTC midnight like the 45d pipes - toDate(toTimeZone(time, {{ String(timezone, 'UTC') }})) AS day, + toDate(toTimeZone(time, tz)) AS day, monitorId, countMerge(count) AS count, countMerge(success) AS ok, @@ -12,6 +13,9 @@ SELECT countMerge(degraded) AS degraded FROM mv__dns_status_15m__v0 WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + -- start at a local midnight so the oldest day is complete, not cut at the + -- 46-day TTL edge; stays inside the TTL for every zone (at most 45d + 14h) + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY GROUP BY day, monitorId ORDER BY day DESC diff --git a/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe index cb1b0332f..cb821e87c 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe @@ -2,9 +2,10 @@ NODE endpoint SQL > % +WITH {{ String(timezone, 'UTC') }} AS tz SELECT -- calendar date of the local day; the client re-keys it as UTC midnight like the 45d pipes - toDate(toTimeZone(time, {{ String(timezone, 'UTC') }})) AS day, + toDate(toTimeZone(time, tz)) AS day, monitorId, countMerge(count) AS count, countMerge(success) AS ok, @@ -12,6 +13,9 @@ SELECT countMerge(degraded) AS degraded FROM mv__grpc_status_15m__v0 WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + -- start at a local midnight so the oldest day is complete, not cut at the + -- 46-day TTL edge; stays inside the TTL for every zone (at most 45d + 14h) + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY GROUP BY day, monitorId ORDER BY day DESC diff --git a/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe index 18a10cd0b..54fc7e8b2 100644 --- a/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe @@ -2,9 +2,10 @@ NODE endpoint SQL > % +WITH {{ String(timezone, 'UTC') }} AS tz SELECT -- calendar date of the local day; the client re-keys it as UTC midnight like the 45d pipes - toDate(toTimeZone(time, {{ String(timezone, 'UTC') }})) AS day, + toDate(toTimeZone(time, tz)) AS day, monitorId, countMerge(count) AS count, countMerge(success) AS ok, @@ -12,6 +13,9 @@ SELECT countMerge(degraded) AS degraded FROM mv__http_status_15m__v0 WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + -- start at a local midnight so the oldest day is complete, not cut at the + -- 46-day TTL edge; stays inside the TTL for every zone (at most 45d + 14h) + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY GROUP BY day, monitorId ORDER BY day DESC diff --git a/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe index 91efe72cd..99d5eb70d 100644 --- a/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe @@ -2,9 +2,10 @@ NODE endpoint SQL > % +WITH {{ String(timezone, 'UTC') }} AS tz SELECT -- calendar date of the local day; the client re-keys it as UTC midnight like the 45d pipes - toDate(toTimeZone(time, {{ String(timezone, 'UTC') }})) AS day, + toDate(toTimeZone(time, tz)) AS day, monitorId, countMerge(count) AS count, countMerge(success) AS ok, @@ -12,6 +13,9 @@ SELECT countMerge(degraded) AS degraded FROM mv__icmp_status_15m__v0 WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + -- start at a local midnight so the oldest day is complete, not cut at the + -- 46-day TTL edge; stays inside the TTL for every zone (at most 45d + 14h) + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY GROUP BY day, monitorId ORDER BY day DESC diff --git a/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe index 43275fe0f..ae4cbddbd 100644 --- a/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe @@ -2,9 +2,10 @@ NODE endpoint SQL > % +WITH {{ String(timezone, 'UTC') }} AS tz SELECT -- calendar date of the local day; the client re-keys it as UTC midnight like the 45d pipes - toDate(toTimeZone(time, {{ String(timezone, 'UTC') }})) AS day, + toDate(toTimeZone(time, tz)) AS day, monitorId, countMerge(count) AS count, countMerge(success) AS ok, @@ -12,6 +13,9 @@ SELECT countMerge(degraded) AS degraded FROM mv__tcp_status_15m__v0 WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + -- start at a local midnight so the oldest day is complete, not cut at the + -- 46-day TTL edge; stays inside the TTL for every zone (at most 45d + 14h) + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY GROUP BY day, monitorId ORDER BY day DESC diff --git a/packages/tinybird/src/client.test.ts b/packages/tinybird/src/client.test.ts index 1bf010db5..cf40311ec 100644 --- a/packages/tinybird/src/client.test.ts +++ b/packages/tinybird/src/client.test.ts @@ -3,6 +3,7 @@ import { describe, test } from "@std/testing/bdd"; import { z } from "zod"; import { + isValidTimeZone, noopFlagSchema, OSTinybird, status15mParameters, @@ -191,6 +192,17 @@ describe("status15mParameters", () => { ).toBe(timezone); } }); + + test("rejects zone names ClickHouse would 400 on, before the request", () => { + for (const timezone of ["Mars/Olympus", "", "GMT+1"]) { + const result = status15mParameters.safeParse({ + monitorIds: ["1"], + timezone, + }); + expect(result.success).toBe(false); + } + expect(isValidTimeZone("Etc/GMT+1")).toBe(true); + }); }); describe("status15mShape", () => { diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index 8443a1069..cbcbf18bd 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -14,17 +14,40 @@ const PUBLIC_CACHE = 300; // 5 * 60 = 300s = 5m const DEV_CACHE = 10 * 60; // 10m const REVALIDATE = env.NODE_ENV === "development" ? DEV_CACHE : 0; +// Cheaper than a Tinybird 400 round trip for a zone ClickHouse won't know. +const timeZoneValidity = new Map(); + +export function isValidTimeZone(timeZone: string): boolean { + let valid = timeZoneValidity.get(timeZone); + if (valid === undefined) { + try { + new Intl.DateTimeFormat("en-US", { timeZone }); + valid = true; + } catch (error) { + if (!(error instanceof RangeError)) throw error; + valid = false; + } + timeZoneValidity.set(timeZone, valid); + } + return valid; +} + // 15-minute buckets regroup into local days for every IANA zone: all current -// offsets are multiples of 15 minutes. ClickHouse rejects unknown zone names. +// offsets are multiples of 15 minutes. export const status15mParameters = z.object({ monitorIds: z.string().array(), - timezone: z.string().prefault("UTC"), + timezone: z + .string() + .refine(isValidTimeZone, "timezone must be an IANA zone name") + .prefault("UTC"), }); /** * Same columns as the 45d pipes. `day` is the local calendar date re-keyed as * UTC midnight, the convention day-keyed consumers already read — a Berlin * 2026-10-14 stays "2026-10-14", not the 22:00Z instant of local midnight. + * `monitorId` is a String column in the http datasource and Int32 elsewhere, + * so the non-http getters pass `z.coerce.string()`. */ export function status15mShape(monitorId: z.ZodType) { return z.object({