From beb5d0ce794cd5609d232cf895b77dd3c952c239 Mon Sep 17 00:00:00 2001 From: Takaaki Abe Date: Thu, 15 Oct 2026 23:57:23 +0900 Subject: [PATCH] feat(tinybird): 15-minute status buckets for timezone-aware status bars (#2897) * feat(tinybird): hourly status buckets grouped into days of a requested timezone The daily status MVs fix the day boundary to UTC when they materialize. Hourly buckets keep enough detail to group into days of any whole-hour timezone at read time, without one MV per timezone. Nothing reads them yet. * fix(tinybird): reject timezones whose offset is not a whole hour in the hourly status pipes * fix(tinybird): name both requirements in the timezone validation message * fix(tinybird): return the local calendar date from the hourly status pipes - `day` is now a `YYYY-MM-DD` date re-keyed as UTC midnight, the convention the 45d pipes and every day-keyed consumer already use, instead of the epoch-ms instant of local midnight that shifted non-UTC days backwards - enforce the whole-hour timezone rule in the endpoints too (January and July offsets), not only in the TypeScript client - share `status60mParameters`/`status60mShape` across the five getters and memoize the zone check - test the schemas directly; extend the server tinybird double * feat(tinybird): bucket status at 15 minutes so every timezone regroups into days Every current IANA offset is a multiple of 15 minutes, so 15m buckets fold into local days for India, Nepal, Adelaide, Chatham and Newfoundland too. Drops the whole-hour validation on both the client and the endpoints; the pipes, datasources and getters are renamed *_status_15m. * fix(tinybird): bound the 15m status endpoints at a local midnight and validate the zone name - start the query at local midnight 45 days back so the oldest returned day is complete instead of cut at the 46-day TTL edge; the bound stays inside the TTL for every zone (at most 45d + 14h) - reject zone names ClickHouse would 400 on before the request, memoized - note why the http getter alone passes `z.string()` for monitorId * fix(tinybird): keep 47 days in the 15m status MVs and reject offset zone IDs - the endpoints return 45 local days plus today, which spans just under 46d + 1h after a DST fall-back; a 46-day TTL could cut the oldest day, so retain 47 and correct the bound comment (the old "45d + 14h" ignored the elapsed part of today) - `Intl.DateTimeFormat` accepts `+05:30`-style offsets that ClickHouse does not know; refuse them before the request --------- Co-authored-by: Maximilian Kaske --- .../src/libs/test/doubles/tinybird.mock.ts | 15 +++ .../mv__dns_status_15m__v0.datasource | 14 +++ .../mv__grpc_status_15m__v0.datasource | 14 +++ .../mv__http_status_15m__v0.datasource | 14 +++ .../mv__icmp_status_15m__v0.datasource | 14 +++ .../mv__tcp_status_15m__v0.datasource | 14 +++ .../endpoint__dns_status_15m__v0.pipe | 23 ++++ .../endpoint__grpc_status_15m__v0.pipe | 23 ++++ .../endpoint__http_status_15m__v0.pipe | 23 ++++ .../endpoint__icmp_status_15m__v0.pipe | 23 ++++ .../endpoint__tcp_status_15m__v0.pipe | 23 ++++ .../aggregate__dns_status_15m__v0.pipe | 17 +++ .../aggregate__grpc_status_15m__v0.pipe | 17 +++ .../aggregate__http_status_15m__v0.pipe | 17 +++ .../aggregate__icmp_status_15m__v0.pipe | 17 +++ .../aggregate__tcp_status_15m__v0.pipe | 17 +++ packages/tinybird/src/client.test.ts | 113 +++++++++++++++++- packages/tinybird/src/client.ts | 101 ++++++++++++++++ 18 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 packages/tinybird/datasources/mv__dns_status_15m__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_status_15m__v0.datasource create mode 100644 packages/tinybird/datasources/mv__http_status_15m__v0.datasource create mode 100644 packages/tinybird/datasources/mv__icmp_status_15m__v0.datasource create mode 100644 packages/tinybird/datasources/mv__tcp_status_15m__v0.datasource create mode 100644 packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__dns_status_15m__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__grpc_status_15m__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__http_status_15m__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__icmp_status_15m__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__tcp_status_15m__v0.pipe diff --git a/apps/server/src/libs/test/doubles/tinybird.mock.ts b/apps/server/src/libs/test/doubles/tinybird.mock.ts index 2cf71dab6..c3c47e39a 100644 --- a/apps/server/src/libs/test/doubles/tinybird.mock.ts +++ b/apps/server/src/libs/test/doubles/tinybird.mock.ts @@ -17,6 +17,21 @@ export class OSTinybird { get legacy_tcpStatus45d() { return empty; } + get httpStatus15m() { + return empty; + } + get tcpStatus15m() { + return empty; + } + get dnsStatus15m() { + return empty; + } + get icmpStatus15m() { + return empty; + } + get grpcStatus15m() { + return empty; + } get httpListBiweekly() { return (params: unknown) => { calls.httpListBiweekly.push(params); diff --git a/packages/tinybird/datasources/mv__dns_status_15m__v0.datasource b/packages/tinybird/datasources/mv__dns_status_15m__v0.datasource new file mode 100644 index 000000000..0a7593f31 --- /dev/null +++ b/packages/tinybird/datasources/mv__dns_status_15m__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__dns_status_15m__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int16, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(47)" diff --git a/packages/tinybird/datasources/mv__grpc_status_15m__v0.datasource b/packages/tinybird/datasources/mv__grpc_status_15m__v0.datasource new file mode 100644 index 000000000..138327c35 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_status_15m__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__grpc_status_15m__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(47)" diff --git a/packages/tinybird/datasources/mv__http_status_15m__v0.datasource b/packages/tinybird/datasources/mv__http_status_15m__v0.datasource new file mode 100644 index 000000000..8a0d33369 --- /dev/null +++ b/packages/tinybird/datasources/mv__http_status_15m__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__http_status_15m__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` String, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(47)" diff --git a/packages/tinybird/datasources/mv__icmp_status_15m__v0.datasource b/packages/tinybird/datasources/mv__icmp_status_15m__v0.datasource new file mode 100644 index 000000000..fe396789c --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_status_15m__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__icmp_status_15m__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(47)" diff --git a/packages/tinybird/datasources/mv__tcp_status_15m__v0.datasource b/packages/tinybird/datasources/mv__tcp_status_15m__v0.datasource new file mode 100644 index 000000000..40e4e0abc --- /dev/null +++ b/packages/tinybird/datasources/mv__tcp_status_15m__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__tcp_status_15m__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(47)" diff --git a/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe new file mode 100644 index 000000000..ef493021d --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__dns_status_15m__v0.pipe @@ -0,0 +1,23 @@ +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, tz)) AS day, + monitorId, + countMerge(count) AS count, + countMerge(success) AS ok, + countMerge(error) AS error, + 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 + -- TTL edge; the bound is under 46d + 1h for every zone (45 local days, one + -- of them 25h after a DST fall-back, plus today so far) and the TTL is 47d + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe new file mode 100644 index 000000000..41aa7d4c7 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_status_15m__v0.pipe @@ -0,0 +1,23 @@ +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, tz)) AS day, + monitorId, + countMerge(count) AS count, + countMerge(success) AS ok, + countMerge(error) AS error, + 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 + -- TTL edge; the bound is under 46d + 1h for every zone (45 local days, one + -- of them 25h after a DST fall-back, plus today so far) and the TTL is 47d + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe new file mode 100644 index 000000000..5f25e5a00 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__http_status_15m__v0.pipe @@ -0,0 +1,23 @@ +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, tz)) AS day, + monitorId, + countMerge(count) AS count, + countMerge(success) AS ok, + countMerge(error) AS error, + 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 + -- TTL edge; the bound is under 46d + 1h for every zone (45 local days, one + -- of them 25h after a DST fall-back, plus today so far) and the TTL is 47d + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe new file mode 100644 index 000000000..1964364aa --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_status_15m__v0.pipe @@ -0,0 +1,23 @@ +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, tz)) AS day, + monitorId, + countMerge(count) AS count, + countMerge(success) AS ok, + countMerge(error) AS error, + 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 + -- TTL edge; the bound is under 46d + 1h for every zone (45 local days, one + -- of them 25h after a DST fall-back, plus today so far) and the TTL is 47d + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe b/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe new file mode 100644 index 000000000..b63225dee --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__tcp_status_15m__v0.pipe @@ -0,0 +1,23 @@ +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, tz)) AS day, + monitorId, + countMerge(count) AS count, + countMerge(success) AS ok, + countMerge(error) AS error, + 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 + -- TTL edge; the bound is under 46d + 1h for every zone (45 local days, one + -- of them 25h after a DST fall-back, plus today so far) and the TTL is 47d + AND time >= toStartOfDay(toTimeZone(now(), tz)) - INTERVAL 45 DAY +GROUP BY day, monitorId +ORDER BY day DESC + +TYPE endpoint diff --git a/packages/tinybird/materializations/aggregate__dns_status_15m__v0.pipe b/packages/tinybird/materializations/aggregate__dns_status_15m__v0.pipe new file mode 100644 index 000000000..30b8977cb --- /dev/null +++ b/packages/tinybird/materializations/aggregate__dns_status_15m__v0.pipe @@ -0,0 +1,17 @@ +NODE aggregate +SQL > + + SELECT + toStartOfFifteenMinutes(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM dns_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__dns_status_15m__v0 diff --git a/packages/tinybird/materializations/aggregate__grpc_status_15m__v0.pipe b/packages/tinybird/materializations/aggregate__grpc_status_15m__v0.pipe new file mode 100644 index 000000000..e09ad94a7 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__grpc_status_15m__v0.pipe @@ -0,0 +1,17 @@ +NODE aggregate +SQL > + + SELECT + toStartOfFifteenMinutes(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM grpc_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__grpc_status_15m__v0 diff --git a/packages/tinybird/materializations/aggregate__http_status_15m__v0.pipe b/packages/tinybird/materializations/aggregate__http_status_15m__v0.pipe new file mode 100644 index 000000000..e27b3f29d --- /dev/null +++ b/packages/tinybird/materializations/aggregate__http_status_15m__v0.pipe @@ -0,0 +1,17 @@ +NODE aggregate +SQL > + + SELECT + toStartOfFifteenMinutes(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM ping_response__v8 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__http_status_15m__v0 diff --git a/packages/tinybird/materializations/aggregate__icmp_status_15m__v0.pipe b/packages/tinybird/materializations/aggregate__icmp_status_15m__v0.pipe new file mode 100644 index 000000000..96a25b801 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__icmp_status_15m__v0.pipe @@ -0,0 +1,17 @@ +NODE aggregate +SQL > + + SELECT + toStartOfFifteenMinutes(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM icmp_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__icmp_status_15m__v0 diff --git a/packages/tinybird/materializations/aggregate__tcp_status_15m__v0.pipe b/packages/tinybird/materializations/aggregate__tcp_status_15m__v0.pipe new file mode 100644 index 000000000..794c2928e --- /dev/null +++ b/packages/tinybird/materializations/aggregate__tcp_status_15m__v0.pipe @@ -0,0 +1,17 @@ +NODE aggregate +SQL > + + SELECT + toStartOfFifteenMinutes(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM tcp_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__tcp_status_15m__v0 diff --git a/packages/tinybird/src/client.test.ts b/packages/tinybird/src/client.test.ts index 17b015f40..b8e06cc9b 100644 --- a/packages/tinybird/src/client.test.ts +++ b/packages/tinybird/src/client.test.ts @@ -1,7 +1,14 @@ import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; +import { z } from "zod"; -import { noopFlagSchema, OSTinybird } from "./client"; +import { + isValidTimeZone, + noopFlagSchema, + OSTinybird, + status15mParameters, + status15mShape, +} from "./client"; describe("noopFlagSchema", () => { test("treats the string 'false' as disabled, not as a truthy string", () => { @@ -156,3 +163,107 @@ describe("response-log facet pipes", () => { }); } }); + +const status15mGetters = [ + "httpStatus15m", + "tcpStatus15m", + "dnsStatus15m", + "icmpStatus15m", + "grpcStatus15m", +] as const; + +describe("status15mParameters", () => { + test("defaults timezone to UTC", () => { + expect(status15mParameters.parse({ monitorIds: ["1"] })).toEqual({ + monitorIds: ["1"], + timezone: "UTC", + }); + }); + + test("forwards any zone untouched, fractional offsets included", () => { + for (const timezone of [ + "Europe/Berlin", + "Asia/Kolkata", + "Asia/Kathmandu", + ]) { + expect( + status15mParameters.parse({ monitorIds: ["1", "2"], timezone }) + .timezone, + ).toBe(timezone); + } + }); + + test("rejects zone names ClickHouse would 400 on, before the request", () => { + for (const timezone of ["Mars/Olympus", "", "GMT+1", "+05:30", "-03:00"]) { + const result = status15mParameters.safeParse({ + monitorIds: ["1"], + timezone, + }); + expect(result.success).toBe(false); + } + expect(isValidTimeZone("Etc/GMT+1")).toBe(true); + }); +}); + +describe("status15mShape", () => { + const shape = status15mShape(z.coerce.string()); + + test("re-keys the local calendar date as UTC midnight, like the 45d pipes", () => { + const row = shape.parse({ + day: "2026-10-14", + count: 10, + ok: 9, + degraded: 0, + error: 1, + monitorId: 1, + }); + expect(row.day).toBe("2026-10-14T00:00:00.000Z"); + // the key every day-indexed consumer derives must be the local date itself + expect(new Date(row.day).toISOString().split("T")[0]).toBe("2026-10-14"); + expect(row.monitorId).toBe("1"); + }); + + test("rejects a datetime or epoch `day`, which would re-introduce the UTC shift", () => { + for (const day of [ + "2026-10-13 22:00:00", + "2026-10-13T22:00:00.000Z", + 1760392800000, + ]) { + expect( + shape.safeParse({ + day, + count: 0, + ok: 0, + degraded: 0, + error: 0, + monitorId: "1", + }).success, + ).toBe(false); + } + }); + + test("defaults missing counters to 0", () => { + expect(shape.parse({ day: "2026-01-01", monitorId: "1" })).toMatchObject({ + count: 0, + ok: 0, + degraded: 0, + error: 0, + }); + }); +}); + +describe("15m status pipes", () => { + const tb = new OSTinybird({ token: "a-token" }); + + for (const name of status15mGetters) { + test(`${name} resolves for any zone and when timezone is omitted`, async () => { + expect( + await tb[name]({ monitorIds: ["1"], timezone: "Asia/Kolkata" }), + ).toEqual({ meta: [], data: [] }); + expect(await tb[name]({ monitorIds: ["1"] })).toEqual({ + meta: [], + data: [], + }); + }); + } +}); diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index 9acecf7b6..565acea63 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -14,6 +14,57 @@ 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 { + // Intl also takes `+05:30`-style offsets; ClickHouse only knows zone names. + if (/^[+-]/.test(timeZone)) return false; + 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. +export const status15mParameters = z.object({ + monitorIds: z.string().array(), + 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({ + day: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .transform((val) => new Date(`${val}T00:00:00.000Z`).toISOString()), + count: z.number().prefault(0), + ok: z.number().prefault(0), + degraded: z.number().prefault(0), + error: z.number().prefault(0), + monitorId, + }); +} + // 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 = { @@ -807,6 +858,16 @@ export class OSTinybird { }); } + /** 15m buckets regrouped into days of `timezone`; see {@link status15mShape}. */ + public get httpStatus15m() { + return this.tb.buildPipe({ + pipe: "endpoint__http_status_15m__v0", + parameters: status15mParameters, + data: status15mShape(z.string()), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get httpStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__http_status_45d__v1", @@ -1374,6 +1435,16 @@ export class OSTinybird { }); } + /** 15m buckets regrouped into days of `timezone`; see {@link status15mShape}. */ + public get tcpStatus15m() { + return this.tb.buildPipe({ + pipe: "endpoint__tcp_status_15m__v0", + parameters: status15mParameters, + data: status15mShape(z.coerce.string()), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get tcpStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__tcp_status_45d__v1", @@ -1774,6 +1845,16 @@ export class OSTinybird { }); } + /** 15m buckets regrouped into days of `timezone`; see {@link status15mShape}. */ + public get icmpStatus15m() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_status_15m__v0", + parameters: status15mParameters, + data: status15mShape(z.coerce.string()), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get icmpStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__icmp_status_45d__v0", @@ -2245,6 +2326,16 @@ export class OSTinybird { }); } + /** 15m buckets regrouped into days of `timezone`; see {@link status15mShape}. */ + public get grpcStatus15m() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_status_15m__v0", + parameters: status15mParameters, + data: status15mShape(z.coerce.string()), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get grpcStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__grpc_status_45d__v0", @@ -3531,6 +3622,16 @@ export class OSTinybird { }); } + /** 15m buckets regrouped into days of `timezone`; see {@link status15mShape}. */ + public get dnsStatus15m() { + return this.tb.buildPipe({ + pipe: "endpoint__dns_status_15m__v0", + parameters: status15mParameters, + data: status15mShape(z.coerce.string()), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get dnsStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__dns_status_45d__v0", -- 2.51.2